Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/sdk/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import yaml

from sdk.core.auth import AuthConfig, AuthMode
from sdk.core.exceptions import InvalidInputError

logger = logging.getLogger(__name__)

Expand All @@ -30,11 +31,11 @@ def load_from_yaml(cloud_name: str = "otc", file_path: str | Path | None = None)
try:
data = yaml.safe_load(f)
except yaml.YAMLError as e:
raise ValueError(f"Failed to parse YAML file at {path_to_load}: {e}") from e
raise InvalidInputError("clouds.yaml", str(path_to_load)) from e

clouds = data.get("clouds", {})
if cloud_name not in clouds:
raise ValueError(f"Cloud '{cloud_name}' not found in {path_to_load}")
raise InvalidInputError("clouds.yaml", str(path_to_load))

cloud_config = clouds[cloud_name]
auth_data = cloud_config.get("auth", {})
Expand Down
10 changes: 10 additions & 0 deletions src/sdk/core/exceptions/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,16 @@ def _format_message(self) -> str:
msg += f"\n{self.body}"
return msg

class MalformedResponseError(SDKError):
"""API returned 2xx but the body doesn't match the expected shape.

Args:
detail: What exactly was wrong with the response.
"""

def __init__(self, detail: str) -> None:
self.detail = detail
super().__init__(f"Malformed API response: {detail}")

# --- Status-code specific errors ---

Expand Down
7 changes: 3 additions & 4 deletions src/sdk/core/opts.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

from pydantic import BaseModel

from sdk.core.exceptions import InvalidInputError


class BaseOpts(BaseModel):
"""Base class for request body options.
Expand Down Expand Up @@ -50,10 +52,7 @@ def to_query_params(self) -> dict[str, str]:
if value == "":
continue
if isinstance(value, (dict, list)):
raise TypeError(
f"Query param {key!r} has non-scalar value {value!r}; "
"query strings only support scalar values."
)
raise InvalidInputError(key, value)
if isinstance(value, bool):
params[key] = "true" if value else "false"
else:
Expand Down
125 changes: 106 additions & 19 deletions src/sdk/core/pagination.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,40 @@

from __future__ import annotations
from pydantic import BaseModel
from typing import TypeVar
from collections.abc import Generator
from typing import Any
from typing import Any, overload, TypeVar
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse, urljoin

from sdk.core.exceptions import InvalidInputError
from sdk.core.exceptions.response import MalformedResponseError
from sdk.core.service_client import ServiceClient

T = TypeVar("T", bound=BaseModel)
PaginatedItem = T | dict[str, Any]

@overload
def marker_paginate(
client: ServiceClient,
path: str,
*,
items_key: str,
model: type[T],
marker_key: str = ...,
limit: int = ...,
params: dict[str, str] | None = ...,
) -> Generator[T, None, None]: ...


@overload
def marker_paginate(
client: ServiceClient,
path: str,
*,
items_key: str,
model: None = ...,
marker_key: str = ...,
limit: int = ...,
params: dict[str, str] | None = ...,
) -> Generator[dict[str, Any], None, None]: ...

def marker_paginate(
client: ServiceClient,
Expand All @@ -49,12 +74,12 @@ def marker_paginate(
marker_key: str = "id",
limit: int = 0,
params: dict[str, str] | None = None,
) -> Generator[PaginatedItem, None, None]:
) -> Generator[Any, None, None]:
"""Paginate using marker-based strategy.

Fetches pages by setting ``marker`` query param to the last
item's ``marker_key`` value. Stops when a page returns
fewer items than ``limit`` or an empty list.
item's ``marker_key`` value. Stops on an empty page, a missing/empty marker on the last item,
or a repeated marker.

