Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "0.8.0"
".": "0.9.0"
}
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## [0.9.0](https://github.com/tembo/sdk-python/compare/v0.8.0...v0.9.0) (2026-11-05)


### Features

* **api:** update SDK surface (2 changes) ([74d8a52](https://github.com/tembo/sdk-python/commit/74d8a52caa38bb6bb41e6c9db3fb6d0d2e130c6c))

## [0.8.0](https://github.com/tembo/sdk-python/compare/v0.7.0...v0.8.0) (2026-11-03)


Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "tembo-sdk-python"
version = "0.8.0" # x-release-please-version
version = "0.9.0" # x-release-please-version
description = "Manage agents, sessions, repositories, and organization resources with the Tembo API."
readme = "README.md"
license = "Apache-2.0"
Expand Down
8 changes: 7 additions & 1 deletion scalar-sdk.manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "TemboSdk",
"slug": "temboSdk",
"generatorVersion": "0.37.1",
"generatorVersion": "0.38.0",
"servers": [
"https://api.tembo.io/"
],
Expand Down Expand Up @@ -14181,6 +14181,7 @@
"headerParams": [],
"publicHeaderParams": [],
"bodyParams": [
"defaultSessionVisibilityByWorkspace",
"autoRestartVm",
"defaultProjectId",
"gpgKey",
Expand All @@ -14202,6 +14203,7 @@
"customSystemPrompt"
],
"publicBodyParams": [
"defaultSessionVisibilityByWorkspace",
"autoRestartVm",
"defaultProjectId",
"gpgKey",
Expand All @@ -14223,6 +14225,10 @@
"customSystemPrompt"
],
"bodyParamDetails": [
{
"name": "defaultSessionVisibilityByWorkspace",
"required": false
},
{
"name": "autoRestartVm",
"required": false
Expand Down
158 changes: 153 additions & 5 deletions src/tembo/_base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,39 @@ async def read() -> None:
response._content = body.content() # pyright: ignore[reportPrivateUsage]


@dataclass
class SchemeCredentials:
"""The credential one security scheme puts on a request, split by where it is sent."""

headers: Dict[str, str] = field(default_factory=dict)
query: Dict[str, str] = field(default_factory=dict)
cookies: Dict[str, str] = field(default_factory=dict)

@property
def configured(self) -> bool:
"""Whether the scheme's client option(s) are set, so it has a credential to send."""
return bool(self.headers or self.query or self.cookies)


def _merge_scheme_credentials(parts: List[SchemeCredentials]) -> SchemeCredentials:
"""The credentials of several schemes sent together, one request's worth."""
merged = SchemeCredentials()
for part in parts:
merged.headers.update(part.headers)
merged.query.update(part.query)
merged.cookies.update(part.cookies)
return merged


@dataclass(frozen=True)
class AuthLocations:
"""The lower-cased header names, and the query and cookie names, the client's credentials use."""

headers: frozenset[str] = frozenset()
query: frozenset[str] = frozenset()
cookies: frozenset[str] = frozenset()


@dataclass
class CursorPageConfig:
"""Per-operation cursor pagination descriptor baked into a page by the generated list method.
Expand Down Expand Up @@ -894,21 +927,106 @@ def _make_status_error(
) -> _exceptions.APIStatusError:
raise NotImplementedError()

@property
def _auth_by_scheme(self) -> dict[str, SchemeCredentials]:
"""The credentials each security scheme is sent with, by the scheme's name in the document."""
return {}

def _select_auth(self, security: SecurityOptions) -> SchemeCredentials:
"""
The credentials an operation with its own `security` requirements is sent with.

The first alternative whose schemes all have a configured credential wins, and only its
credentials are sent: two schemes can share a wire slot (`Authorization` for Basic and
Bearer), so merging every configured credential would let one overwrite the one the
operation asked for. An empty alternative is the anonymous one and selects nothing.

With no alternative met, the configured part of the first alternative that has one is sent:
the request only goes out at all when the caller supplies the rest some other way (see
`_credential_supplied`), and withholding the half the client holds would leave it incomplete.
An empty (anonymous) alternative counts as met, so an operation that also accepts `{}` is sent
nothing rather than a partial credential. Only schemes the operation names are ever sent.
"""
available = self._auth_by_scheme
partial: SchemeCredentials | None = None
for alternative in security:
parts = [available.get(scheme) for scheme in alternative]
configured = [part for part in parts if part is not None and part.configured]
if len(configured) == len(parts):
return _merge_scheme_credentials(configured)
if partial is None and configured:
partial = _merge_scheme_credentials(configured)
return partial if partial is not None else SchemeCredentials()

@property
def _auth_locations(self) -> AuthLocations:
"""Where the client's credentials travel, configured or not."""
return AuthLocations()

def _credential_supplied(
self,
security: SecurityOptions,
headers: Mapping[str, str],
custom_headers: Headers,
params: Query,
cookies: Mapping[str, str],
) -> bool:
"""
Whether the request already carries a credential the caller set some other way than a client
option — the client's `default_headers` or `default_query`, or the call's `extra_headers` or
`extra_query` — or omits a credential header on purpose, as the client-wide check accepts.

A value `_select_auth` put on the request is the client's own partial credential, not the
caller's, so it never counts: a half-configured alternative is refused unless the caller
supplies the rest.
"""
locations = self._auth_locations
selected = self._select_auth(security)
selected_headers = {name.lower(): value for name, value in selected.headers.items()}
return (
any(
name.lower() in locations.headers and value and selected_headers.get(name.lower()) != value
for name, value in headers.items()
)
or any(
name.lower() in locations.headers and isinstance(value, Omit) for name, value in custom_headers.items()
)
or any(
params.get(name) is not None and params.get(name) != selected.query.get(name)
for name in locations.query
)
or any(
cookies.get(name) is not None and cookies.get(name) != selected.cookies.get(name)
for name in locations.cookies
)
)

def _security_met(self, security: SecurityOptions) -> bool:
"""
Whether an operation's own requirements can be met: it is public (`[]`), lists the anonymous
alternative (an empty one), or the credentials of some alternative are all configured.
"""
available = self._auth_by_scheme
return not security or any(
all((part := available.get(scheme)) is not None and part.configured for scheme in alternative)
for alternative in security
)

def _auth_query(
self,
security: SecurityOptions, # noqa: ARG002
security: SecurityOptions | None, # noqa: ARG002
) -> dict[str, str]:
return {}

def _auth_cookies(
self,
security: SecurityOptions, # noqa: ARG002
security: SecurityOptions | None, # noqa: ARG002
) -> dict[str, str]:
return {}

def _custom_auth(
self,
security: SecurityOptions, # noqa: ARG002
security: SecurityOptions | None, # noqa: ARG002
) -> httpx.Auth | None:
# Single hook for custom httpx auth on both the sync and async request paths. It defaults to
# the legacy public `custom_auth` property so subclasses that already override that property
Expand All @@ -927,8 +1045,38 @@ def _build_headers(
custom_headers = options.headers or {}
# Auth headers are folded into `default_headers` (matching the reference SDKs), so
# merging `default_headers` here already applies the configured credentials.
headers_dict = _merge_mappings(self.default_headers, custom_headers)
self._validate_headers(headers_dict, custom_headers, params, cookies)
default_headers = self.default_headers
if options.security is not None:
# The operation names its own requirements: swap the client-wide credential headers for
# the selected alternative's, keeping any header the caller configured on the client.
auth_names = {name.lower() for name in self.auth_headers}
custom_names = {name.lower() for name in self._custom_headers}
default_headers = {
**{
name: value
for name, value in default_headers.items()
if name.lower() not in auth_names or name.lower() in custom_names
},
**self._select_auth(options.security).headers,
**self._custom_headers,
}
headers_dict = _merge_mappings(default_headers, custom_headers)
if options.security is None:
# The client-wide check that some credential is configured.
self._validate_headers(headers_dict, custom_headers, params, cookies)
elif not self._security_met(options.security) and not self._credential_supplied(
options.security, headers_dict, custom_headers, params, cookies
):
# An operation naming its own requirements is checked against those alone: a public one
# or one met by a query key is never refused for want of a header, and the client-wide
# check would both pass a document whose root allows anonymous calls and name credentials
# that cannot meet this operation. A credential the caller supplies outside the client's
# options takes the place of a configured one; the selected credentials are already
# swapped in above, so an unselected configured one does not count.
alternatives = " or ".join(" + ".join(alternative) for alternative in options.security)
raise TypeError(
f"Could not resolve authentication method for this operation. Expected the credentials of {alternatives} to be set."
)

# headers are case-insensitive while dictionaries are not.
headers = httpx.Headers(headers_dict)
Expand Down
47 changes: 39 additions & 8 deletions src/tembo/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,11 @@
from ._utils import is_given, is_mapping_t, get_async_library
from ._compat import cached_property
from ._exceptions import APIStatusError, TemboError
from ._models import SecurityOptions
from ._base_client import (
DEFAULT_MAX_RETRIES,
AuthLocations,
SchemeCredentials,
SyncAPIClient,
AsyncAPIClient,
)
Expand Down Expand Up @@ -291,15 +294,29 @@ def auth_headers(self) -> dict[str, str]:
}

@override
def _auth_query(self, security: dict[str, bool]) -> dict[str, str]:
_ = security
def _auth_query(self, security: SecurityOptions | None) -> dict[str, str]:
if security is not None:
return self._select_auth(security).query
return {}

@override
def _auth_cookies(self, security: dict[str, bool]) -> dict[str, str]:
_ = security
def _auth_cookies(self, security: SecurityOptions | None) -> dict[str, str]:
if security is not None:
return self._select_auth(security).cookies
return {}

@property
@override
def _auth_by_scheme(self) -> dict[str, SchemeCredentials]:
return {
"apiKey": SchemeCredentials(headers=self._api_key_header_auth),
}

@property
@override
def _auth_locations(self) -> AuthLocations:
return AuthLocations(headers=frozenset({"authorization"}))

@property
def _api_key_header_auth(self) -> dict[str, str]:
value = self.api_key
Expand Down Expand Up @@ -609,15 +626,29 @@ def auth_headers(self) -> dict[str, str]:
}

@override
def _auth_query(self, security: dict[str, bool]) -> dict[str, str]:
_ = security
def _auth_query(self, security: SecurityOptions | None) -> dict[str, str]:
if security is not None:
return self._select_auth(security).query
return {}

@override
def _auth_cookies(self, security: dict[str, bool]) -> dict[str, str]:
_ = security
def _auth_cookies(self, security: SecurityOptions | None) -> dict[str, str]:
if security is not None:
return self._select_auth(security).cookies
return {}

@property
@override
def _auth_by_scheme(self) -> dict[str, SchemeCredentials]:
return {
"apiKey": SchemeCredentials(headers=self._api_key_header_auth),
}

@property
@override
def _auth_locations(self) -> AuthLocations:
return AuthLocations(headers=frozenset({"authorization"}))

@property
def _api_key_header_auth(self) -> dict[str, str]:
value = self.api_key
Expand Down
14 changes: 7 additions & 7 deletions src/tembo/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -874,9 +874,12 @@ def _create_pydantic_model(type_: _T) -> Type[RootModel[_T]]:
return RootModel[type_] # type: ignore


class SecurityOptions(TypedDict, total=False):
api_key_auth: bool
bearer_auth: bool
# An operation's own security requirements, by security scheme name: alternatives (any one
# suffices), each the schemes sent together. The client sends only the schemes these name: the
# first alternative whose credentials are all configured, else the configured part of the first
# alternative that has one. An empty list sends no credential. `None` is an operation that inherits
# the client's default and is sent every configured credential.
SecurityOptions: TypeAlias = List[List[str]]


class FinalRequestOptionsInput(TypedDict, total=False):
Expand Down Expand Up @@ -907,10 +910,7 @@ class FinalRequestOptions(pydantic.BaseModel):
idempotency_key: Union[str, None] = None
post_parser: Union[Callable[[Any], Any], NotGiven] = NotGiven()
follow_redirects: Union[bool, None] = None
security: SecurityOptions = {
"api_key_auth": True,
"bearer_auth": True,
}
security: Union[SecurityOptions, None] = None

content: Union[bytes, bytearray, IO[bytes], Iterable[bytes], AsyncIterable[bytes], None] = None
# It should be noted that we cannot use `json` here as that would override
Expand Down
2 changes: 1 addition & 1 deletion src/tembo/_version.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# File generated from our OpenAPI spec by Scalar. See README.md for details.

__title__ = "temboSdk"
__version__ = "0.8.0" # x-release-please-version
__version__ = "0.9.0" # x-release-please-version
8 changes: 4 additions & 4 deletions src/tembo/resources/live/ws.py
Original file line number Diff line number Diff line change
Expand Up @@ -581,12 +581,12 @@ def _connect_ws(self, extra_query: Query, extra_headers: Headers) -> WebSocketCo
params = _merge_mappings(
{
**url.params,
**self.__client._auth_query({}),
**self.__client._auth_query(None),
**self.__client.default_query,
},
extra_query,
)
cookies = self.__client._auth_cookies({})
cookies = self.__client._auth_cookies(None)
headers = _merge_mappings(
{
**self.__client.auth_headers,
Expand Down Expand Up @@ -729,12 +729,12 @@ async def _connect_ws(self, extra_query: Query, extra_headers: Headers) -> Async
params = _merge_mappings(
{
**url.params,
**self.__client._auth_query({}),
**self.__client._auth_query(None),
**self.__client.default_query,
},
extra_query,
)
cookies = self.__client._auth_cookies({})
cookies = self.__client._auth_cookies(None)
headers = _merge_mappings(
{
**self.__client.auth_headers,
Expand Down
Loading
Loading