From 74d8a52caa38bb6bb41e6c9db3fb6d0d2e130c6c Mon Sep 17 00:00:00 2001 From: scalarbot Date: Thu, 5 Nov 2026 00:32:59 +0000 Subject: [PATCH 1/2] feat(api): update SDK surface (2 changes) - Regenerated with generator `0.38.0` (previously `0.37.1`). - Added optional body field `defaultSessionVisibilityByWorkspace` to `users.settings.update`. Build: nIVdmr95sKHYROyZwLBCP --- scalar-sdk.manifest.json | 8 +- src/tembo/_base_client.py | 158 +++++++++++++++++- src/tembo/_client.py | 47 +++++- src/tembo/_models.py | 14 +- src/tembo/_version.py | 2 +- src/tembo/resources/live/ws.py | 8 +- src/tembo/resources/users/settings.py | 8 +- src/tembo/types/session_stop_response.py | 24 ++- .../types/users/setting_retrieve_response.py | 4 + .../types/users/setting_update_params.py | 5 + .../types/users/setting_update_response.py | 4 + tests/smoke-test.py | 1 + 12 files changed, 255 insertions(+), 28 deletions(-) diff --git a/scalar-sdk.manifest.json b/scalar-sdk.manifest.json index 653bee36..88410ed8 100644 --- a/scalar-sdk.manifest.json +++ b/scalar-sdk.manifest.json @@ -1,7 +1,7 @@ { "name": "TemboSdk", "slug": "temboSdk", - "generatorVersion": "0.37.1", + "generatorVersion": "0.38.0", "servers": [ "https://api.tembo.io/" ], @@ -14181,6 +14181,7 @@ "headerParams": [], "publicHeaderParams": [], "bodyParams": [ + "defaultSessionVisibilityByWorkspace", "autoRestartVm", "defaultProjectId", "gpgKey", @@ -14202,6 +14203,7 @@ "customSystemPrompt" ], "publicBodyParams": [ + "defaultSessionVisibilityByWorkspace", "autoRestartVm", "defaultProjectId", "gpgKey", @@ -14223,6 +14225,10 @@ "customSystemPrompt" ], "bodyParamDetails": [ + { + "name": "defaultSessionVisibilityByWorkspace", + "required": false + }, { "name": "autoRestartVm", "required": false diff --git a/src/tembo/_base_client.py b/src/tembo/_base_client.py index cf3d8d51..e258ad20 100644 --- a/src/tembo/_base_client.py +++ b/src/tembo/_base_client.py @@ -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. @@ -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 @@ -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) diff --git a/src/tembo/_client.py b/src/tembo/_client.py index fd379bbf..20fdd759 100644 --- a/src/tembo/_client.py +++ b/src/tembo/_client.py @@ -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, ) @@ -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 @@ -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 diff --git a/src/tembo/_models.py b/src/tembo/_models.py index 4ae7a440..d385cd4a 100644 --- a/src/tembo/_models.py +++ b/src/tembo/_models.py @@ -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): @@ -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 diff --git a/src/tembo/_version.py b/src/tembo/_version.py index 75be0064..2423cff5 100644 --- a/src/tembo/_version.py +++ b/src/tembo/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Scalar. See README.md for details. __title__ = "temboSdk" -__version__ = "0.7.0" # x-release-please-version +__version__ = "0.8.0" # x-release-please-version diff --git a/src/tembo/resources/live/ws.py b/src/tembo/resources/live/ws.py index 5e174765..70da2df5 100644 --- a/src/tembo/resources/live/ws.py +++ b/src/tembo/resources/live/ws.py @@ -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, @@ -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, diff --git a/src/tembo/resources/users/settings.py b/src/tembo/resources/users/settings.py index 004a6819..660f7f07 100644 --- a/src/tembo/resources/users/settings.py +++ b/src/tembo/resources/users/settings.py @@ -4,7 +4,7 @@ import httpx -from typing import Optional +from typing import Dict, Optional from typing_extensions import Literal from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given @@ -79,6 +79,7 @@ def update( self, user_id: str, *, + default_session_visibility_by_workspace: Dict[str, Literal["private", "public"]] | Omit = omit, auto_restart_vm: bool | Omit = omit, default_project_id: Optional[str] | Omit = omit, gpg_key: str | Omit = omit, @@ -110,6 +111,7 @@ def update( Args: user_id: Path parameter. + default_session_visibility_by_workspace: Default visibility for your new sessions, keyed by workspace ID. auto_restart_vm: Automatically restart a session VM when needed. default_project_id: Default project environment for new sessions, or null to clear the preference. gpg_key: GPG signing key configuration. This value is returned by retrieveUserSettings. @@ -150,6 +152,7 @@ def update( path_template("/v1/users/{userId}/settings", **{"userId": user_id}), body=maybe_transform( { + "default_session_visibility_by_workspace": default_session_visibility_by_workspace, "auto_restart_vm": auto_restart_vm, "default_project_id": default_project_id, "gpg_key": gpg_key, @@ -233,6 +236,7 @@ async def update( self, user_id: str, *, + default_session_visibility_by_workspace: Dict[str, Literal["private", "public"]] | Omit = omit, auto_restart_vm: bool | Omit = omit, default_project_id: Optional[str] | Omit = omit, gpg_key: str | Omit = omit, @@ -264,6 +268,7 @@ async def update( Args: user_id: Path parameter. + default_session_visibility_by_workspace: Default visibility for your new sessions, keyed by workspace ID. auto_restart_vm: Automatically restart a session VM when needed. default_project_id: Default project environment for new sessions, or null to clear the preference. gpg_key: GPG signing key configuration. This value is returned by retrieveUserSettings. @@ -304,6 +309,7 @@ async def update( path_template("/v1/users/{userId}/settings", **{"userId": user_id}), body=await async_maybe_transform( { + "default_session_visibility_by_workspace": default_session_visibility_by_workspace, "auto_restart_vm": auto_restart_vm, "default_project_id": default_project_id, "gpg_key": gpg_key, diff --git a/src/tembo/types/session_stop_response.py b/src/tembo/types/session_stop_response.py index 970e9143..0b2de0c4 100644 --- a/src/tembo/types/session_stop_response.py +++ b/src/tembo/types/session_stop_response.py @@ -1,10 +1,29 @@ # File generated from our OpenAPI spec by Scalar. See README.md for details. +from __future__ import annotations + +from typing import Optional +from typing_extensions import Literal + from pydantic import Field as FieldInfo from .._models import BaseModel -__all__ = ["SessionStopResponse"] +__all__ = ["SessionStopResponse", "State"] + + +class State(BaseModel): + current: Optional[Literal["inProgress", "queued", "completed", "failed", "cancelled"]] = None + + is_queued: bool = FieldInfo(alias="isQueued") + + is_cancelled: bool = FieldInfo(alias="isCancelled") + + is_completed: bool = FieldInfo(alias="isCompleted") + + is_failed: bool = FieldInfo(alias="isFailed") + + in_progress: bool = FieldInfo(alias="inProgress") class SessionStopResponse(BaseModel): @@ -17,3 +36,6 @@ class SessionStopResponse(BaseModel): interrupted_runtime_turns: int = FieldInfo(alias="interruptedRuntimeTurns") cancelled_runtime_turns: int = FieldInfo(alias="cancelledRuntimeTurns") + + state: Optional[State] = None + """Canonical session state after stopping and reconciling queued work.""" diff --git a/src/tembo/types/users/setting_retrieve_response.py b/src/tembo/types/users/setting_retrieve_response.py index ab0d8060..34f5e961 100644 --- a/src/tembo/types/users/setting_retrieve_response.py +++ b/src/tembo/types/users/setting_retrieve_response.py @@ -179,6 +179,10 @@ class SettingRetrieveResponse(BaseModel): default_project_id: Optional[str] = FieldInfo(alias="defaultProjectId", default=None) + default_session_visibility_by_workspace: Optional[Dict[str, Literal["private", "public"]]] = FieldInfo( + alias="defaultSessionVisibilityByWorkspace", default=None + ) + gpg_key: Optional[str] = FieldInfo(alias="gpgKey", default=None) include_tembo_commit_attribution: Optional[bool] = FieldInfo(alias="includeTemboCommitAttribution", default=None) diff --git a/src/tembo/types/users/setting_update_params.py b/src/tembo/types/users/setting_update_params.py index 90dcc727..3a49bbd6 100644 --- a/src/tembo/types/users/setting_update_params.py +++ b/src/tembo/types/users/setting_update_params.py @@ -26,6 +26,11 @@ class SettingUpdateParams(TypedDict, total=False): + default_session_visibility_by_workspace: Annotated[ + Dict[str, Literal["private", "public"]], PropertyInfo(alias="defaultSessionVisibilityByWorkspace") + ] + """Default visibility for your new sessions, keyed by workspace ID.""" + auto_restart_vm: Annotated[bool, PropertyInfo(alias="autoRestartVm")] """Automatically restart a session VM when needed.""" diff --git a/src/tembo/types/users/setting_update_response.py b/src/tembo/types/users/setting_update_response.py index 8428d40d..2341d190 100644 --- a/src/tembo/types/users/setting_update_response.py +++ b/src/tembo/types/users/setting_update_response.py @@ -179,6 +179,10 @@ class SettingUpdateResponse(BaseModel): default_project_id: Optional[str] = FieldInfo(alias="defaultProjectId", default=None) + default_session_visibility_by_workspace: Optional[Dict[str, Literal["private", "public"]]] = FieldInfo( + alias="defaultSessionVisibilityByWorkspace", default=None + ) + gpg_key: Optional[str] = FieldInfo(alias="gpgKey", default=None) include_tembo_commit_attribution: Optional[bool] = FieldInfo(alias="includeTemboCommitAttribution", default=None) diff --git a/tests/smoke-test.py b/tests/smoke-test.py index 8f42ed7b..4f9e7c7e 100644 --- a/tests/smoke-test.py +++ b/tests/smoke-test.py @@ -682,6 +682,7 @@ def _smoke_case_80() -> None: def _smoke_case_81() -> None: setting = client.users.settings.update( user_id="userId", + default_session_visibility_by_workspace={}, auto_restart_vm=False, default_project_id="x", gpg_key="", From 8f480527baf13875c9bb7de7b1d13f17f7b7f99f Mon Sep 17 00:00:00 2001 From: "scalar-docs[bot]" <148485328+scalar-docs[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:33:15 +0000 Subject: [PATCH 2/2] release: 0.9.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- src/tembo/_version.py | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 6538ca91..6d78745c 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.8.0" + ".": "0.9.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index e42497ba..1eb79455 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/pyproject.toml b/pyproject.toml index c2f7c85d..92d95d90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/tembo/_version.py b/src/tembo/_version.py index 2423cff5..d9746fbd 100644 --- a/src/tembo/_version.py +++ b/src/tembo/_version.py @@ -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