Args:
client: Service client to send requests through.
Expand Down Expand Up @@ -86,20 +111,43 @@ def marker_paginate(
for item in items:
yield model.model_validate(item) if model else item

if limit and len(items) < limit:
return
last = items[-1]
raw_marker = last.get(marker_key)

if raw_marker is None or raw_marker == "":
return

marker_str = str(raw_marker)
if query.get("marker") == marker_str:
return
# if query.get("marker") == marker_str:
# return

query["marker"] = marker_str

@overload
def offset_paginate(
client: ServiceClient,
path: str,
*,
items_key: str,
model: type[T],
limit: int,
start_offset: int = ...,
params: dict[str, str] | None = ...,
) -> Generator[T, None, None]: ...


@overload
def offset_paginate(
client: ServiceClient,
path: str,
*,
items_key: str,
model: None = ...,
limit: int,
start_offset: int = ...,
params: dict[str, str] | None = ...,
) -> Generator[dict[str, Any], None, None]: ...

def offset_paginate(
client: ServiceClient,
path: str,
Expand All @@ -109,7 +157,7 @@ def offset_paginate(
limit: int,
start_offset: int = 0,
params: dict[str, str] | None = None,
) -> Generator[PaginatedItem, None, None]:
) -> Generator[Any, None, None]:
"""Paginate using offset-based strategy.

Increments ``offset`` by ``limit`` on each page. Stops when
Expand All @@ -133,7 +181,7 @@ def offset_paginate(
otherwise raw resource dicts.
"""
if limit <= 0:
raise ValueError("Limit must be strictly positive for offset pagination.")
raise InvalidInputError("limit", limit)
query: dict[str, str] = dict(params) if params else {}
query["limit"] = str(limit)
offset = start_offset
Expand All @@ -149,11 +197,30 @@ def offset_paginate(
for item in items:
yield model.model_validate(item) if model else item

if len(items) < limit:
return
offset += len(items)

@overload
def linked_paginate(
client: ServiceClient,
path: str,
*,
items_key: str,
model: type[T],
link_path: list[str] | None = ...,
params: dict[str, str] | None = ...,
) -> Generator[T, None, None]: ...

offset += limit

@overload
def linked_paginate(
client: ServiceClient,
path: str,
*,
items_key: str,
model: None = ...,
link_path: list[str] | None = ...,
params: dict[str, str] | None = ...,
) -> Generator[dict[str, Any], None, None]: ...

def linked_paginate(
client: ServiceClient,
Expand All @@ -163,7 +230,7 @@ def linked_paginate(
model: type[T] | None = None,
link_path: list[str] | None = None,
params: dict[str, str] | None = None,
) -> Generator[PaginatedItem, None, None]:
) -> Generator[Any, None, None]:
"""Paginate using linked (next URL) strategy.

Follows a ``next`` link embedded in the response body.
Expand Down Expand Up @@ -211,6 +278,26 @@ def linked_paginate(
return
url = urljoin(url, next_url)

@overload
def single_page(
client: ServiceClient,
path: str,
*,
items_key: str,
model: type[T],
params: dict[str, str] | None = ...,
) -> list[T]: ...


@overload
def single_page(
client: ServiceClient,
path: str,
*,
items_key: str,
model: None = ...,
params: dict[str, str] | None = ...,
) -> list[dict[str, Any]]: ...

def single_page(
client: ServiceClient,
Expand All @@ -219,7 +306,7 @@ def single_page(
items_key: str,
model: type[T] | None = None,
params: dict[str, str] | None = None,
) -> list[PaginatedItem]:
) -> list[Any]:
"""Fetch a single (non-paginated) list response.

Convenience wrapper for endpoints that return all items
Expand Down Expand Up @@ -311,6 +398,6 @@ def _fetch_page(
data = resp.json()

if items_key not in data:
raise ValueError(f"Expected key '{items_key}' not found in API response")

raise MalformedResponseError(
f"expected key '{items_key}' not found in list response")
return data, data[items_key]
56 changes: 23 additions & 33 deletions src/sdk/core/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
from sdk.core.exceptions import (
ReauthError,
UnauthorizedError,
raise_for_status
raise_for_status, AuthError, ResourceNotFoundError
)
from sdk.core.signer import SignOptions, sign_request

Expand Down Expand Up @@ -183,9 +183,8 @@ def authenticate(self) -> None:
else:
self._aksk_auth()
if self.endpoint_locator is None:
raise RuntimeError(
"Endpoint locator not initialized after authentication"
)
raise AuthError(
"Endpoint locator not initialized after authentication")

def request(
self,
Expand Down Expand Up @@ -607,42 +606,33 @@ def _fetch_catalog(self) -> list[CatalogEntry]:
raw_catalog = resp.json().get("catalog", [])
return [CatalogEntry.model_validate(entry) for entry in raw_catalog]

def _resolve_project_id(self, name: str) -> str:
"""Look up project ID by name via IAM API.

Args:
name: Project name.

Returns:
Project ID string.
def _resolve_named_id(
self, *, resource: str, url_suffix: str, items_key: str, name: str
) -> str:
"""Look up a resource ID by name via the IAM API.

Raises:
EndpointNotFoundError: If no project is found.
ResourceNotFoundError: If nothing matches ``name``.
"""
resp = self._iam_request(
"GET",
self.identity_v3_endpoint + f"projects?name={name}",
"GET", self.identity_v3_endpoint + f"{url_suffix}?name={name}"
)
data = resp.json()
projects = data.get("projects", [])
if not projects:
raise ValueError(f"Project with name '{name}' not found")
return projects[0]["id"]

def _resolve_domain_id(self, name: str) -> str:
"""Look up domain ID by name via IAM API.
items = resp.json().get(items_key, [])
if not items:
raise ResourceNotFoundError(resource, name)
return items[0]["id"]

Args:
name: Domain name.
def _resolve_project_id(self, name: str) -> str:
return self._resolve_named_id(
resource="Project", url_suffix="projects",
items_key="projects", name=name,
)

Returns:
Domain ID string, or empty string if not found.
"""
resp = self._iam_request("GET", self.identity_v3_endpoint + f"auth/domains?name={name}")
domains = resp.json().get("domains", [])
if not domains:
raise ValueError(f"Domain with name '{name}' not found")
return domains[0]["id"]
def _resolve_domain_id(self, name: str) -> str:
return self._resolve_named_id(
resource="Domain", url_suffix="auth/domains",
items_key="domains", name=name,
)

# ======================================================================
# Module-level helpers
Expand Down
Loading