From 02118eb270c01f69319efb8b8c4b48cb0f477ff7 Mon Sep 17 00:00:00 2001 From: Marcus Schiesser Date: Wed, 21 Oct 2026 19:20:46 +0700 Subject: [PATCH 1/5] refactor: move the mixed sync/async modules under _async/ Pure git mv of each module that holds both an Async* class and its sync twin to its path under introspection_sdk/_async/, with no content change, so the next commits show as edits of the moved files. The tree does not import at this commit. Co-Authored-By: Claude Opus 5.5 --- introspection_sdk/{ => _async}/_http.py | 0 introspection_sdk/{ => _async}/auth.py | 0 introspection_sdk/{ => _async}/client.py | 0 introspection_sdk/{ => _async}/protocols.py | 0 introspection_sdk/{ => _async}/proxy/_transport.py | 0 introspection_sdk/{ => _async}/proxy/httpx.py | 0 introspection_sdk/{ => _async}/proxy/httpx2.py | 0 introspection_sdk/{ => _async}/resources/connectors.py | 0 introspection_sdk/{ => _async}/resources/experiments.py | 0 introspection_sdk/{ => _async}/resources/members.py | 0 introspection_sdk/{ => _async}/resources/recipes.py | 0 introspection_sdk/{ => _async}/resources/repositories.py | 0 introspection_sdk/{ => _async}/resources/runtimes.py | 0 introspection_sdk/{ => _async}/resources/tags.py | 0 introspection_sdk/{ => _async}/runner.py | 0 introspection_sdk/{ => _async}/runner_resources/_reads.py | 0 introspection_sdk/{ => _async}/runner_resources/automations.py | 0 introspection_sdk/{ => _async}/runner_resources/connections.py | 0 introspection_sdk/{ => _async}/runner_resources/conversations.py | 0 introspection_sdk/{ => _async}/runner_resources/events.py | 0 introspection_sdk/{ => _async}/runner_resources/files.py | 0 introspection_sdk/{ => _async}/runner_resources/metrics.py | 0 introspection_sdk/{ => _async}/runner_resources/shares.py | 0 introspection_sdk/{ => _async}/runner_resources/tasks.py | 0 introspection_sdk/{ => _async}/streaming.py | 0 25 files changed, 0 insertions(+), 0 deletions(-) rename introspection_sdk/{ => _async}/_http.py (100%) rename introspection_sdk/{ => _async}/auth.py (100%) rename introspection_sdk/{ => _async}/client.py (100%) rename introspection_sdk/{ => _async}/protocols.py (100%) rename introspection_sdk/{ => _async}/proxy/_transport.py (100%) rename introspection_sdk/{ => _async}/proxy/httpx.py (100%) rename introspection_sdk/{ => _async}/proxy/httpx2.py (100%) rename introspection_sdk/{ => _async}/resources/connectors.py (100%) rename introspection_sdk/{ => _async}/resources/experiments.py (100%) rename introspection_sdk/{ => _async}/resources/members.py (100%) rename introspection_sdk/{ => _async}/resources/recipes.py (100%) rename introspection_sdk/{ => _async}/resources/repositories.py (100%) rename introspection_sdk/{ => _async}/resources/runtimes.py (100%) rename introspection_sdk/{ => _async}/resources/tags.py (100%) rename introspection_sdk/{ => _async}/runner.py (100%) rename introspection_sdk/{ => _async}/runner_resources/_reads.py (100%) rename introspection_sdk/{ => _async}/runner_resources/automations.py (100%) rename introspection_sdk/{ => _async}/runner_resources/connections.py (100%) rename introspection_sdk/{ => _async}/runner_resources/conversations.py (100%) rename introspection_sdk/{ => _async}/runner_resources/events.py (100%) rename introspection_sdk/{ => _async}/runner_resources/files.py (100%) rename introspection_sdk/{ => _async}/runner_resources/metrics.py (100%) rename introspection_sdk/{ => _async}/runner_resources/shares.py (100%) rename introspection_sdk/{ => _async}/runner_resources/tasks.py (100%) rename introspection_sdk/{ => _async}/streaming.py (100%) diff --git a/introspection_sdk/_http.py b/introspection_sdk/_async/_http.py similarity index 100% rename from introspection_sdk/_http.py rename to introspection_sdk/_async/_http.py diff --git a/introspection_sdk/auth.py b/introspection_sdk/_async/auth.py similarity index 100% rename from introspection_sdk/auth.py rename to introspection_sdk/_async/auth.py diff --git a/introspection_sdk/client.py b/introspection_sdk/_async/client.py similarity index 100% rename from introspection_sdk/client.py rename to introspection_sdk/_async/client.py diff --git a/introspection_sdk/protocols.py b/introspection_sdk/_async/protocols.py similarity index 100% rename from introspection_sdk/protocols.py rename to introspection_sdk/_async/protocols.py diff --git a/introspection_sdk/proxy/_transport.py b/introspection_sdk/_async/proxy/_transport.py similarity index 100% rename from introspection_sdk/proxy/_transport.py rename to introspection_sdk/_async/proxy/_transport.py diff --git a/introspection_sdk/proxy/httpx.py b/introspection_sdk/_async/proxy/httpx.py similarity index 100% rename from introspection_sdk/proxy/httpx.py rename to introspection_sdk/_async/proxy/httpx.py diff --git a/introspection_sdk/proxy/httpx2.py b/introspection_sdk/_async/proxy/httpx2.py similarity index 100% rename from introspection_sdk/proxy/httpx2.py rename to introspection_sdk/_async/proxy/httpx2.py diff --git a/introspection_sdk/resources/connectors.py b/introspection_sdk/_async/resources/connectors.py similarity index 100% rename from introspection_sdk/resources/connectors.py rename to introspection_sdk/_async/resources/connectors.py diff --git a/introspection_sdk/resources/experiments.py b/introspection_sdk/_async/resources/experiments.py similarity index 100% rename from introspection_sdk/resources/experiments.py rename to introspection_sdk/_async/resources/experiments.py diff --git a/introspection_sdk/resources/members.py b/introspection_sdk/_async/resources/members.py similarity index 100% rename from introspection_sdk/resources/members.py rename to introspection_sdk/_async/resources/members.py diff --git a/introspection_sdk/resources/recipes.py b/introspection_sdk/_async/resources/recipes.py similarity index 100% rename from introspection_sdk/resources/recipes.py rename to introspection_sdk/_async/resources/recipes.py diff --git a/introspection_sdk/resources/repositories.py b/introspection_sdk/_async/resources/repositories.py similarity index 100% rename from introspection_sdk/resources/repositories.py rename to introspection_sdk/_async/resources/repositories.py diff --git a/introspection_sdk/resources/runtimes.py b/introspection_sdk/_async/resources/runtimes.py similarity index 100% rename from introspection_sdk/resources/runtimes.py rename to introspection_sdk/_async/resources/runtimes.py diff --git a/introspection_sdk/resources/tags.py b/introspection_sdk/_async/resources/tags.py similarity index 100% rename from introspection_sdk/resources/tags.py rename to introspection_sdk/_async/resources/tags.py diff --git a/introspection_sdk/runner.py b/introspection_sdk/_async/runner.py similarity index 100% rename from introspection_sdk/runner.py rename to introspection_sdk/_async/runner.py diff --git a/introspection_sdk/runner_resources/_reads.py b/introspection_sdk/_async/runner_resources/_reads.py similarity index 100% rename from introspection_sdk/runner_resources/_reads.py rename to introspection_sdk/_async/runner_resources/_reads.py diff --git a/introspection_sdk/runner_resources/automations.py b/introspection_sdk/_async/runner_resources/automations.py similarity index 100% rename from introspection_sdk/runner_resources/automations.py rename to introspection_sdk/_async/runner_resources/automations.py diff --git a/introspection_sdk/runner_resources/connections.py b/introspection_sdk/_async/runner_resources/connections.py similarity index 100% rename from introspection_sdk/runner_resources/connections.py rename to introspection_sdk/_async/runner_resources/connections.py diff --git a/introspection_sdk/runner_resources/conversations.py b/introspection_sdk/_async/runner_resources/conversations.py similarity index 100% rename from introspection_sdk/runner_resources/conversations.py rename to introspection_sdk/_async/runner_resources/conversations.py diff --git a/introspection_sdk/runner_resources/events.py b/introspection_sdk/_async/runner_resources/events.py similarity index 100% rename from introspection_sdk/runner_resources/events.py rename to introspection_sdk/_async/runner_resources/events.py diff --git a/introspection_sdk/runner_resources/files.py b/introspection_sdk/_async/runner_resources/files.py similarity index 100% rename from introspection_sdk/runner_resources/files.py rename to introspection_sdk/_async/runner_resources/files.py diff --git a/introspection_sdk/runner_resources/metrics.py b/introspection_sdk/_async/runner_resources/metrics.py similarity index 100% rename from introspection_sdk/runner_resources/metrics.py rename to introspection_sdk/_async/runner_resources/metrics.py diff --git a/introspection_sdk/runner_resources/shares.py b/introspection_sdk/_async/runner_resources/shares.py similarity index 100% rename from introspection_sdk/runner_resources/shares.py rename to introspection_sdk/_async/runner_resources/shares.py diff --git a/introspection_sdk/runner_resources/tasks.py b/introspection_sdk/_async/runner_resources/tasks.py similarity index 100% rename from introspection_sdk/runner_resources/tasks.py rename to introspection_sdk/_async/runner_resources/tasks.py diff --git a/introspection_sdk/streaming.py b/introspection_sdk/_async/streaming.py similarity index 100% rename from introspection_sdk/streaming.py rename to introspection_sdk/_async/streaming.py From 0f484bcc32eb3de71f00db0fa5143a891369de21 Mon Sep 17 00:00:00 2001 From: Marcus Schiesser Date: Wed, 21 Oct 2026 20:10:24 +0700 Subject: [PATCH 2/5] refactor(_async): keep only the async twins In each moved module, delete the sync classes and functions so the file holds the async source alone; the helper functions and constants stay where they were, and unasync copies them into the sync twin. The async docstrings carry the full text. Six modules get a sibling __common.py for what both twins must share as one object, which cannot live in the public module that imports the twins: auth (OAuthToken, AuthSession, SignInSupersededError, _EmailCodeAuthBase), _http (RawResponse), events (UnknownEventCounter, UNKNOWN_EVENT_SKIPS), runtimes (the hand-written RuntimeHandle / AsyncRuntimeHandle), conversations (ConversationExportParams) and streaming (_SseEvent, _SseAccumulator), plus the private helpers those classes call. Co-Authored-By: Claude Opus 5.5 --- introspection_sdk/_async/__init__.py | 4 + introspection_sdk/_async/_http.py | 246 +---- introspection_sdk/_async/auth.py | 909 +----------------- introspection_sdk/_async/client.py | 234 +---- introspection_sdk/_async/protocols.py | 606 +----------- introspection_sdk/_async/proxy/__init__.py | 0 introspection_sdk/_async/proxy/_transport.py | 27 +- introspection_sdk/_async/proxy/httpx.py | 55 +- introspection_sdk/_async/proxy/httpx2.py | 55 +- .../_async/resources/__init__.py | 0 .../_async/resources/connectors.py | 417 +------- .../_async/resources/experiments.py | 269 +----- introspection_sdk/_async/resources/members.py | 149 +-- introspection_sdk/_async/resources/recipes.py | 58 +- .../_async/resources/repositories.py | 213 +--- .../_async/resources/runtimes.py | 395 +------- introspection_sdk/_async/resources/tags.py | 59 +- introspection_sdk/_async/runner.py | 222 +---- .../_async/runner_resources/__init__.py | 0 .../_async/runner_resources/_reads.py | 62 +- .../_async/runner_resources/automations.py | 187 +--- .../_async/runner_resources/connections.py | 109 +-- .../_async/runner_resources/conversations.py | 581 ++--------- .../_async/runner_resources/events.py | 301 +----- .../_async/runner_resources/files.py | 250 +---- .../_async/runner_resources/metrics.py | 40 +- .../_async/runner_resources/shares.py | 123 +-- .../_async/runner_resources/tasks.py | 355 +------ introspection_sdk/_async/streaming.py | 99 +- introspection_sdk/_auth_common.py | 225 +++++ introspection_sdk/_backoff.py | 12 + introspection_sdk/_http_common.py | 20 + introspection_sdk/_streaming_common.py | 62 ++ .../resources/_runtimes_common.py | 214 +++++ .../runner_resources/_conversations_common.py | 17 + .../runner_resources/_events_common.py | 29 + 36 files changed, 1116 insertions(+), 5488 deletions(-) create mode 100644 introspection_sdk/_async/__init__.py create mode 100644 introspection_sdk/_async/proxy/__init__.py create mode 100644 introspection_sdk/_async/resources/__init__.py create mode 100644 introspection_sdk/_async/runner_resources/__init__.py create mode 100644 introspection_sdk/_auth_common.py create mode 100644 introspection_sdk/_http_common.py create mode 100644 introspection_sdk/_streaming_common.py create mode 100644 introspection_sdk/resources/_runtimes_common.py create mode 100644 introspection_sdk/runner_resources/_conversations_common.py create mode 100644 introspection_sdk/runner_resources/_events_common.py diff --git a/introspection_sdk/_async/__init__.py b/introspection_sdk/_async/__init__.py new file mode 100644 index 0000000..1119b35 --- /dev/null +++ b/introspection_sdk/_async/__init__.py @@ -0,0 +1,4 @@ +"""Re-exported by the public modules. + +See "Sync code generation" in AGENTS.md. +""" diff --git a/introspection_sdk/_async/_http.py b/introspection_sdk/_async/_http.py index da962c8..3732f73 100644 --- a/introspection_sdk/_async/_http.py +++ b/introspection_sdk/_async/_http.py @@ -1,42 +1,31 @@ -"""Small HTTP clients used by the REST API namespaces. - -:class:`_HttpClient` wraps ``httpx.Client`` and :class:`_AsyncHttpClient` -wraps ``httpx.AsyncClient``; both centralise base-URL joining, -``Authorization`` header injection, error translation, and SSE -streaming. Kept private so the public surface stays the REST namespace -classes themselves. - -``httpx`` throughout this package is ``httpx2`` under an alias --- the -Pydantic-maintained successor, which the OpenAI, Anthropic and MCP SDKs are -also built on. Callers passing their own ``transport=`` must pass an httpx2 -one; see docs/advanced.md. The two clients are kept deliberately symmetric so -the sync and async resource namespaces can mirror each other line for -line. -""" +"""Re-exported by :mod:`introspection_sdk._http`.""" from __future__ import annotations -import asyncio -import time -from collections.abc import AsyncIterator, Iterator, Mapping -from dataclasses import dataclass +from collections.abc import AsyncIterator, Mapping from datetime import date, datetime from typing import TYPE_CHECKING, Any import httpx2 as httpx -from introspection_sdk._backoff import _is_retryable_status, _retry_delay +from introspection_sdk._backoff import ( + _is_retryable_status, + _retry_delay, + _sleep_async, +) from introspection_sdk._errors import ( NetworkError, RunnerExpiredError, _parse_retry_after, error_from_response, ) +from introspection_sdk._http_common import ( + RawResponse, +) if TYPE_CHECKING: from introspection_sdk.auth import ( AsyncCredentialProvider, - CredentialProvider, ) #: Default automatic retries for unary REST calls: ``429`` on every method, @@ -50,21 +39,17 @@ DEFAULT_RETRY_BASE = 0.5 -@dataclass(frozen=True) -class RawResponse: - """A raw response body plus headers, returned by ``request(expect="raw")``. +class _AsyncHttpClient: + # unasync: async-only (the sync twin's docstring follows) + """Thin wrapper around ``httpx.AsyncClient`` for REST calls. - Used by the Arrow list-read path, where the row values arrive in the - columnar body and pagination metadata (``X-Next-Cursor`` / - ``X-Result-Count`` / ``X-Truncated`` / ``X-Total-Count``) moves to - response headers. Keeps ``httpx`` off the resource layer's surface. + The async twin of :class:`_HttpClient`. Used both for the CP-facing + client (on ``AsyncIntrospectionClient``) and for the DP-facing + client (on an ``AsyncRunner``). The two only differ in their base + URL and bearer token. """ - content: bytes - headers: Mapping[str, str] - - -class _HttpClient: + # unasync: end """Thin wrapper around ``httpx.Client`` for REST calls. Used both for the CP-facing client (on ``IntrospectionClient``) @@ -80,14 +65,14 @@ def __init__( cp_session: str | None = None, additional_headers: Mapping[str, str] | None = None, timeout: float = 30.0, - transport: httpx.BaseTransport | None = None, + transport: httpx.AsyncBaseTransport | None = None, max_retries: int = DEFAULT_MAX_RETRIES, retry_base: float = DEFAULT_RETRY_BASE, - credentials: CredentialProvider | None = None, + credentials: AsyncCredentialProvider | None = None, ) -> None: self._closed = False self._credentials = credentials - self._client = httpx.Client( + self._client = httpx.AsyncClient( base_url=api_url.rstrip("/"), timeout=timeout, transport=transport, @@ -104,9 +89,9 @@ def __init__( self._max_retries = max_retries self._retry_base = retry_base - def close(self) -> None: + async def aclose(self) -> None: self._closed = True - self._client.close() + await self._client.aclose() def _check_open(self) -> None: """Raise a typed error once this client has been closed. @@ -125,19 +110,19 @@ def _check_open(self) -> None: status_code=0, ) - def _request_headers( + async def _request_headers( self, extra: Mapping[str, str] | None ) -> dict[str, str]: req_headers = dict(self._auth_headers) if self._credentials is not None and "Cookie" not in req_headers: - authorization = self._credentials.authorization() + authorization = await self._credentials.authorization() if authorization: req_headers["Authorization"] = authorization if extra: req_headers.update(extra) return req_headers - def request( + async def request( self, method: str, path: str, @@ -159,183 +144,6 @@ def request( idempotent = method.upper() == "GET" attempt = 0 reauthorized = files is not None - while True: - req_headers = self._request_headers(headers) - try: - res = self._client.request( - method, - path, - params=_clean_params(params), - json=json, - files=files, - data=data, - headers=req_headers, - ) - except httpx.HTTPError as exc: - raise NetworkError(str(exc)) from exc - if ( - res.status_code == 401 - and self._credentials is not None - and not reauthorized - ): - reauthorized = True - if self._credentials.refresh_after_unauthorized( - req_headers.get("Authorization") - ): - continue - if ( - _is_retryable_status(res.status_code, idempotent) - and attempt < retries - ): - delay = _retry_delay( - attempt, - _parse_retry_after(res.headers.get("retry-after")), - self._retry_base, - ) - attempt += 1 - time.sleep(delay) - continue - if res.status_code >= 400: - raise error_from_response(res) - if expect == "empty": - return None - if expect == "bytes": - return res.content - if expect == "raw": - return RawResponse(content=res.content, headers=res.headers) - return res.json() - - def stream_bytes( - self, - path: str, - *, - params: Mapping[str, Any] | None = None, - headers: Mapping[str, str] | None = None, - ) -> Iterator[bytes]: - req_headers = self._request_headers(headers) - try: - with self._client.stream( - "GET", - path, - params=_clean_params(params), - headers=req_headers, - ) as res: - if res.status_code >= 400: - res.read() - raise error_from_response(res) - yield from res.iter_bytes() - except httpx.HTTPError as exc: - raise NetworkError(str(exc)) from exc - - def stream_sse_lines( - self, - path: str, - *, - params: Mapping[str, Any] | None = None, - headers: Mapping[str, str] | None = None, - ) -> Iterator[str]: - self._check_open() - req_headers = self._request_headers( - {"Accept": "text/event-stream", **(headers or {})} - ) - try: - with self._client.stream( - "GET", path, params=_clean_params(params), headers=req_headers - ) as res: - if res.status_code >= 400: - res.read() - raise error_from_response(res) - yield from res.iter_lines() - except httpx.HTTPError as exc: - raise NetworkError(str(exc)) from exc - - -class _AsyncHttpClient: - """Thin wrapper around ``httpx.AsyncClient`` for REST calls. - - The async twin of :class:`_HttpClient`. Used both for the CP-facing - client (on ``AsyncIntrospectionClient``) and for the DP-facing - client (on an ``AsyncRunner``). The two only differ in their base - URL and bearer token. - """ - - def __init__( - self, - *, - api_url: str, - token: str, - cp_session: str | None = None, - additional_headers: Mapping[str, str] | None = None, - timeout: float = 30.0, - transport: httpx.AsyncBaseTransport | None = None, - max_retries: int = DEFAULT_MAX_RETRIES, - retry_base: float = DEFAULT_RETRY_BASE, - credentials: AsyncCredentialProvider | None = None, - ) -> None: - self._closed = False - self._credentials = credentials - self._client = httpx.AsyncClient( - base_url=api_url.rstrip("/"), - timeout=timeout, - transport=transport, - ) - self._auth_headers: dict[str, str] = ( - {"Cookie": f"intro_cp_session={cp_session}"} - if cp_session - else {} - if credentials is not None - else {"Authorization": f"Bearer {token}"} - ) - if additional_headers: - self._auth_headers.update(additional_headers) - self._max_retries = max_retries - self._retry_base = retry_base - - async def aclose(self) -> None: - self._closed = True - await self._client.aclose() - - def _check_open(self) -> None: - """Async twin of :meth:`_HttpClient._check_open`.""" - if self._closed: - raise RunnerExpiredError( - "This client has been closed (the Runner was closed or " - "refreshed); take a fresh namespace handle from the Runner.", - status_code=0, - ) - - async def _request_headers( - self, extra: Mapping[str, str] | None - ) -> dict[str, str]: - req_headers = dict(self._auth_headers) - if self._credentials is not None and "Cookie" not in req_headers: - authorization = await self._credentials.authorization() - if authorization: - req_headers["Authorization"] = authorization - if extra: - req_headers.update(extra) - return req_headers - - async def request( - self, - method: str, - path: str, - *, - params: Mapping[str, Any] | None = None, - json: Any = None, - files: Mapping[str, Any] | None = None, - data: Mapping[str, Any] | None = None, - headers: Mapping[str, str] | None = None, - expect: str = "json", - ) -> Any: - self._check_open() - # See the sync twin: transparent retry on ``429`` (any method) and - # ``502``/``503``/``504`` (``GET`` only), honouring ``Retry-After`` - # as a backoff floor; multipart uploads are excluded. - retries = 0 if files is not None else self._max_retries - idempotent = method.upper() == "GET" - attempt = 0 - reauthorized = files is not None while True: req_headers = await self._request_headers(headers) try: @@ -370,7 +178,7 @@ async def request( self._retry_base, ) attempt += 1 - await asyncio.sleep(delay) + await _sleep_async(delay) continue if res.status_code >= 400: raise error_from_response(res) diff --git a/introspection_sdk/_async/auth.py b/introspection_sdk/_async/auth.py index 1b21f7a..37ae3ff 100644 --- a/introspection_sdk/_async/auth.py +++ b/introspection_sdk/_async/auth.py @@ -1,102 +1,20 @@ -"""OAuth helpers for every Application type, plus native email-code sign-in. - -These mint a short-lived, project-scoped Introspection access token from -the Control Plane ``POST /v1/oauth/token`` endpoint, so callers no longer -hand-roll a form-encoded token POST. An Application has exactly one type, -and each type has one way in: - -* ``service_account`` — :func:`service_account_token`, the OAuth 2.0 - ``client_credentials`` grant for a confidential machine client. The - headless counterpart to a long-lived API key: the ``client_id`` / - ``client_secret`` stay server-side and you re-mint when the token - expires (no refresh token is issued). -* ``jwks`` — :func:`token_exchange`, RFC 8693 token-exchange: trade an end - user's JWT from your own identity provider for a project-scoped access - token for a federated ``customer`` member. No refresh token is issued; - exchange again before it expires. -* ``spa`` — :func:`authorization_code_token`, the RFC 6749 / PKCE - ``authorization_code`` exchange for the hosted-login callback. An ``spa`` - with a brokered identity provider also exchanges that login's - ``id_token`` through :func:`token_exchange`. -* ``native`` — email-code sign-in for a ``customer`` member: - :class:`EmailCodeAuth` / :class:`AsyncEmailCodeAuth` send the code, - verify it, and keep the refreshable session current. The one-shot - calls underneath are :func:`send_email_code`, :func:`email_code_token`, - :func:`refresh_access_token` and :func:`revoke_session`. - -A token issued to an end user (``jwks``, ``spa``, ``native``) carries at -most the Application's ``allowed_scopes``. - -Every token helper returns the shared :class:`OAuthToken` shape, which -carries ``dp_url`` — the Data Plane endpoint the CP resolved for the -token's project — so a client needs no separately configured Data Plane -URL. - -Each function has an ``async_`` twin (:func:`async_service_account_token` -etc.) for use from :class:`~introspection_sdk.AsyncIntrospectionClient` -and other ``asyncio`` callers. A ``service_account`` token is an ordinary -CP bearer token, so it drops straight into -:class:`~introspection_sdk.IntrospectionClient`, or use -:meth:`IntrospectionClient.from_service_account` to mint and construct in -one call. A ``native`` email-code token is a Data Plane credential: -Control Plane routes reject it. -""" +"""Re-exported by :mod:`introspection_sdk.auth`.""" from __future__ import annotations -import asyncio -import base64 -import binascii -import json -import os -import threading -from collections.abc import Callable -from datetime import UTC, datetime, timedelta -from typing import TYPE_CHECKING, Any, Protocol +from typing import Protocol import httpx2 as httpx -from pydantic import BaseModel, ConfigDict +from introspection_sdk._auth_common import ( + OAuthToken, + _resolve_base_api_url, +) from introspection_sdk._errors import ( - AuthenticationError, - IntrospectionAPIError, NetworkError, - ValidationError, error_from_response, ) -if TYPE_CHECKING: - from introspection_sdk.client import ( - AsyncIntrospectionClient, - IntrospectionClient, - ) - -__all__ = [ - "GRANT_EMAIL_CODE", - "AsyncCredentialProvider", - "AsyncEmailCodeAuth", - "AuthSession", - "CredentialProvider", - "EmailCodeAuth", - "OAuthToken", - "SignInSupersededError", - "async_authorization_code_token", - "async_email_code_token", - "async_refresh_access_token", - "async_revoke_session", - "async_send_email_code", - "async_service_account_token", - "async_token_exchange", - "authorization_code_token", - "email_code_token", - "refresh_access_token", - "revoke_session", - "send_email_code", - "service_account_token", - "token_exchange", -] - -_DEFAULT_BASE_API_URL = "https://api.introspection.dev" _TOKEN_PATH = "/v1/oauth/token" _GRANT_CLIENT_CREDENTIALS = "client_credentials" _GRANT_TOKEN_EXCHANGE = "urn:ietf:params:oauth:grant-type:token-exchange" @@ -109,46 +27,6 @@ GRANT_EMAIL_CODE = "urn:introspection:params:oauth:grant-type:email_code" -class OAuthToken(BaseModel): - """CP ``POST /v1/oauth/token`` response. - - The machine and federated grants issue no refresh token — re-mint (call - the helper again) once it expires. ``email_code`` and ``refresh_token`` - return the refresh token plus the ``session_id`` / ``org_id`` that key the - next refresh. Additional wire fields are preserved but left unmodelled. - """ - - model_config = ConfigDict(extra="allow") - - #: Project-scoped RS256 access token (``Authorization: Bearer …``). - access_token: str - #: Always ``"Bearer"``. - token_type: str = "Bearer" - #: Token lifetime in seconds. - expires_in: int - #: The granted (scope-capped) scope, when the CP returns one. - scope: str | None = None - #: Data Plane API base URL for the token's project, resolved by the - #: CP. ``None`` when no deployment resolves; the caller then needs an - #: explicit DP URL. Hand this to a browser client so it needs no - #: separate Data Plane configuration. - dp_url: str | None = None - #: Rotating refresh token; set only by the session-backed grants. - refresh_token: str | None = None - #: The CP session row (the access token's ``jti``). - session_id: str | None = None - org_id: str | None = None - project_id: str | None = None - member_id: str | None = None - - -def _resolve_base_api_url(base_api_url: str | None) -> str: - resolved = base_api_url or os.getenv( - "INTROSPECTION_BASE_API_URL", _DEFAULT_BASE_API_URL - ) - return resolved.rstrip("/") - - def _service_account_form( client_id: str, client_secret: str, @@ -201,27 +79,7 @@ def _authorization_code_form( } -def _cp_post( - base_api_url: str, - path: str, - *, - data: dict[str, str] | None = None, - json: dict[str, str] | None = None, - transport: httpx.BaseTransport | None = None, -) -> httpx.Response: - try: - with httpx.Client( - base_url=base_api_url, timeout=30.0, transport=transport - ) as client: - res = client.post(path, data=data, json=json) - except httpx.HTTPError as exc: - raise NetworkError(str(exc)) from exc - if res.status_code >= 400: - raise error_from_response(res) - return res - - -async def _acp_post( +async def _cp_post_async( base_api_url: str, path: str, *, @@ -241,39 +99,26 @@ async def _acp_post( return res -def _post_token_form( - base_api_url: str, - form: dict[str, str], - *, - transport: httpx.BaseTransport | None = None, -) -> OAuthToken: - res = _cp_post(base_api_url, _TOKEN_PATH, data=form, transport=transport) - return OAuthToken.model_validate(res.json()) - - -async def _apost_token_form( +async def _post_token_form_async( base_api_url: str, form: dict[str, str], *, transport: httpx.AsyncBaseTransport | None = None, ) -> OAuthToken: - res = await _acp_post( + res = await _cp_post_async( base_api_url, _TOKEN_PATH, data=form, transport=transport ) return OAuthToken.model_validate(res.json()) -# --- client_credentials (service account) --------------------------- - - -def service_account_token( +async def async_service_account_token( client_id: str, client_secret: str, project: str, *, scope: str | None = None, base_api_url: str | None = None, - transport: httpx.BaseTransport | None = None, + transport: httpx.AsyncBaseTransport | None = None, ) -> OAuthToken: """Mint a project-scoped CP access token from service-account creds. @@ -292,36 +137,12 @@ def service_account_token( project=project, scope=scope, ) - return _post_token_form( + return await _post_token_form_async( _resolve_base_api_url(base_api_url), form, transport=transport ) -async def async_service_account_token( - client_id: str, - client_secret: str, - project: str, - *, - scope: str | None = None, - base_api_url: str | None = None, - transport: httpx.AsyncBaseTransport | None = None, -) -> OAuthToken: - """Async twin of :func:`service_account_token`.""" - form = _service_account_form( - client_id=client_id, - client_secret=client_secret, - project=project, - scope=scope, - ) - return await _apost_token_form( - _resolve_base_api_url(base_api_url), form, transport=transport - ) - - -# --- token-exchange (RFC 8693, federated identity) ------------------ - - -def token_exchange( +async def async_token_exchange( subject_token: str, client_id: str, project: str, @@ -329,7 +150,7 @@ def token_exchange( subject_token_type: str | None = None, scope: str | None = None, base_api_url: str | None = None, - transport: httpx.BaseTransport | None = None, + transport: httpx.AsyncBaseTransport | None = None, ) -> OAuthToken: """RFC 8693 token-exchange against CP ``POST /v1/oauth/token``. @@ -349,45 +170,19 @@ def token_exchange( subject_token_type=subject_token_type, scope=scope, ) - return _post_token_form( + return await _post_token_form_async( _resolve_base_api_url(base_api_url), form, transport=transport ) -async def async_token_exchange( - subject_token: str, - client_id: str, - project: str, - *, - subject_token_type: str | None = None, - scope: str | None = None, - base_api_url: str | None = None, - transport: httpx.AsyncBaseTransport | None = None, -) -> OAuthToken: - """Async twin of :func:`token_exchange`.""" - form = _token_exchange_form( - subject_token=subject_token, - client_id=client_id, - project=project, - subject_token_type=subject_token_type, - scope=scope, - ) - return await _apost_token_form( - _resolve_base_api_url(base_api_url), form, transport=transport - ) - - -# --- authorization_code (PKCE, hosted login) ------------------------ - - -def authorization_code_token( +async def async_authorization_code_token( code: str, client_id: str, redirect_uri: str, code_verifier: str, *, base_api_url: str | None = None, - transport: httpx.BaseTransport | None = None, + transport: httpx.AsyncBaseTransport | None = None, ) -> OAuthToken: """RFC 6749 / PKCE ``authorization_code`` exchange. @@ -402,35 +197,11 @@ def authorization_code_token( redirect_uri=redirect_uri, code_verifier=code_verifier, ) - return _post_token_form( + return await _post_token_form_async( _resolve_base_api_url(base_api_url), form, transport=transport ) -async def async_authorization_code_token( - code: str, - client_id: str, - redirect_uri: str, - code_verifier: str, - *, - base_api_url: str | None = None, - transport: httpx.AsyncBaseTransport | None = None, -) -> OAuthToken: - """Async twin of :func:`authorization_code_token`.""" - form = _authorization_code_form( - code=code, - client_id=client_id, - redirect_uri=redirect_uri, - code_verifier=code_verifier, - ) - return await _apost_token_form( - _resolve_base_api_url(base_api_url), form, transport=transport - ) - - -# --- email_code (native sign-in) ------------------------------------ - - def _email_code_form( email: str, code: str, client_id: str, project: str ) -> dict[str, str]: @@ -457,13 +228,13 @@ def _refresh_form( } -def send_email_code( +async def async_send_email_code( email: str, client_id: str, project: str, *, base_api_url: str | None = None, - transport: httpx.BaseTransport | None = None, + transport: httpx.AsyncBaseTransport | None = None, ) -> None: """Email a sign-in code to ``email`` (CP ``POST /v1/oauth/email/code``). @@ -472,24 +243,7 @@ def send_email_code( whether or not the email has an account. A rate-limited send raises :class:`~introspection_sdk.RateLimitError` carrying ``retry_after``. """ - _cp_post( - _resolve_base_api_url(base_api_url), - _EMAIL_CODE_PATH, - json={"client_id": client_id, "email": email, "project": project}, - transport=transport, - ) - - -async def async_send_email_code( - email: str, - client_id: str, - project: str, - *, - base_api_url: str | None = None, - transport: httpx.AsyncBaseTransport | None = None, -) -> None: - """Async twin of :func:`send_email_code`.""" - await _acp_post( + await _cp_post_async( _resolve_base_api_url(base_api_url), _EMAIL_CODE_PATH, json={"client_id": client_id, "email": email, "project": project}, @@ -497,14 +251,14 @@ async def async_send_email_code( ) -def email_code_token( +async def async_email_code_token( email: str, code: str, client_id: str, project: str, *, base_api_url: str | None = None, - transport: httpx.BaseTransport | None = None, + transport: httpx.AsyncBaseTransport | None = None, ) -> OAuthToken: """Exchange an emailed code for a refreshable ``customer`` session. @@ -513,89 +267,34 @@ def email_code_token( The returned token carries ``refresh_token``, ``session_id`` and ``org_id`` for :func:`refresh_access_token`, and ``dp_url``. """ - return _post_token_form( - _resolve_base_api_url(base_api_url), - _email_code_form(email, code, client_id, project), - transport=transport, - ) - - -async def async_email_code_token( - email: str, - code: str, - client_id: str, - project: str, - *, - base_api_url: str | None = None, - transport: httpx.AsyncBaseTransport | None = None, -) -> OAuthToken: - """Async twin of :func:`email_code_token`.""" - return await _apost_token_form( + return await _post_token_form_async( _resolve_base_api_url(base_api_url), _email_code_form(email, code, client_id, project), transport=transport, ) -def refresh_access_token( +async def async_refresh_access_token( refresh_token: str, client_id: str, session_id: str, org_id: str, *, base_api_url: str | None = None, - transport: httpx.BaseTransport | None = None, + transport: httpx.AsyncBaseTransport | None = None, ) -> OAuthToken: """Renew a session-backed token with the ``refresh_token`` grant. The refresh token rotates: persist the returned one. The response carries no ``dp_url``; keep the one from sign-in. """ - return _post_token_form( + return await _post_token_form_async( _resolve_base_api_url(base_api_url), _refresh_form(refresh_token, client_id, session_id, org_id), transport=transport, ) -async def async_refresh_access_token( - refresh_token: str, - client_id: str, - session_id: str, - org_id: str, - *, - base_api_url: str | None = None, - transport: httpx.AsyncBaseTransport | None = None, -) -> OAuthToken: - """Async twin of :func:`refresh_access_token`.""" - return await _apost_token_form( - _resolve_base_api_url(base_api_url), - _refresh_form(refresh_token, client_id, session_id, org_id), - transport=transport, - ) - - -def revoke_session( - session_id: str, - org_id: str, - client_id: str, - *, - base_api_url: str | None = None, - transport: httpx.BaseTransport | None = None, -) -> None: - """Revoke a session (CP ``POST /v1/oauth/revoke``); its next refresh fails.""" - _cp_post( - _resolve_base_api_url(base_api_url), - _REVOKE_PATH, - data={ - "client_id": client_id, - "session_id": session_id, - "org_id": org_id, - }, - transport=transport, - ) - - async def async_revoke_session( session_id: str, org_id: str, @@ -604,8 +303,8 @@ async def async_revoke_session( base_api_url: str | None = None, transport: httpx.AsyncBaseTransport | None = None, ) -> None: - """Async twin of :func:`revoke_session`.""" - await _acp_post( + """Revoke a session (CP ``POST /v1/oauth/revoke``); its next refresh fails.""" + await _cp_post_async( _resolve_base_api_url(base_api_url), _REVOKE_PATH, data={ @@ -617,562 +316,18 @@ async def async_revoke_session( ) -# --- native session --------------------------------------------------- - - -def _jwt_claims(token: str) -> dict[str, Any]: - """The payload of a JWT, decoded WITHOUT verifying it; ``{}`` if not one.""" - segments = token.split(".") - if len(segments) < 2: - return {} - padded = segments[1] + "=" * (-len(segments[1]) % 4) - try: - claims = json.loads(base64.urlsafe_b64decode(padded)) - except (ValueError, binascii.Error): - return {} - return claims if isinstance(claims, dict) else {} - - -def _claim(claims: dict[str, Any], name: str) -> str | None: - value = claims.get(name) - return value if isinstance(value, str) else None - - -class AuthSession(BaseModel): - """A signed-in member's session: the access token plus what renews it. - - Serializable (``model_dump_json`` / ``model_validate_json``) so an app can - keep it between runs and hand it back as ``EmailCodeAuth(session=...)``. - Treat it as a secret: it holds the refresh token. - """ - - model_config = ConfigDict(extra="allow") - - access_token: str - expires_at: datetime | None = None - #: Rotated by every refresh. - refresh_token: str | None = None - #: The CP session row (the access token's ``jti``). - session_id: str | None = None - org_id: str | None = None - project_id: str | None = None - member_id: str | None = None - #: The Data Plane URL returned at sign-in. - dp_url: str | None = None - scope: str | None = None - - @classmethod - def from_token( - cls, - token: OAuthToken, - *, - received_at: datetime, - previous: AuthSession | None = None, - ) -> AuthSession: - """From a token response; missing ids come from the JWT, then ``previous``.""" - claims = _jwt_claims(token.access_token) - return cls( - access_token=token.access_token, - expires_at=received_at + timedelta(seconds=token.expires_in), - refresh_token=token.refresh_token - or (previous.refresh_token if previous else None), - session_id=token.session_id - or _claim(claims, "jti") - or (previous.session_id if previous else None), - org_id=token.org_id - or _claim(claims, "org_id") - or (previous.org_id if previous else None), - project_id=token.project_id - or _claim(claims, "project_id") - or (previous.project_id if previous else None), - member_id=token.member_id - or _claim(claims, "member_id") - or (previous.member_id if previous else None), - dp_url=token.dp_url or (previous.dp_url if previous else None), - scope=token.scope or (previous.scope if previous else None), - ) - - -class SignInSupersededError(Exception): - """A sign-in or refresh response arrived after a newer sign-in or a - sign-out, so it was discarded rather than overwrite the newer state.""" - - -class CredentialProvider(Protocol): +class AsyncCredentialProvider(Protocol): """Supplies ``Authorization`` to a client and renews it after a ``401``. :class:`EmailCodeAuth` implements it; pass one as ``IntrospectionClient(credentials=...)``. """ - def authorization(self) -> str | None: + async def authorization(self) -> str | None: """The header value to send, or ``None`` to send none.""" ... - def refresh_after_unauthorized(self, rejected: str | None) -> bool: + async def refresh_after_unauthorized(self, rejected: str | None) -> bool: """Called after a ``401`` for a request sent with ``rejected``; return ``True`` to retry it once with :meth:`authorization`.""" ... - - -class AsyncCredentialProvider(Protocol): - """Async twin of :class:`CredentialProvider`.""" - - async def authorization(self) -> str | None: ... - - async def refresh_after_unauthorized( - self, rejected: str | None - ) -> bool: ... - - -def _is_rejection(error: IntrospectionAPIError) -> bool: - """The server refused the session, as opposed to a failure worth retrying.""" - return ( - isinstance(error, AuthenticationError | ValidationError) - or error.status_code == 403 - or error.code == "invalid_grant" - ) - - -def _rejected(error: IntrospectionAPIError) -> AuthenticationError: - return AuthenticationError( - f"The session is no longer valid: {error}", - status_code=error.status_code, - code=error.code, - request_id=error.request_id, - body=error.body, - ) - - -def _not_signed_in() -> AuthenticationError: - return AuthenticationError("Not signed in", status_code=0) - - -def _utcnow() -> datetime: - return datetime.now(UTC) - - -SessionListener = Callable[["AuthSession | None"], None] - - -class _EmailCodeAuthBase: - def __init__( - self, - client_id: str, - project: str, - *, - session: AuthSession | None = None, - base_api_url: str | None = None, - leeway: float = 60.0, - on_session_change: SessionListener | None = None, - clock: Callable[[], datetime] = _utcnow, - ) -> None: - self.client_id = client_id - self.project = project - self.base_api_url = _resolve_base_api_url(base_api_url) - self.leeway = leeway - self._session = session - self._generation = 0 - self._on_session_change = on_session_change - self._clock = clock - - @property - def current_session(self) -> AuthSession | None: - """The session as held, without refreshing it.""" - return self._session - - def _expiring(self, session: AuthSession) -> bool: - return ( - session.expires_at is not None - and session.expires_at - timedelta(seconds=self.leeway) - <= self._clock() - ) - - def _refresh_key(self, session: AuthSession) -> tuple[str, str, str]: - if not ( - session.refresh_token and session.session_id and session.org_id - ): - raise AuthenticationError( - "The session has no refresh token", status_code=0 - ) - return session.refresh_token, session.session_id, session.org_id - - def _adopt(self, token: OAuthToken, expected: int | None) -> AuthSession: - """Install a sign-in response unless a newer one superseded it.""" - if expected is not None and self._generation != expected: - raise SignInSupersededError( - "A sign-out or another sign-in completed first" - ) - self._generation += 1 - self._session = AuthSession.from_token( - token, received_at=self._clock() - ) - self._notify(self._session) - return self._session - - def _replace( - self, previous: AuthSession, token: OAuthToken, generation: int - ) -> AuthSession: - if self._generation != generation: - raise SignInSupersededError( - "A sign-out or sign-in completed during the refresh" - ) - self._session = AuthSession.from_token( - token, received_at=self._clock(), previous=previous - ) - self._notify(self._session) - return self._session - - def _clear(self) -> AuthSession | None: - previous = self._session - self._generation += 1 - self._session = None - if previous is not None: - self._notify(None) - return previous - - def _notify(self, session: AuthSession | None) -> None: - if self._on_session_change is not None: - self._on_session_change(session) - - -class EmailCodeAuth(_EmailCodeAuthBase): - """Native email-code sign-in for a ``native`` Application's end users. - - Send a code, verify it, and use the session: the access token is renewed - ``leeway`` seconds before it expires and after a ``401``, with concurrent - callers sharing one refresh. A sign-in or refresh response that arrives - after a newer sign-in or a sign-out is dropped and raises - :class:`SignInSupersededError`:: - - auth = EmailCodeAuth(client_id="intro_app_…", project="ark") - auth.send_code("user@example.com") - auth.verify_code("user@example.com", input("Code: ")) - client = auth.client() - - Pass ``session=`` to restore a session kept from an earlier run and - ``on_session_change`` to persist every change (``None`` on sign-out). - The token is a Data Plane credential for a ``customer`` member scoped to - the Application's ``allowed_scopes``: Control Plane routes reject it. - """ - - def __init__( - self, - client_id: str, - project: str, - *, - session: AuthSession | None = None, - base_api_url: str | None = None, - leeway: float = 60.0, - on_session_change: SessionListener | None = None, - transport: httpx.BaseTransport | None = None, - clock: Callable[[], datetime] = _utcnow, - ) -> None: - super().__init__( - client_id, - project, - session=session, - base_api_url=base_api_url, - leeway=leeway, - on_session_change=on_session_change, - clock=clock, - ) - self.transport = transport - self._state = threading.RLock() - self._refreshing = threading.Lock() - - def send_code(self, email: str) -> None: - """Email a one-time code to ``email``.""" - send_email_code( - email, - self.client_id, - self.project, - base_api_url=self.base_api_url, - transport=self.transport, - ) - - def verify_code(self, email: str, code: str) -> AuthSession: - """Verify the code sent to ``email`` and sign in.""" - with self._state: - generation = self._generation - token = email_code_token( - email, - code, - self.client_id, - self.project, - base_api_url=self.base_api_url, - transport=self.transport, - ) - with self._state: - return self._adopt(token, generation) - - def set_session(self, token: OAuthToken | AuthSession) -> AuthSession: - """Adopt a session obtained elsewhere.""" - with self._state: - if isinstance(token, OAuthToken): - return self._adopt(token, None) - self._generation += 1 - self._session = token - self._notify(token) - return token - - def get_session(self) -> AuthSession | None: - """The current session, refreshed first when within ``leeway`` of - expiry; ``None`` when signed out.""" - session = self._session - if session is None: - return None - if self._expiring(session): - return self.refresh() - return session - - def refresh(self) -> AuthSession: - """Renew the session now, or wait for the renewal in flight. A - refresh the server rejects signs out and raises - :class:`~introspection_sdk.AuthenticationError`.""" - with self._state: - session, generation = self._session, self._generation - if session is None: - raise _not_signed_in() - with self._refreshing: - with self._state: - if self._generation != generation: - raise SignInSupersededError( - "A sign-out or sign-in completed during the refresh" - ) - if self._session is not session and self._session is not None: - return self._session - refresh_token, session_id, org_id = self._refresh_key(session) - try: - token = refresh_access_token( - refresh_token, - self.client_id, - session_id, - org_id, - base_api_url=self.base_api_url, - transport=self.transport, - ) - except IntrospectionAPIError as exc: - if not _is_rejection(exc): - raise - with self._state: - if self._generation != generation: - raise SignInSupersededError( - "A sign-out or sign-in completed during the refresh" - ) from exc - self._clear() - raise _rejected(exc) from exc - with self._state: - return self._replace(session, token, generation) - - def sign_out(self) -> None: - """Forget the session, then revoke it on the Control Plane. The local - session is cleared even when revocation fails.""" - with self._state: - session = self._clear() - if session is None or session.refresh_token is None: - return - if session.session_id and session.org_id: - revoke_session( - session.session_id, - session.org_id, - self.client_id, - base_api_url=self.base_api_url, - transport=self.transport, - ) - - def authorization(self) -> str | None: - session = self.get_session() - return f"Bearer {session.access_token}" if session else None - - def refresh_after_unauthorized(self, rejected: str | None) -> bool: - session = self.get_session() - if session is None: - return False - if ( - rejected is not None - and rejected != f"Bearer {session.access_token}" - ): - return True - self.refresh() - return True - - def client( - self, - *, - dp_url: str | None = None, - additional_headers: dict[str, str] | None = None, - ) -> IntrospectionClient: - """A client authenticated as the signed-in member, pointed at the - session's ``dp_url`` unless ``dp_url`` overrides it. Only its Data - Plane namespaces accept the token.""" - # Imported here: the client module imports this one. - from introspection_sdk.client import IntrospectionClient - - session = self.get_session() - if session is None: - raise _not_signed_in() - return IntrospectionClient( - credentials=self, - base_api_url=self.base_api_url, - dp_url=dp_url or session.dp_url, - additional_headers=additional_headers, - transport=self.transport, - ) - - -class AsyncEmailCodeAuth(_EmailCodeAuthBase): - """Async twin of :class:`EmailCodeAuth`.""" - - def __init__( - self, - client_id: str, - project: str, - *, - session: AuthSession | None = None, - base_api_url: str | None = None, - leeway: float = 60.0, - on_session_change: SessionListener | None = None, - transport: httpx.AsyncBaseTransport | None = None, - clock: Callable[[], datetime] = _utcnow, - ) -> None: - super().__init__( - client_id, - project, - session=session, - base_api_url=base_api_url, - leeway=leeway, - on_session_change=on_session_change, - clock=clock, - ) - self.transport = transport - self._refreshing = asyncio.Lock() - - async def send_code(self, email: str) -> None: - """Email a one-time code to ``email``.""" - await async_send_email_code( - email, - self.client_id, - self.project, - base_api_url=self.base_api_url, - transport=self.transport, - ) - - async def verify_code(self, email: str, code: str) -> AuthSession: - """Verify the code sent to ``email`` and sign in.""" - generation = self._generation - token = await async_email_code_token( - email, - code, - self.client_id, - self.project, - base_api_url=self.base_api_url, - transport=self.transport, - ) - return self._adopt(token, generation) - - def set_session(self, token: OAuthToken | AuthSession) -> AuthSession: - """Adopt a session obtained elsewhere.""" - if isinstance(token, OAuthToken): - return self._adopt(token, None) - self._generation += 1 - self._session = token - self._notify(token) - return token - - async def get_session(self) -> AuthSession | None: - """The current session, refreshed first when within ``leeway`` of - expiry; ``None`` when signed out.""" - session = self._session - if session is None: - return None - if self._expiring(session): - return await self.refresh() - return session - - async def refresh(self) -> AuthSession: - """Renew the session now, or wait for the renewal in flight. A - refresh the server rejects signs out and raises - :class:`~introspection_sdk.AuthenticationError`.""" - session, generation = self._session, self._generation - if session is None: - raise _not_signed_in() - async with self._refreshing: - if self._generation != generation: - raise SignInSupersededError( - "A sign-out or sign-in completed during the refresh" - ) - if self._session is not session and self._session is not None: - return self._session - refresh_token, session_id, org_id = self._refresh_key(session) - try: - token = await async_refresh_access_token( - refresh_token, - self.client_id, - session_id, - org_id, - base_api_url=self.base_api_url, - transport=self.transport, - ) - except IntrospectionAPIError as exc: - if not _is_rejection(exc): - raise - if self._generation != generation: - raise SignInSupersededError( - "A sign-out or sign-in completed during the refresh" - ) from exc - self._clear() - raise _rejected(exc) from exc - return self._replace(session, token, generation) - - async def sign_out(self) -> None: - """Forget the session, then revoke it on the Control Plane. The local - session is cleared even when revocation fails.""" - session = self._clear() - if session is None or session.refresh_token is None: - return - if session.session_id and session.org_id: - await async_revoke_session( - session.session_id, - session.org_id, - self.client_id, - base_api_url=self.base_api_url, - transport=self.transport, - ) - - async def authorization(self) -> str | None: - session = await self.get_session() - return f"Bearer {session.access_token}" if session else None - - async def refresh_after_unauthorized(self, rejected: str | None) -> bool: - session = await self.get_session() - if session is None: - return False - if ( - rejected is not None - and rejected != f"Bearer {session.access_token}" - ): - return True - await self.refresh() - return True - - async def client( - self, - *, - dp_url: str | None = None, - additional_headers: dict[str, str] | None = None, - ) -> AsyncIntrospectionClient: - """Async twin of :meth:`EmailCodeAuth.client`.""" - # Imported here: the client module imports this one. - from introspection_sdk.client import AsyncIntrospectionClient - - session = await self.get_session() - if session is None: - raise _not_signed_in() - return AsyncIntrospectionClient( - credentials=self, - base_api_url=self.base_api_url, - dp_url=dp_url or session.dp_url, - additional_headers=additional_headers, - transport=self.transport, - ) diff --git a/introspection_sdk/_async/client.py b/introspection_sdk/_async/client.py index 09154ed..6fce233 100644 --- a/introspection_sdk/_async/client.py +++ b/introspection_sdk/_async/client.py @@ -1,37 +1,18 @@ -"""REST-only Introspection client. - -This module exposes the CP REST surface -(:class:`~introspection_sdk.resources.Runtimes`, -:class:`~introspection_sdk.resources.Experiments`) plus the DP -:class:`~introspection_sdk.runner.Runner` flow, **without** importing -OpenTelemetry. - -For OpenTelemetry-based emission of ``track`` / ``feedback`` / -``identify`` events, install the ``[otel]`` extra and use -:class:`introspection_sdk.IntrospectionLogs`. For trace export, attach -:class:`introspection_sdk.IntrospectionSpanProcessor` to your -``TracerProvider`` — or call :func:`introspection_sdk.otel.init` to get -both surfaces at once. -""" +"""Re-exported by :mod:`introspection_sdk.client`.""" from __future__ import annotations -__all__ = ["AsyncIntrospectionClient", "IntrospectionClient"] - import os import httpx2 as httpx -from introspection_sdk._http import _AsyncHttpClient, _HttpClient +from introspection_sdk._http import _AsyncHttpClient from introspection_sdk.auth import ( AsyncCredentialProvider, - CredentialProvider, async_service_account_token, - service_account_token, ) from introspection_sdk.dev_target import client_headers from introspection_sdk.resources import ( - Annotations, AsyncAnnotations, AsyncAutomations, AsyncConnectors, @@ -41,17 +22,8 @@ AsyncRepositories, AsyncRuntimes, AsyncTags, - Automations, - Connectors, - Experiments, - Members, - Recipes, - Repositories, - Runtimes, - Tags, ) from introspection_sdk.runner_resources import ( - AppConnections, AsyncAppConnections, AsyncConversations, AsyncEvents, @@ -59,16 +31,28 @@ AsyncMetrics, AsyncShares, AsyncTasks, - Conversations, - Events, - Files, - Metrics, - Shares, - Tasks, ) -class IntrospectionClient: +class AsyncIntrospectionClient: + # unasync: async-only (the sync twin's docstring follows) + """Async twin of :class:`IntrospectionClient` (no OpenTelemetry). + + Use :attr:`runtimes` / :attr:`experiments` to drive the CP REST + surface. ``await client.runtimes(slug).run()`` and + ``await client.experiments(id).run()`` mint an + :class:`~introspection_sdk.runner.AsyncRunner` for DP traffic + (``await runner.tasks...`` / ``await runner.files...``). + + Backed by ``httpx.AsyncClient``; everything that touches the network + is awaitable. Supports ``async with`` for deterministic teardown. + + For the OpenTelemetry-based ``track`` / ``feedback`` / ``identify`` + surface, see :class:`introspection_sdk.IntrospectionLogs` (requires + the ``[otel]`` extra). + """ + + # unasync: end """REST-only Introspection client (no OpenTelemetry). Use :attr:`runtimes` / :attr:`experiments` to drive the CP REST @@ -88,156 +72,6 @@ class IntrospectionClient: the ``[otel]`` extra). """ - runtimes: Runtimes - experiments: Experiments - recipes: Recipes - repositories: Repositories - connectors: Connectors - members: Members - annotations: Annotations - tags: Tags - automations: Automations - events: Events - tasks: Tasks - files: Files - conversations: Conversations - metrics: Metrics - shares: Shares - connections: AppConnections - - def __init__( - self, - *, - token: str | None = None, - cp_session: str | None = None, - base_api_url: str | None = None, - dp_url: str | None = None, - additional_headers: dict[str, str] | None = None, - credentials: CredentialProvider | None = None, - transport: httpx.BaseTransport | None = None, - ) -> None: - self._token = token or os.getenv("INTROSPECTION_TOKEN", "") - self._base_api_url = base_api_url or os.getenv( - "INTROSPECTION_BASE_API_URL", - "https://api.introspection.dev", - ) - # INTROSPECTION_DEV_TARGET rides every request as a header so it reaches - # the paths a runner cannot: a bare `POST /v1/tasks` with a dev API key - # mints its JWT from the key row and has no per-request claim to carry. - # Resolved once here so every resource client below inherits it. - self._additional_headers = client_headers(additional_headers) - self._http = _HttpClient( - api_url=self._base_api_url, - token=self._token, - cp_session=cp_session, - additional_headers=self._additional_headers, - transport=transport, - credentials=credentials, - ) - self._dp_http = _HttpClient( - api_url=dp_url or self._base_api_url, - token=self._token, - additional_headers=self._additional_headers, - transport=transport, - credentials=credentials, - ) - self.runtimes = Runtimes( - self._http, - additional_headers=self._additional_headers, - ) - self.experiments = Experiments( - self._http, - additional_headers=self._additional_headers, - ) - self.recipes = Recipes(self._http) - self.repositories = Repositories(self._http, self._dp_http) - self.connectors = Connectors(self._http) - self.members = Members(self._http) - self.annotations = Annotations(self._http, self._dp_http) - self.tags = Tags(self._dp_http) - self.automations = Automations(self._dp_http) - self.events = Events(self._dp_http) - self.tasks = Tasks(self._dp_http) - self.files = Files(self._dp_http) - self.conversations = Conversations(self._dp_http) - self.metrics = Metrics(self._dp_http) - self.shares = Shares(self._dp_http) - self.connections = AppConnections(self._dp_http) - - @classmethod - def from_service_account( - cls, - client_id: str, - client_secret: str, - project: str, - *, - scope: str | None = None, - base_api_url: str | None = None, - additional_headers: dict[str, str] | None = None, - transport: httpx.BaseTransport | None = None, - ) -> IntrospectionClient: - """Authenticate as a confidential service account and return a - ready client. - - Mints a short-lived, project-scoped CP access token via the - ``client_credentials`` grant (see - :func:`~introspection_sdk.auth.service_account_token`) and wires - it in as the bearer token, so the runtime flow works exactly as - it does with an API key:: - - client = IntrospectionClient.from_service_account( - client_id=os.environ["INTRO_SA_CLIENT_ID"], - client_secret=os.environ["INTRO_SA_CLIENT_SECRET"], - project=os.environ["INTRO_PROJECT"], - ) - runner = client.runtimes("customer-agent").run() - - The token is not auto-refreshed: it lives for ``expires_in`` - seconds, so re-mint (call this again) for long-lived processes - once it lapses. The resolved Data Plane URL is wired into the - annotation and project-label namespaces automatically. - """ - token = service_account_token( - client_id=client_id, - client_secret=client_secret, - project=project, - scope=scope, - base_api_url=base_api_url, - transport=transport, - ) - return cls( - token=token.access_token, - base_api_url=base_api_url, - dp_url=token.dp_url, - additional_headers=additional_headers, - ) - - def shutdown(self) -> None: - """Graceful shutdown — closes the underlying HTTP client.""" - try: - self._http.close() - self._dp_http.close() - except Exception: # noqa: BLE001 — best-effort cleanup - pass - - -class AsyncIntrospectionClient: - """Async twin of :class:`IntrospectionClient` (no OpenTelemetry). - - Use :attr:`runtimes` / :attr:`experiments` to drive the CP REST - surface. ``await client.runtimes(slug).run()`` and - ``await client.experiments(id).run()`` mint an - :class:`~introspection_sdk.runner.AsyncRunner` for DP traffic - (``await runner.tasks...`` / ``await runner.files...``). - - Backed by ``httpx.AsyncClient``; everything that touches the network - is awaitable. Supports ``async with`` for deterministic teardown. - - For the OpenTelemetry-based ``track`` / ``feedback`` / ``identify`` - surface, see :class:`introspection_sdk.IntrospectionLogs` (requires - the ``[otel]`` extra). - """ - runtimes: AsyncRuntimes experiments: AsyncExperiments recipes: AsyncRecipes @@ -326,6 +160,7 @@ async def from_service_account( additional_headers: dict[str, str] | None = None, transport: httpx.AsyncBaseTransport | None = None, ) -> AsyncIntrospectionClient: + # unasync: async-only (the sync twin's docstring follows) """Async twin of :meth:`IntrospectionClient.from_service_account`. @@ -341,6 +176,28 @@ async def from_service_account( ) runner = await client.runtimes("customer-agent").run() """ + # unasync: end + """Authenticate as a confidential service account and return a + ready client. + + Mints a short-lived, project-scoped CP access token via the + ``client_credentials`` grant (see + :func:`~introspection_sdk.auth.service_account_token`) and wires + it in as the bearer token, so the runtime flow works exactly as + it does with an API key:: + + client = IntrospectionClient.from_service_account( + client_id=os.environ["INTRO_SA_CLIENT_ID"], + client_secret=os.environ["INTRO_SA_CLIENT_SECRET"], + project=os.environ["INTRO_PROJECT"], + ) + runner = client.runtimes("customer-agent").run() + + The token is not auto-refreshed: it lives for ``expires_in`` + seconds, so re-mint (call this again) for long-lived processes + once it lapses. The resolved Data Plane URL is wired into the + annotation and project-label namespaces automatically. + """ token = await async_service_account_token( client_id=client_id, client_secret=client_secret, @@ -364,8 +221,11 @@ async def shutdown(self) -> None: except Exception: # noqa: BLE001 — best-effort cleanup pass + # unasync: async-only (the sync client is no context manager) async def __aenter__(self) -> AsyncIntrospectionClient: return self async def __aexit__(self, *_exc_info: object) -> None: await self.shutdown() + + # unasync: end diff --git a/introspection_sdk/_async/protocols.py b/introspection_sdk/_async/protocols.py index dffa1d6..79ae774 100644 --- a/introspection_sdk/_async/protocols.py +++ b/introspection_sdk/_async/protocols.py @@ -1,29 +1,15 @@ -"""Typing Protocols for the data-plane namespaces. - -:class:`DataPlaneResources` is the interface the client and the -:class:`~introspection_sdk.runner.Runner` share on the data plane, and -:class:`AsyncDataPlaneResources` its async twin. Each namespace is a -Protocol of its methods too, so code written against these runs with -either handle, and the type checker fails when the two drift apart. - -The two differ in credential, not in shape: the client sends its own token -(an API key, a service account or a member's token) and the Runner sends -the session token minted for it. ``runner.connections.create`` takes -``runtime`` as optional, defaulting to the runner's runtime group, which -still satisfies the Protocol, where ``runtime`` is required. -""" +"""Re-exported by :mod:`introspection_sdk.protocols`.""" from __future__ import annotations import builtins -from collections.abc import AsyncIterator, Iterator +from collections.abc import AsyncIterator from datetime import datetime from typing import Any, Literal, Protocol, Unpack, runtime_checkable from uuid import UUID -from introspection_sdk.pagination import AsyncPager, Pager +from introspection_sdk.pagination import AsyncPager from introspection_sdk.runner_resources._reads import ( - ArrowPageIterator, AsyncArrowPageIterator, ReadFormat, ) @@ -35,7 +21,7 @@ ConversationSentiment, ) from introspection_sdk.runner_resources.files import FileLike -from introspection_sdk.runner_resources.tasks import AsyncRunHandle, RunHandle +from introspection_sdk.runner_resources.tasks import AsyncRunHandle from introspection_sdk.schemas.agui import AGUIEvent, ResumeEntry from introspection_sdk.schemas.automations import ( Automation, @@ -80,540 +66,6 @@ ) from introspection_sdk.schemas.trajectory import Trajectory -__all__ = [ - "AsyncAutomationsResource", - "AsyncConnectionsResource", - "AsyncConversationItemsResource", - "AsyncConversationsResource", - "AsyncDataPlaneResources", - "AsyncEventsResource", - "AsyncFileVersionsResource", - "AsyncFilesResource", - "AsyncMetricsResource", - "AsyncSharesResource", - "AsyncTaskRunsResource", - "AsyncTasksResource", - "AutomationsResource", - "ConnectionsResource", - "ConversationItemsResource", - "ConversationsResource", - "DataPlaneResources", - "EventsResource", - "FileVersionsResource", - "FilesResource", - "MetricsResource", - "SharesResource", - "TaskRunsResource", - "TasksResource", -] - - -@runtime_checkable -class TaskRunsResource(Protocol): - """Protocol of :class:`~introspection_sdk.runner_resources.tasks.TaskRuns`.""" - - def create( - self, - task_id: str, - *, - prompt: TaskPrompt | dict[str, Any] | None = None, - kind: TaskRunKind | str | None = None, - metadata: dict[str, Any] | None = None, - files: list[TaskFileRef | dict[str, Any]] | None = None, - ) -> RunHandle: ... - - def resume( - self, task_id: str, *, resume: list[ResumeEntry | dict[str, Any]] - ) -> RunHandle: ... - - def get(self, task_id: str, run_id: str) -> TaskRun: ... - - def cancel( - self, - task_id: str, - run_id: str, - options: TaskCancelRequest | dict[str, Any] | None = None, - ) -> TaskCancelResponse: ... - - def stream( - self, - task_id: str, - run_id: str, - *, - max_reconnects: int = 5, - backoff: float = 0.5, - timeout: float = 300.0, - ) -> Iterator[AGUIEvent]: ... - - -@runtime_checkable -class TasksResource(Protocol): - """Protocol of :class:`~introspection_sdk.runner_resources.tasks.Tasks`.""" - - @property - def runs(self) -> TaskRunsResource: ... - - def list( - self, - *, - limit: int = 100, - next: str | None = None, - include_total: bool = False, - statuses: builtins.list[str] | None = None, - require_automation_id: bool | None = None, - tag: str | None = None, - ) -> Pager[Task, Paginated[Task]]: ... - - def create( - self, - *, - title: str | None = None, - prompt: str | None = None, - agent_name: str | None = None, - repositories: builtins.list[TaskRepoRequest | dict[str, Any]] - | None = None, - metadata: dict[str, Any] | None = None, - conversation_metadata: dict[str, str] | None = None, - idle_timeout_seconds: int | None = None, - fork_share_id: str | None = None, - files: builtins.list[TaskFileRef | dict[str, Any]] | None = None, - tags: builtins.list[str] | None = None, - ) -> TaskCreateResponse: ... - - def get(self, task_id: str) -> Task: ... - - def update( - self, - task_id: str, - *, - title: str | None = None, - is_archived: bool | None = None, - metadata: dict[str, Any] | None = None, - tags: builtins.list[str] | None = None, - ) -> Task: ... - - def delete(self, task_id: str) -> None: ... - - def archive(self, task_id: str) -> None: ... - - def unarchive(self, task_id: str) -> None: ... - - def start(self, *, prompt: str, **kwargs: Any) -> RunHandle: ... - - -@runtime_checkable -class FileVersionsResource(Protocol): - """Protocol of :class:`~introspection_sdk.runner_resources.files.FileVersions`.""" - - def list( - self, - file_id: str, - *, - limit: int = 100, - next: str | None = None, - include_total: bool = False, - ) -> Pager[File, Paginated[File]]: ... - - def get(self, file_id: str, version_id: str) -> File: ... - - def create( - self, - file_id: str, - *, - file: FileLike, - name: str | None = None, - file_type: FileType | str = FileType.OTHER, - content_type: str | None = None, - ) -> File: ... - - -@runtime_checkable -class FilesResource(Protocol): - """Protocol of :class:`~introspection_sdk.runner_resources.files.Files`.""" - - @property - def versions(self) -> FileVersionsResource: ... - - def list( - self, - *, - limit: int = 100, - next: str | None = None, - include_total: bool = False, - name: str | None = None, - file_type: FileType | str | None = None, - storage_path: str | None = None, - tag: str | None = None, - metadata: dict[str, str] | None = None, - ) -> Pager[File, Paginated[File]]: ... - - def upload( - self, - *, - file: FileLike, - name: str | None = None, - file_type: FileType | str = FileType.OTHER, - content_type: str | None = None, - metadata: dict[str, Any] | None = None, - tags: builtins.list[str] | None = None, - ) -> File: ... - - def create_text( - self, - *, - name: str, - content: str, - mime_type: str = "text/markdown", - metadata: dict[str, Any] | None = None, - tags: builtins.list[str] | None = None, - ) -> File: ... - - def get(self, file_id: str) -> File: ... - - def update( - self, - file_id: str, - *, - name: str | None = None, - metadata: dict[str, Any] | None = None, - tags: builtins.list[str] | None = None, - ) -> File: ... - - def delete(self, file_id: str) -> None: ... - - def download(self, file_id: str) -> bytes: ... - - def download_stream(self, file_id: str) -> Iterator[bytes]: ... - - -@runtime_checkable -class ConversationItemsResource(Protocol): - """Protocol of :class:`~introspection_sdk.runner_resources.conversations.ConversationItems`.""" - - def list( - self, - conversation_id: str, - *, - limit: int = 100, - next: str | None = None, - include: builtins.list[ConversationItemInclude] | None = None, - agent: str | None = None, - service_name: str | None = None, - operation_name: str | None = None, - start_date: str | None = None, - end_date: str | None = None, - lookback_days: int | None = None, - ) -> Pager[GenAiSpan, GenAiSpanList]: ... - - def get( - self, - conversation_id: str, - item_id: str, - *, - include: builtins.list[ConversationItemInclude] | None = None, - ) -> GenAiSpan: ... - - -@runtime_checkable -class ConversationsResource(Protocol): - """Protocol of :class:`~introspection_sdk.runner_resources.conversations.Conversations`.""" - - @property - def items(self) -> ConversationItemsResource: ... - - def list( - self, - *, - limit: int = 100, - next: str | None = None, - conversation_id: str | None = None, - sort: ConversationSortField | None = None, - direction: Literal["asc", "desc"] | None = None, - model: str | None = None, - agent_name: str | None = None, - status: SpanStatus | None = None, - service_name: str | None = None, - service_names: builtins.list[str] | None = None, - environment: str | None = None, - runtime_id: UUID | None = None, - runtime_group_id: UUID | None = None, - experiment_id: UUID | None = None, - recipe_git_commit_sha: str | None = None, - conversation_ids: builtins.list[str] | None = None, - resolution: ConversationResolution | None = None, - sentiment: ConversationSentiment | None = None, - owner_key: str | None = None, - metadata: dict[str, str] | None = None, - start_date: str | datetime | None = None, - end_date: str | datetime | None = None, - order: Literal["asc", "desc"] | None = None, - start: str | datetime | None = None, - end: str | datetime | None = None, - lookback: str | None = None, - format: ReadFormat = "json", - ) -> Pager[Conversation, Paginated[Conversation]]: ... - - def list_arrow( - self, - *, - limit: int = 100, - next: str | None = None, - conversation_id: str | None = None, - sort: ConversationSortField | None = None, - direction: Literal["asc", "desc"] | None = None, - model: str | None = None, - agent_name: str | None = None, - status: SpanStatus | None = None, - service_name: str | None = None, - service_names: builtins.list[str] | None = None, - environment: str | None = None, - runtime_id: UUID | None = None, - runtime_group_id: UUID | None = None, - experiment_id: UUID | None = None, - recipe_git_commit_sha: str | None = None, - conversation_ids: builtins.list[str] | None = None, - resolution: ConversationResolution | None = None, - sentiment: ConversationSentiment | None = None, - owner_key: str | None = None, - metadata: dict[str, str] | None = None, - start_date: str | datetime | None = None, - end_date: str | datetime | None = None, - order: Literal["asc", "desc"] | None = None, - start: str | datetime | None = None, - end: str | datetime | None = None, - lookback: str | None = None, - ) -> ArrowPageIterator: ... - - def iterate( - self, *, max_items: int | None = None, **kwargs: Any - ) -> Iterator[Conversation]: ... - - def get(self, conversation_id: str) -> Conversation: ... - - def export_json( - self, conversation_id: str, **params: Unpack[ConversationExportParams] - ) -> GenAiSpanList: ... - - def export_stream( - self, - conversation_id: str, - format: ConversationExportFormat, - **params: Unpack[ConversationExportParams], - ) -> Iterator[bytes]: ... - - def export_trajectory( - self, - conversation_id: str, - *, - agent: str | None = None, - service_name: str | None = None, - operation_name: str | None = None, - lookback_days: int | None = None, - start_date: str | datetime | None = None, - end_date: str | datetime | None = None, - ) -> Trajectory: ... - - def export_arrow( - self, - conversation_id: str, - *, - agent: str | None = None, - service_name: str | None = None, - operation_name: str | None = None, - lookback_days: int | None = None, - start_date: str | datetime | None = None, - end_date: str | datetime | None = None, - ) -> Any: ... - - def retrieve( - self, conversation_id: str, item_id: str | None = None - ) -> GenAiSpan | None: ... - - -@runtime_checkable -class EventsResource(Protocol): - """Protocol of :class:`~introspection_sdk.runner_resources.events.Events`.""" - - def list( - self, - event_name: str | IntrospectionEventName, - *, - limit: int = 100, - next: str | None = None, - sort: EventSortField | None = None, - direction: Literal["asc", "desc"] | None = None, - order: Literal["asc", "desc"] | None = None, - start: str | datetime | None = None, - end: str | datetime | None = None, - lookback: str | None = None, - start_date: str | datetime | None = None, - end_date: str | datetime | None = None, - conversation_id: str | None = None, - service_name: str | None = None, - environment: str | None = None, - runtime_group_id: UUID | None = None, - trace_id: str | None = None, - span_id: str | None = None, - owner_key: str | None = None, - event_id: builtins.list[str] | None = None, - conversation_ids: builtins.list[str] | None = None, - lens: str | None = None, - pattern_id: str | UUID | None = None, - include_superseded: bool | None = None, - severities: builtins.list[str] | None = None, - runtime_group_unattributed: bool | None = None, - status: str | None = None, - automation_id: UUID | str | None = None, - task_id: UUID | str | None = None, - format: ReadFormat = "json", - ) -> Pager[Event, Paginated[Event]]: ... - - def get(self, event_id: str) -> Event | UnknownEvent: ... - - def iterate( - self, - event_name: str | IntrospectionEventName, - *, - max_items: int | None = None, - **kwargs: Any, - ) -> Iterator[Event]: ... - - def list_arrow( - self, - event_name: str | IntrospectionEventName, - *, - limit: int = 100, - next: str | None = None, - sort: EventSortField | None = None, - direction: Literal["asc", "desc"] | None = None, - order: Literal["asc", "desc"] | None = None, - start: str | datetime | None = None, - end: str | datetime | None = None, - lookback: str | None = None, - start_date: str | datetime | None = None, - end_date: str | datetime | None = None, - conversation_id: str | None = None, - service_name: str | None = None, - environment: str | None = None, - runtime_group_id: UUID | None = None, - trace_id: str | None = None, - span_id: str | None = None, - owner_key: str | None = None, - event_id: builtins.list[str] | None = None, - conversation_ids: builtins.list[str] | None = None, - lens: str | None = None, - pattern_id: str | UUID | None = None, - include_superseded: bool | None = None, - severities: builtins.list[str] | None = None, - runtime_group_unattributed: bool | None = None, - status: str | None = None, - automation_id: UUID | str | None = None, - task_id: UUID | str | None = None, - ) -> ArrowPageIterator: ... - - -@runtime_checkable -class MetricsResource(Protocol): - """Protocol of :class:`~introspection_sdk.runner_resources.metrics.Metrics`.""" - - def query( - self, request: MetricQueryRequest | dict[str, Any] - ) -> MetricQueryResponse: ... - - -@runtime_checkable -class SharesResource(Protocol): - """Protocol of :class:`~introspection_sdk.runner_resources.shares.Shares`.""" - - def list( - self, - *, - limit: int = 100, - next: str | None = None, - resource_type: ShareResourceType | str | None = None, - resource_id: str | None = None, - granted_member_id: str | None = None, - granted_tag: str | None = None, - created_by_me: bool = False, - granted_to_me: bool = False, - ) -> Pager[ResourceShare, Paginated[ResourceShare]]: ... - - def create( - self, - *, - resource_type: ShareResourceType | str, - resource_id: str, - granted_member_id: str | None = None, - granted_tag: str | None = None, - visible_from: datetime | None = None, - ) -> ResourceShare: ... - - def get(self, share_id: str) -> ResourceShare: ... - - def update( - self, - share_id: str, - *, - visible_from: datetime | None, - ) -> ResourceShare: ... - - def delete(self, share_id: str) -> None: ... - - -@runtime_checkable -class AutomationsResource(Protocol): - """Protocol of :class:`~introspection_sdk.runner_resources.automations.Automations`.""" - - def list( - self, - *, - kind: AutomationKind | str | None = None, - enabled: bool | None = None, - scheduled: bool | None = None, - task_id: UUID | str | None = None, - limit: int | None = None, - next: str | None = None, - ) -> Pager[Automation, Paginated[Automation]]: ... - - def get(self, automation_id: UUID | str) -> Automation: ... - - def create( - self, - *, - name: str, - trigger_type: AutomationTriggerType | str, - description: str | None = None, - cron_schedule: str | None = None, - kind: AutomationKind | str | None = None, - prompt: str | None = None, - runtime_group_id: UUID | None = None, - task_id: UUID | None = None, - next_trigger_at: datetime | None = None, - metadata: MetadataInput | None = None, - enabled: bool | None = None, - ) -> Automation: ... - - def update( - self, - automation_id: UUID | str, - *, - name: str | None = None, - description: str | None = None, - cron_schedule: str | None = None, - prompt: str | None = None, - runtime_group_id: UUID | None = None, - task_id: UUID | None = None, - next_trigger_at: datetime | None = None, - metadata: MetadataInput | None = None, - enabled: bool | None = None, - ) -> Automation: ... - - def delete(self, automation_id: UUID | str) -> None: ... - - def trigger( - self, automation_id: UUID | str - ) -> AutomationTriggerResponse: ... - @runtime_checkable class AsyncTaskRunsResource(Protocol): @@ -1122,56 +574,6 @@ async def trigger( ) -> AutomationTriggerResponse: ... -@runtime_checkable -class ConnectionsResource(Protocol): - """Protocol of :class:`~introspection_sdk.runner_resources.connections.AppConnections`.""" - - def list( - self, - *, - member_id: UUID | str | None = None, - app: str | None = None, - limit: int | None = None, - next: str | None = None, - ) -> Pager[Connection, Paginated[Connection]]: ... - - def create(self, *, app: str, runtime: str | UUID) -> ConnectPage: ... - - def get(self, connection_id: UUID | str) -> Connection: ... - - def delete(self, connection_id: UUID | str) -> None: ... - - -@runtime_checkable -class DataPlaneResources(Protocol): - """The data-plane namespaces :class:`~introspection_sdk.IntrospectionClient` - and :class:`~introspection_sdk.runner.Runner` both expose.""" - - @property - def tasks(self) -> TasksResource: ... - - @property - def files(self) -> FilesResource: ... - - @property - def conversations(self) -> ConversationsResource: ... - - @property - def events(self) -> EventsResource: ... - - @property - def metrics(self) -> MetricsResource: ... - - @property - def shares(self) -> SharesResource: ... - - @property - def automations(self) -> AutomationsResource: ... - - @property - def connections(self) -> ConnectionsResource: ... - - @runtime_checkable class AsyncConnectionsResource(Protocol): """Protocol of :class:`~introspection_sdk.runner_resources.connections.AsyncAppConnections`.""" diff --git a/introspection_sdk/_async/proxy/__init__.py b/introspection_sdk/_async/proxy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/introspection_sdk/_async/proxy/_transport.py b/introspection_sdk/_async/proxy/_transport.py index 2b94b52..8ff8032 100644 --- a/introspection_sdk/_async/proxy/_transport.py +++ b/introspection_sdk/_async/proxy/_transport.py @@ -1,4 +1,4 @@ -"""HTTP-library-neutral transport routing implementation.""" +"""Re-exported by :mod:`introspection_sdk.proxy._transport`.""" from __future__ import annotations @@ -67,31 +67,6 @@ def select_transport( return direct, False -def handle_request( - http: Any, - config: ProxyConfig, - request: Any, - *, - direct: Any, - egress: Any, - forward: Any | None, - send: Callable[[Any, Any], Any], -) -> Any: - transport, rewrite = select_transport( - config, - request, - direct=direct, - egress=egress, - forward=forward, - ) - if rewrite: - assert config.egress_url is not None - request = egress_request( - http, request, config.egress_url, config.relay_target - ) - return send(transport, request) - - async def handle_async_request( http: Any, config: ProxyConfig, diff --git a/introspection_sdk/_async/proxy/httpx.py b/introspection_sdk/_async/proxy/httpx.py index 93a7075..f8865db 100644 --- a/introspection_sdk/_async/proxy/httpx.py +++ b/introspection_sdk/_async/proxy/httpx.py @@ -1,4 +1,4 @@ -"""Legacy :mod:`httpx` transports for Harbor and vendor SDK compatibility.""" +"""Re-exported by :mod:`introspection_sdk.proxy.httpx`.""" from __future__ import annotations @@ -15,60 +15,10 @@ from introspection_sdk.proxy._transport import ( forward_proxy, handle_async_request, - handle_request, unique_transports, ) -class IntrospectionTransport(httpx.BaseTransport): - """Synchronous legacy-``httpx`` endpoint-binding transport.""" - - def __init__( - self, - config: ProxyConfig | None = None, - *, - verify: ssl.SSLContext | bool | str = True, - _direct: httpx.BaseTransport | None = None, - _egress: httpx.BaseTransport | None = None, - _forward: httpx.BaseTransport | None = None, - ) -> None: - self.config = config or ProxyConfig.from_env() - ssl_context = httpx.create_ssl_context(verify=verify) - self._direct = _direct or httpx.HTTPTransport(verify=ssl_context) - self._egress = _egress or httpx.HTTPTransport(verify=ssl_context) - self._forward = _forward - if self._forward is None and self.config.forward_proxy_url: - self._forward = httpx.HTTPTransport( - verify=ssl_context, - proxy=forward_proxy( - httpx, self.config.forward_proxy_url, ssl_context - ), - ) - - @classmethod - def from_env( - cls, *, verify: ssl.SSLContext | bool | str = True - ) -> IntrospectionTransport: - return cls(ProxyConfig.from_env(), verify=verify) - - def handle_request(self, request: httpx.Request) -> httpx.Response: - return handle_request( - httpx, - self.config, - request, - direct=self._direct, - egress=self._egress, - forward=self._forward, - send=lambda transport, value: transport.handle_request(value), - ) - - def close(self) -> None: - for transport in unique_transports( - self._direct, self._egress, self._forward - ): - transport.close() - - class AsyncIntrospectionTransport(httpx.AsyncBaseTransport): """Asynchronous legacy-``httpx`` endpoint-binding transport.""" @@ -120,6 +70,3 @@ async def aclose(self) -> None: self._direct, self._egress, self._forward ): await transport.aclose() - - -__all__ = ["AsyncIntrospectionTransport", "IntrospectionTransport"] diff --git a/introspection_sdk/_async/proxy/httpx2.py b/introspection_sdk/_async/proxy/httpx2.py index 00bb049..a30e1de 100644 --- a/introspection_sdk/_async/proxy/httpx2.py +++ b/introspection_sdk/_async/proxy/httpx2.py @@ -1,4 +1,4 @@ -"""Native :mod:`httpx2` transports for Introspection egress.""" +"""Re-exported by :mod:`introspection_sdk.proxy.httpx2`.""" from __future__ import annotations @@ -10,60 +10,10 @@ from introspection_sdk.proxy._transport import ( forward_proxy, handle_async_request, - handle_request, unique_transports, ) -class IntrospectionTransport(httpx.BaseTransport): - """Synchronous ``httpx2`` endpoint-binding transport.""" - - def __init__( - self, - config: ProxyConfig | None = None, - *, - verify: ssl.SSLContext | bool | str = True, - _direct: httpx.BaseTransport | None = None, - _egress: httpx.BaseTransport | None = None, - _forward: httpx.BaseTransport | None = None, - ) -> None: - self.config = config or ProxyConfig.from_env() - ssl_context = httpx.create_ssl_context(verify=verify) - self._direct = _direct or httpx.HTTPTransport(verify=ssl_context) - self._egress = _egress or httpx.HTTPTransport(verify=ssl_context) - self._forward = _forward - if self._forward is None and self.config.forward_proxy_url: - self._forward = httpx.HTTPTransport( - verify=ssl_context, - proxy=forward_proxy( - httpx, self.config.forward_proxy_url, ssl_context - ), - ) - - @classmethod - def from_env( - cls, *, verify: ssl.SSLContext | bool | str = True - ) -> IntrospectionTransport: - return cls(ProxyConfig.from_env(), verify=verify) - - def handle_request(self, request: httpx.Request) -> httpx.Response: - return handle_request( - httpx, - self.config, - request, - direct=self._direct, - egress=self._egress, - forward=self._forward, - send=lambda transport, value: transport.handle_request(value), - ) - - def close(self) -> None: - for transport in unique_transports( - self._direct, self._egress, self._forward - ): - transport.close() - - class AsyncIntrospectionTransport(httpx.AsyncBaseTransport): """Asynchronous ``httpx2`` endpoint-binding transport.""" @@ -115,6 +65,3 @@ async def aclose(self) -> None: self._direct, self._egress, self._forward ): await transport.aclose() - - -__all__ = ["AsyncIntrospectionTransport", "IntrospectionTransport"] diff --git a/introspection_sdk/_async/resources/__init__.py b/introspection_sdk/_async/resources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/introspection_sdk/_async/resources/connectors.py b/introspection_sdk/_async/resources/connectors.py index e079995..e65ea60 100644 --- a/introspection_sdk/_async/resources/connectors.py +++ b/introspection_sdk/_async/resources/connectors.py @@ -1,16 +1,4 @@ -"""``client.connectors`` — CP namespace for ``/v1/connectors``. - -Connectors are provider integrations (Slack, Gmail, Stripe, ...) a -project owns; connections are the authorized subjects living under -them (``client.connectors.connections.*``). ``authorize`` mints the -consent URL a Business hands its customer — the SDK presents it as a -connector operation even though the route lives under ``/v1/oauth/``. - -``client_secret`` / ``signing_secret`` are write-only: accepted on -create and update, absent from every response, and omitting them on -update means "unchanged". Project scope comes from the authenticated -credential; connector calls do not take a separate project selector. -""" +"""Re-exported by :mod:`introspection_sdk.resources.connectors`.""" from __future__ import annotations @@ -18,12 +6,10 @@ from typing import Any from uuid import UUID -from introspection_sdk._http import _AsyncHttpClient, _HttpClient +from introspection_sdk._http import _AsyncHttpClient from introspection_sdk.pagination import ( AsyncPager, - Pager, async_cursor_paginate, - cursor_paginate, ) from introspection_sdk.schemas.connectors import ( Connection, @@ -209,380 +195,18 @@ def _token_result(payload: Any) -> ConnectionTokenResult: return ConnectionToken.model_validate(payload) -class Connections: +class AsyncConnections: + # unasync: async-only (the sync twin's docstring follows) + """Async twin of :class:`Connections` + (CP ``/v1/connectors/{connector_id}/connections``).""" + + # unasync: end """CP ``/v1/connectors/{connector_id}/connections`` namespace. Connections are addressed by connector id and take no ``project`` parameter. ``revoke`` (not ``delete``) destroys the provider token. """ - def __init__(self, http: _HttpClient) -> None: - self._http = http - - def list( - self, - connector_id: UUID, - *, - limit: int | None = None, - next: str | None = None, - ) -> Pager[Connection, Paginated[Connection]]: - """List a connector's connections. Iterate the returned - :class:`Pager` to stream every connection across pages, or call - ``.page()`` for the first page only.""" - - def fetch(cursor: str | None) -> Paginated[Connection]: - params: dict[str, Any] = {"limit": limit, "next": cursor} - payload = self._http.request( - "GET", - f"/v1/connectors/{connector_id}/connections", - params=params, - ) - return Paginated[Connection].model_validate(payload) - - return cursor_paginate(fetch, start=next) - - def create( - self, - connector_id: UUID, - *, - access_token: str, - subject_type: ConnectionCreateSubjectType | str | None = None, - scopes_granted: builtins.list[str] | None = None, - refresh_token: str | None = None, - token_expires_at: Any = None, - ) -> Connection: - """Register a connection with a caller-supplied provider token - (registered mode). Tokens are write-only — stored encrypted, - never returned.""" - payload = self._http.request( - "POST", - f"/v1/connectors/{connector_id}/connections", - json=_connection_create_body( - access_token=access_token, - subject_type=subject_type, - scopes_granted=scopes_granted, - refresh_token=refresh_token, - token_expires_at=token_expires_at, - ), - ) - return Connection.model_validate(payload) - - def get_token( - self, - connector_id: UUID, - *, - subject: ConnectionBrokerSubjectType | str | None = None, - action: str | None = None, - requested_permissions: ConnectionMissionConstraints - | dict[str, Any] - | None = None, - ) -> ConnectionTokenResult: - """Resolve a provider token, or a pending human-approval mission.""" - payload = self._http.request( - "POST", - "/v1/oauth/connections/token", - json=_token_body( - connector_id=connector_id, - subject=subject, - action=action, - requested_permissions=requested_permissions, - ), - ) - return _token_result(payload) - - def get(self, connector_id: UUID, connection_id: UUID) -> Connection: - payload = self._http.request( - "GET", - f"/v1/connectors/{connector_id}/connections/{connection_id}", - ) - return Connection.model_validate(payload) - - def revoke(self, connector_id: UUID, connection_id: UUID) -> None: - """Revoke a connection — destroys the stored provider token.""" - self._http.request( - "DELETE", - f"/v1/connectors/{connector_id}/connections/{connection_id}", - expect="empty", - ) - - -class Connectors: - """CP ``/v1/connectors`` namespace. - - Connector CRUD uses the project carried by the authenticated - credential. Nested ``connections`` and ``authorize`` are addressed - by connector id alone. - """ - - def __init__(self, http: _HttpClient) -> None: - self._http = http - self.connections = Connections(http) - - def list( - self, - *, - limit: int | None = None, - next: str | None = None, - ) -> Pager[Connector, Paginated[Connector]]: - """List connectors. Iterate the returned :class:`Pager` to - stream every connector across pages, or call ``.page()`` for the - first page only.""" - - def fetch(cursor: str | None) -> Paginated[Connector]: - params: dict[str, Any] = { - "limit": limit, - "next": cursor, - } - payload = self._http.request( - "GET", "/v1/connectors", params=params - ) - return Paginated[Connector].model_validate(payload) - - return cursor_paginate(fetch, start=next) - - def create( - self, - *, - name: str, - provider: str, - auth_mode: ConnectorAuthMode | str, - slug: str | None = None, - environment: str | None = None, - agent_member_id: UUID | None = None, - authorization_endpoint: str | None = None, - token_endpoint: str | None = None, - scopes: builtins.list[str] | None = None, - api_hosts: builtins.list[str] | None = None, - client_id: str | None = None, - client_secret: str | None = None, - signing_secret: str | None = None, - metadata: dict[str, Any] | None = None, - issuer: str | None = None, - person_server_url: str | None = None, - application_id: UUID | None = None, - assertion_audience: str | None = None, - policy_entities: builtins.list[PolicyEntity | dict[str, Any]] - | None = None, - webhook_url: str | None = None, - ) -> Connector: - """Create a connector, or replace one with the same ``slug``. - - ``slug`` is unique per project. A repeat create with a live slug - replaces that connector's configuration (name, environment, - endpoints, scopes, api hosts, client id, metadata, ...) and keeps - its ``provider``, ``auth_mode`` and stored secrets, so re-running - a create never duplicates the connector. - - ``client_secret`` / ``signing_secret`` are write-only — never - returned on any read. Pass ``issuer`` to have the server resolve - the OAuth endpoints from ``.well-known`` discovery and, where the - provider supports it, register an OAuth client on its own. A - ``pipedream`` connector requires ``metadata["provider_workspace_id"]`` - — its Pipedream Connect project id (``proj_...``). An ``aauth`` - connector requires ``person_server_url`` and ``application_id``; - ``policy_entities`` are the Cedar entities the egress policy gate - adds to its requests. - """ - payload = self._http.request( - "POST", - "/v1/connectors", - json=_create_body( - name=name, - provider=provider, - auth_mode=auth_mode, - slug=slug, - environment=environment, - agent_member_id=agent_member_id, - authorization_endpoint=authorization_endpoint, - token_endpoint=token_endpoint, - scopes=scopes, - api_hosts=api_hosts, - client_id=client_id, - client_secret=client_secret, - signing_secret=signing_secret, - metadata=metadata, - issuer=issuer, - person_server_url=person_server_url, - application_id=application_id, - assertion_audience=assertion_audience, - policy_entities=policy_entities, - webhook_url=webhook_url, - ), - ) - return Connector.model_validate(payload) - - def list_apps( - self, - connector_id: UUID, - *, - query: str | None = None, - limit: int | None = None, - ) -> builtins.list[ConnectorApp]: - """Search a connector's provider application catalogue.""" - payload = self._http.request( - "GET", - f"/v1/connectors/{connector_id}/apps", - params={"q": query, "limit": limit}, - ) - return [ConnectorApp.model_validate(app) for app in payload["data"]] - - def search_custom_apps( - self, - query: str, - *, - limit: int | None = None, - ) -> builtins.list[ConnectorApp]: - """Search the open MCP registry — the catalogue a custom connector - picks from (``GET /v1/connectors/custom/apps``). - - Takes no connector and no project: it is read before any - connector exists. ``query`` is 2–100 characters; ``limit`` is - 1–50 (server default 20). A listing's ``mcp_url`` is what to pass - to :meth:`discover_oauth` and ``create(issuer=...)``. - """ - payload = self._http.request( - "GET", - "/v1/connectors/custom/apps", - params={"q": query, "limit": limit}, - ) - return [ConnectorApp.model_validate(app) for app in payload["data"]] - - def discover_oauth(self, issuer: str) -> ConnectorOAuthDiscoveryResponse: - """Resolve a provider's OAuth metadata before creating a connector - (``POST /v1/connectors/discover-oauth``). - - ``issuer`` is an authorization-server issuer URL or an MCP server - URL. Discovery may REGISTER an OAuth client with the provider - (dynamic registration) and return its ``client_id`` / - ``client_secret`` — pass those to :meth:`create` so a second - client is not registered. ``create`` with only ``issuer`` also - discovers and registers on its own. Raises - :class:`~introspection_sdk.ValidationError` (400) when discovery - fails. - """ - payload = self._http.request( - "POST", - "/v1/connectors/discover-oauth", - json=ConnectorOAuthDiscoveryRequest(issuer=issuer).model_dump( - mode="json" - ), - ) - return ConnectorOAuthDiscoveryResponse.model_validate(payload) - - def get(self, connector_id: UUID) -> Connector: - payload = self._http.request( - "GET", - f"/v1/connectors/{connector_id}", - ) - return Connector.model_validate(payload) - - def update( - self, - connector_id: UUID, - *, - name: str | None = None, - agent_member_id: UUID | None = None, - scopes: builtins.list[str] | None = None, - api_hosts: builtins.list[str] | None = None, - status: ConnectorStatus | str | None = None, - metadata: dict[str, Any] | None = None, - webhook_url: str | None = None, - policy_entities: builtins.list[PolicyEntity | dict[str, Any]] - | None = None, - client_secret: str | None = None, - signing_secret: str | None = None, - ) -> Connector: - """Update a connector. Only provided fields change; an omitted - ``client_secret`` / ``signing_secret`` stays as it is (write-only - rotation, never a clear). ``policy_entities`` replaces the - connector's Cedar entities.""" - payload = self._http.request( - "PATCH", - f"/v1/connectors/{connector_id}", - json=_update_body( - name=name, - agent_member_id=agent_member_id, - scopes=scopes, - api_hosts=api_hosts, - status=status, - metadata=metadata, - webhook_url=webhook_url, - policy_entities=policy_entities, - client_secret=client_secret, - signing_secret=signing_secret, - ), - ) - return Connector.model_validate(payload) - - def delete(self, connector_id: UUID) -> None: - """Soft-delete a connector; its connections are revoked.""" - self._http.request( - "DELETE", - f"/v1/connectors/{connector_id}", - expect="empty", - ) - - def authorize( - self, - connector_id: UUID, - *, - runtime: str | UUID | None = None, - subject: ConnectionBrokerSubjectType | str | None = None, - return_url: str | None = None, - expires_in: int | None = None, - identity: RunnerIdentity | dict[str, Any] | None = None, - app: str | None = None, - allow_progressive_scopes: bool = False, - binding: ConnectorAuthorizeBinding | dict[str, Any] | None = None, - ) -> ConnectorAuthorization: - """Mint a consent URL for the connector - (``POST /v1/oauth/connections/authorize``). - - Each call writes a fresh single-use ``state``: two calls give - two different URLs, and the response must never be cached. The - URL stays valid for ``expires_in`` seconds (60–86400, default - 600) — raise it when handing the link to someone else to open. A - connector with ``requires_runtime=True`` 422s unless ``runtime`` - (slug or runtime group id) names the agent that replies. - - ``identity`` asserts the end customer the grant is for: its - ``user_id`` resolves a ``customer`` member recorded as the - connection's ``created_by_member_id``, so a partner can associate - the connection with their own caller rather than the agent member - that made the API call. Omit to attribute the grant to the - authenticated principal. Asserting one mints a ``customer`` - member, so it can raise - :class:`~introspection_sdk.ConflictError` (409) when the org has - reached its member limit — a plan conflict, not back-pressure. - - ``binding`` names the MCP endpoint (environment, Recipe MCP - server id, https URL) the grant completes into. It requires - ``runtime``; on a successful grant the control plane writes the - endpoint binding in the same transaction as the connection, so - the runtime is never authorized-but-unbound. - """ - payload = self._http.request( - "POST", - "/v1/oauth/connections/authorize", - json=_authorize_body( - connector_id=connector_id, - runtime=runtime, - subject=subject, - return_url=return_url, - expires_in=expires_in, - identity=identity, - app=app, - allow_progressive_scopes=allow_progressive_scopes, - binding=binding, - ), - ) - return ConnectorAuthorization.model_validate(payload) - - -class AsyncConnections: - """Async twin of :class:`Connections` - (CP ``/v1/connectors/{connector_id}/connections``).""" - def __init__(self, http: _AsyncHttpClient) -> None: self._http = http @@ -593,9 +217,14 @@ def list( limit: int | None = None, next: str | None = None, ) -> AsyncPager[Connection, Paginated[Connection]]: + # unasync: async-only (the sync twin's docstring follows) """List a connector's connections. ``await`` the returned :class:`AsyncPager` for the first page, or ``async for`` it to stream every connection across pages.""" + # unasync: end + """List a connector's connections. Iterate the returned + :class:`Pager` to stream every connection across pages, or call + ``.page()`` for the first page only.""" async def fetch(cursor: str | None) -> Paginated[Connection]: params: dict[str, Any] = {"limit": limit, "next": cursor} @@ -674,7 +303,12 @@ async def revoke(self, connector_id: UUID, connection_id: UUID) -> None: class AsyncConnectors: - """Async twin of :class:`Connectors` (CP ``/v1/connectors``).""" + """CP ``/v1/connectors`` namespace. + + Connector CRUD uses the project carried by the authenticated + credential. Nested ``connections`` and ``authorize`` are addressed + by connector id alone. + """ def __init__(self, http: _AsyncHttpClient) -> None: self._http = http @@ -686,9 +320,14 @@ def list( limit: int | None = None, next: str | None = None, ) -> AsyncPager[Connector, Paginated[Connector]]: + # unasync: async-only (the sync twin's docstring follows) """List connectors. ``await`` the returned :class:`AsyncPager` for the first page, or ``async for`` it to stream every connector across pages.""" + # unasync: end + """List connectors. Iterate the returned :class:`Pager` to + stream every connector across pages, or call ``.page()`` for the + first page only.""" async def fetch(cursor: str | None) -> Paginated[Connector]: params: dict[str, Any] = { @@ -941,11 +580,3 @@ async def authorize( ), ) return ConnectorAuthorization.model_validate(payload) - - -__all__ = [ - "AsyncConnections", - "AsyncConnectors", - "Connections", - "Connectors", -] diff --git a/introspection_sdk/_async/resources/experiments.py b/introspection_sdk/_async/resources/experiments.py index 9fbe93d..d79bdde 100644 --- a/introspection_sdk/_async/resources/experiments.py +++ b/introspection_sdk/_async/resources/experiments.py @@ -1,13 +1,4 @@ -"""``client.experiments`` — CP CRUD + lifecycle + ``.run()`` returning a -:class:`Runner`. - -Request documents for create/update are passed through to the Control Plane, -whose experiment schema is the source of truth. - -``client.experiments`` is the :class:`Experiments` instance; calling -``client.experiments(id)`` returns an :class:`ExperimentHandle` with -``.run()``, ``.start()``, ``.end(...)``, and ``.cancel()``. -""" +"""Re-exported by :mod:`introspection_sdk.resources.experiments`.""" from __future__ import annotations @@ -16,14 +7,12 @@ from uuid import UUID from introspection_sdk._errors import ValidationError -from introspection_sdk._http import _AsyncHttpClient, _HttpClient +from introspection_sdk._http import _AsyncHttpClient from introspection_sdk.pagination import ( AsyncPager, - Pager, async_cursor_paginate, - cursor_paginate, ) -from introspection_sdk.runner import AsyncRunner, Runner +from introspection_sdk.runner import AsyncRunner from introspection_sdk.schemas.experiments import Experiment from introspection_sdk.schemas.pagination import Paginated from introspection_sdk.schemas.runner import ( @@ -34,237 +23,19 @@ ) -class Experiments: - """CP ``/v1/experiments`` namespace. +class AsyncExperiments: + # unasync: async-only (the sync twin's docstring follows) + """Async twin of :class:`Experiments` (CP ``/v1/experiments``). Also callable: ``client.experiments(id)`` returns an - :class:`ExperimentHandle`. - """ - - def __init__( - self, - http: _HttpClient, - *, - additional_headers: Mapping[str, str] | None = None, - ) -> None: - self._http = http - self._additional_headers = additional_headers - - def __call__(self, experiment_id: UUID) -> ExperimentHandle: - return ExperimentHandle(self, experiment_id=experiment_id) - - # --- reads -------------------------------------------------------- - - def list( - self, - *, - project: str | UUID | None = None, - runtime: str | UUID | None = None, - environment: str | None = None, - status: str | None = None, - limit: int = 100, - next: str | None = None, - ) -> Pager[Experiment, Paginated[Experiment]]: - """List experiments. Iterate the returned :class:`Pager` to stream - every experiment across pages, or call ``.page()`` for the first - page only.""" - - def fetch(cursor: str | None) -> Paginated[Experiment]: - params: dict[str, Any] = { - "project": str(project) if project else None, - "runtime": str(runtime) if runtime is not None else None, - "environment": environment, - "status": status, - "limit": limit, - "next": cursor, - } - payload = self._http.request( - "GET", "/v1/experiments", params=params - ) - return Paginated[Experiment].model_validate(payload) - - return cursor_paginate(fetch, start=next) - - def get( - self, experiment_id: UUID, *, project: str | UUID | None = None - ) -> Experiment: - params: dict[str, Any] = {} - if project: - params["project"] = str(project) - payload = self._http.request( - "GET", - f"/v1/experiments/{experiment_id}", - params=params or None, - ) - return Experiment.model_validate(payload) - - def create( - self, - document: Mapping[str, Any], - *, - project: str | UUID | None = None, - ) -> Experiment: - body = dict(document) - if project is not None: - if "project" in body and "project_id" in body: - raise ValidationError( - "experiment document must not contain both project and project_id", - status_code=422, - code="conflicting_experiment_project", - ) - supplied = body.get("project", body.get("project_id")) - if supplied is not None and str(supplied) != str(project): - raise ValidationError( - "experiment document project conflicts with project", - status_code=422, - code="conflicting_experiment_project", - ) - body.pop("project_id", None) - body["project"] = str(project) - return Experiment.model_validate( - self._http.request("POST", "/v1/experiments", json=body) - ) - - def update( - self, - experiment_id: UUID, - document: Mapping[str, Any], - *, - project: str | UUID | None = None, - ) -> Experiment: - return Experiment.model_validate( - self._http.request( - "PATCH", - f"/v1/experiments/{experiment_id}", - params={ - "project": str(project) if project is not None else None - }, - json=dict(document), - ) - ) - - def delete( - self, - experiment_id: UUID, - *, - project: str | UUID | None = None, - ) -> None: - self._http.request( - "DELETE", - f"/v1/experiments/{experiment_id}", - params={"project": str(project) if project is not None else None}, - expect="empty", - ) - - def _post_run( - self, - experiment_id: UUID, - options: RunRequest, - ) -> RunnerSpec: - body: dict[str, Any] = options.model_dump( - exclude_none=True, mode="json" - ) - payload = self._http.request( - "POST", f"/v1/experiments/{experiment_id}/run", json=body - ) - return RunnerSpec.model_validate(payload) - - def _start(self, experiment_id: UUID) -> Experiment: - payload = self._http.request( - "POST", f"/v1/experiments/{experiment_id}/start" - ) - return Experiment.model_validate(payload) - - def _end(self, experiment_id: UUID) -> Experiment: - payload = self._http.request( - "POST", - f"/v1/experiments/{experiment_id}/end", - ) - return Experiment.model_validate(payload) - - def _cancel(self, experiment_id: UUID) -> Experiment: - payload = self._http.request( - "POST", f"/v1/experiments/{experiment_id}/cancel" - ) - return Experiment.model_validate(payload) - - -class ExperimentHandle: - """Handle for a specific experiment. - - Built by ``client.experiments(id)``. + :class:`AsyncExperimentHandle`. """ - def __init__( - self, - experiments: Experiments, - *, - experiment_id: UUID, - ) -> None: - self._experiments = experiments - self._experiment_id = experiment_id - - @property - def experiment_id(self) -> UUID: - return self._experiment_id - - def run( - self, - *, - identity: RunnerIdentity | dict[str, Any] | None = None, - caller: RunCaller | dict[str, Any] | None = None, - agent_name: str | None = None, - ttl_seconds: int | None = 3600, - scope: str | None = None, - ) -> Runner: - ident: RunnerIdentity | None - if identity is None: - ident = None - elif isinstance(identity, RunnerIdentity): - ident = identity - else: - ident = RunnerIdentity.model_validate(identity) - call: RunCaller | None - if caller is None: - call = None - elif isinstance(caller, RunCaller): - call = caller - else: - call = RunCaller.model_validate(caller) - options = RunRequest( - identity=ident, - caller=call, - agent_name=agent_name, - ttl_seconds=ttl_seconds, - scope=scope, - ) - eid = self._experiment_id - - def refresher() -> RunnerSpec: - return self._experiments._post_run(eid, options) - - spec = refresher() - return Runner( - spec, - refresher=refresher, - additional_headers=self._experiments._additional_headers, - ) - - def start(self) -> Experiment: - return self._experiments._start(self._experiment_id) - - def end(self) -> Experiment: - return self._experiments._end(self._experiment_id) - - def cancel(self) -> Experiment: - return self._experiments._cancel(self._experiment_id) - - -class AsyncExperiments: - """Async twin of :class:`Experiments` (CP ``/v1/experiments``). + # unasync: end + """CP ``/v1/experiments`` namespace. Also callable: ``client.experiments(id)`` returns an - :class:`AsyncExperimentHandle`. + :class:`ExperimentHandle`. """ def __init__( @@ -291,9 +62,14 @@ def list( limit: int = 100, next: str | None = None, ) -> AsyncPager[Experiment, Paginated[Experiment]]: + # unasync: async-only (the sync twin's docstring follows) """List experiments. ``await`` the returned :class:`AsyncPager` for the first page, or ``async for`` it to stream every experiment across pages.""" + # unasync: end + """List experiments. Iterate the returned :class:`Pager` to stream + every experiment across pages, or call ``.page()`` for the first + page only.""" async def fetch(cursor: str | None) -> Paginated[Experiment]: params: dict[str, Any] = { @@ -416,11 +192,18 @@ async def _cancel(self, experiment_id: UUID) -> Experiment: class AsyncExperimentHandle: + # unasync: async-only (the sync twin's docstring follows) """Async twin of :class:`ExperimentHandle`. Built by ``client.experiments(id)``. """ + # unasync: end + """Handle for a specific experiment. + + Built by ``client.experiments(id)``. + """ + def __init__( self, experiments: AsyncExperiments, @@ -484,11 +267,3 @@ async def end(self) -> Experiment: async def cancel(self) -> Experiment: return await self._experiments._cancel(self._experiment_id) - - -__all__ = [ - "AsyncExperimentHandle", - "AsyncExperiments", - "ExperimentHandle", - "Experiments", -] diff --git a/introspection_sdk/_async/resources/members.py b/introspection_sdk/_async/resources/members.py index 47b0c03..b199a5f 100644 --- a/introspection_sdk/_async/resources/members.py +++ b/introspection_sdk/_async/resources/members.py @@ -1,17 +1,4 @@ -"""``client.members`` — CP namespace for ``/v1/members``. - -Members are the organization's principals: ``business`` humans, -``agent`` members, and ``customer`` members minted from an asserted -identity (:class:`~introspection_sdk.schemas.runner.RunnerIdentity`). -``create`` invites a human by email, or (owner only) records a -``customer`` member by its ``external_user_id``; customer members are -also minted by identity assertion. - -Reads need ``members:read``. ``update`` needs ``members:manage`` (owner), -as does seeding ``tags`` or ``policy_attributes`` on ``create``: a member -tag is access-bearing, and policy attributes decide what the member's agents -may do. ``metadata`` grants nothing. -""" +"""Re-exported by :mod:`introspection_sdk.resources.members`.""" from __future__ import annotations @@ -21,12 +8,10 @@ from pydantic import BaseModel -from introspection_sdk._http import _AsyncHttpClient, _HttpClient +from introspection_sdk._http import _AsyncHttpClient from introspection_sdk.pagination import ( AsyncPager, - Pager, async_cursor_paginate, - cursor_paginate, ) from introspection_sdk.schemas.members import ( MEMBER_CREATE_ADAPTER, @@ -99,10 +84,10 @@ def _update_body( ).model_dump(mode="json", exclude_none=True) -class Members: +class AsyncMembers: """CP ``/v1/members`` namespace.""" - def __init__(self, http: _HttpClient) -> None: + def __init__(self, http: _AsyncHttpClient) -> None: self._http = http def list( @@ -116,7 +101,19 @@ def list( metadata: dict[str, str] | None = None, limit: int | None = None, next: str | None = None, - ) -> Pager[Member, Paginated[Member]]: + ) -> AsyncPager[Member, Paginated[Member]]: + # unasync: async-only (the sync twin's docstring follows) + """List members. ``await`` the returned :class:`AsyncPager` for the + first page, or ``async for`` it to stream every member across + pages. + + Filters narrow and AND together. ``tag`` matches one access-bearing + tag exactly. ``metadata`` matches members whose metadata holds every + pair, each compared exactly against the string value; it is sent as + one repeated ``metadata=key:value`` param per entry (distinct keys, + at most 16). ``project`` is only needed by a credential that is not + already org-scoped (a deployment token).""" + # unasync: end """List members. Iterate the returned :class:`Pager` to stream every member across pages, or call ``.page()`` for the first page only. @@ -127,7 +124,7 @@ def list( at most 16). ``project`` is only needed by a credential that is not already org-scoped (a deployment token).""" - def fetch(cursor: str | None) -> Paginated[Member]: + async def fetch(cursor: str | None) -> Paginated[Member]: params = _list_params( project=project, member_type=member_type, @@ -138,22 +135,24 @@ def fetch(cursor: str | None) -> Paginated[Member]: limit=limit, cursor=cursor, ) - payload = self._http.request("GET", "/v1/members", params=params) + payload = await self._http.request( + "GET", "/v1/members", params=params + ) return Paginated[Member].model_validate(payload) - return cursor_paginate(fetch, start=next) + return async_cursor_paginate(fetch, start=next) - def get( + async def get( self, member_id: UUID, *, project: str | UUID | None = None ) -> Member: - payload = self._http.request( + payload = await self._http.request( "GET", f"/v1/members/{member_id}", params={"project": str(project) if project else None}, ) return Member.model_validate(payload) - def create( + async def create( self, member: BusinessMemberCreate | CustomerMemberCreate | dict[str, Any], ) -> Member: @@ -169,12 +168,12 @@ def create( needs ``members:manage``. ``metadata`` keys are letters, digits, ``_`` and ``-``, values are non-empty strings, at most 64 entries per write; the server answers 422 otherwise.""" - payload = self._http.request( + payload = await self._http.request( "POST", "/v1/members", json=_create_body(member) ) return Member.model_validate(payload) - def update( + async def update( self, member_id: UUID, *, @@ -192,100 +191,6 @@ def update( key, read the member, edit its map, and send the whole map back. ``policy_attributes`` replaces the member's Cedar attributes for the egress policy gate; ``None`` leaves them untouched.""" - payload = self._http.request( - "PATCH", - f"/v1/members/{member_id}", - json=_update_body( - name=name, - image_url=image_url, - role=role, - tags=tags, - metadata=metadata, - policy_attributes=policy_attributes, - ), - ) - return Member.model_validate(payload) - - -class AsyncMembers: - """Async twin of :class:`Members` (CP ``/v1/members``).""" - - def __init__(self, http: _AsyncHttpClient) -> None: - self._http = http - - def list( - self, - *, - project: str | UUID | None = None, - member_type: MemberType | str | None = None, - connector_id: UUID | None = None, - application_idp_id: UUID | None = None, - tag: str | None = None, - metadata: dict[str, str] | None = None, - limit: int | None = None, - next: str | None = None, - ) -> AsyncPager[Member, Paginated[Member]]: - """List members. ``await`` the returned :class:`AsyncPager` for the - first page, or ``async for`` it to stream every member across - pages. - - Filters narrow and AND together. ``tag`` matches one access-bearing - tag exactly. ``metadata`` matches members whose metadata holds every - pair, each compared exactly against the string value; it is sent as - one repeated ``metadata=key:value`` param per entry (distinct keys, - at most 16). ``project`` is only needed by a credential that is not - already org-scoped (a deployment token).""" - - async def fetch(cursor: str | None) -> Paginated[Member]: - params = _list_params( - project=project, - member_type=member_type, - connector_id=connector_id, - application_idp_id=application_idp_id, - tag=tag, - metadata=metadata, - limit=limit, - cursor=cursor, - ) - payload = await self._http.request( - "GET", "/v1/members", params=params - ) - return Paginated[Member].model_validate(payload) - - return async_cursor_paginate(fetch, start=next) - - async def get( - self, member_id: UUID, *, project: str | UUID | None = None - ) -> Member: - payload = await self._http.request( - "GET", - f"/v1/members/{member_id}", - params={"project": str(project) if project else None}, - ) - return Member.model_validate(payload) - - async def create( - self, - member: BusinessMemberCreate | CustomerMemberCreate | dict[str, Any], - ) -> Member: - """Async twin of :meth:`Members.create`.""" - payload = await self._http.request( - "POST", "/v1/members", json=_create_body(member) - ) - return Member.model_validate(payload) - - async def update( - self, - member_id: UUID, - *, - name: str | None = None, - image_url: str | None = None, - role: str | None = None, - tags: builtins.list[str] | None = None, - metadata: dict[str, str] | None = None, - policy_attributes: dict[str, Any] | None = None, - ) -> Member: - """Async twin of :meth:`Members.update`.""" payload = await self._http.request( "PATCH", f"/v1/members/{member_id}", diff --git a/introspection_sdk/_async/resources/recipes.py b/introspection_sdk/_async/resources/recipes.py index 8a69574..ef347e8 100644 --- a/introspection_sdk/_async/resources/recipes.py +++ b/introspection_sdk/_async/resources/recipes.py @@ -1,69 +1,21 @@ -"""``client.recipes`` — CP read namespace for ``/v1/recipes``. - -Read-only, no handle subtype. A runner resolves the recipe it is running -under; authoring one is a project-authoring act and lives in the CLI. -Recipes are immutable snapshots of a git repository at a specific commit; -runtimes / experiment arms refer to recipes by id. -""" +"""Re-exported by :mod:`introspection_sdk.resources.recipes`.""" from __future__ import annotations from typing import Any from uuid import UUID -from introspection_sdk._http import _AsyncHttpClient, _HttpClient +from introspection_sdk._http import _AsyncHttpClient from introspection_sdk.pagination import ( AsyncPager, - Pager, async_cursor_paginate, - cursor_paginate, ) from introspection_sdk.schemas.pagination import Paginated from introspection_sdk.schemas.recipes import Recipe -class Recipes: - """CP ``/v1/recipes`` namespace.""" - - def __init__(self, http: _HttpClient) -> None: - self._http = http - - # --- reads -------------------------------------------------------- - - def list( - self, - *, - project: str | UUID | None = None, - repository_id: UUID | None = None, - name: str | None = None, - limit: int | None = None, - next: str | None = None, - ) -> Pager[Recipe, Paginated[Recipe]]: - """List recipes. Iterate the returned :class:`Pager` to stream every - recipe across pages, or call ``.page()`` for the first page only.""" - - def fetch(cursor: str | None) -> Paginated[Recipe]: - params: dict[str, Any] = { - "project": str(project) if project else None, - "repository_id": ( - str(repository_id) if repository_id is not None else None - ), - "name": name, - "limit": limit, - "next": cursor, - } - payload = self._http.request("GET", "/v1/recipes", params=params) - return Paginated[Recipe].model_validate(payload) - - return cursor_paginate(fetch, start=next) - - def get(self, recipe_id: UUID) -> Recipe: - payload = self._http.request("GET", f"/v1/recipes/{recipe_id}") - return Recipe.model_validate(payload) - - class AsyncRecipes: - """Async twin of :class:`Recipes` (CP ``/v1/recipes``).""" + """CP ``/v1/recipes`` namespace.""" def __init__(self, http: _AsyncHttpClient) -> None: self._http = http @@ -79,9 +31,13 @@ def list( limit: int | None = None, next: str | None = None, ) -> AsyncPager[Recipe, Paginated[Recipe]]: + # unasync: async-only (the sync twin's docstring follows) """List recipes. ``await`` the returned :class:`AsyncPager` for the first page, or ``async for`` it to stream every recipe across pages.""" + # unasync: end + """List recipes. Iterate the returned :class:`Pager` to stream every + recipe across pages, or call ``.page()`` for the first page only.""" async def fetch(cursor: str | None) -> Paginated[Recipe]: params: dict[str, Any] = { diff --git a/introspection_sdk/_async/resources/repositories.py b/introspection_sdk/_async/resources/repositories.py index 41656db..4a2c62d 100644 --- a/introspection_sdk/_async/resources/repositories.py +++ b/introspection_sdk/_async/resources/repositories.py @@ -1,17 +1,4 @@ -"""``client.repositories`` — repository lookup (CP), contents, commits and -merges (DP). - -A runner resolves the repository behind the recipe it runs, it does not -register one. Linking a repository to a project is a project-authoring act -and lives in the CLI. - -Unlike the other CP lists, ``GET /v1/repositories`` answers a bare JSON -array rather than the cursor envelope, so :meth:`Repositories.list` returns -a ``list``. Contents are read on the data plane, which resolves ``ref`` to -one commit and reads every page of a listing at it. Commits page the -history from ``sha`` (the default branch when omitted). Merges mirror -GitHub's ``POST /repos/{owner}/{repo}/merges``. -""" +"""Re-exported by :mod:`introspection_sdk.resources.repositories`.""" from __future__ import annotations @@ -22,12 +9,10 @@ from pydantic import TypeAdapter -from introspection_sdk._http import RawResponse, _AsyncHttpClient, _HttpClient +from introspection_sdk._http import RawResponse, _AsyncHttpClient from introspection_sdk.pagination import ( AsyncPager, - Pager, async_cursor_paginate, - cursor_paginate, ) from introspection_sdk.schemas.pagination import Paginated from introspection_sdk.schemas.repositories import ( @@ -98,168 +83,13 @@ def _pager_next(page: RepositoryDirectory) -> str | None: return page.next -class RepositoryContents: +class AsyncRepositoryContents: """DP ``/v1/repositories/{id}/contents`` namespace. Call it to enumerate a directory; use :meth:`get` for one page of a directory or a file's content. """ - def __init__(self, http: _HttpClient) -> None: - self._http = http - - def __call__( - self, - repository_id: UUID | str, - path: str = "", - ref: str | None = None, - limit: int | None = None, - ) -> Pager[RepositoryEntry, RepositoryDirectory]: - """List a directory's entries across pages. - - Iterating raises :class:`ValueError` when ``path`` is a file. - """ - - def fetch(cursor: str | None) -> RepositoryDirectory: - payload = self._http.request( - "GET", - _contents_path(repository_id, path), - params={"ref": ref, "cursor": cursor, "limit": limit}, - ) - return _directory(payload, path) - - return Pager(fetch, items=_pager_items, next_cursor=_pager_next) - - def get( - self, - repository_id: UUID | str, - path: str = "", - ref: str | None = None, - ) -> RepositoryDirectory | RepositoryFile: - """A directory's first page or a file's content, discriminated on - ``type``.""" - payload = self._http.request( - "GET", - _contents_path(repository_id, path), - params={"ref": ref}, - ) - return _CONTENT.validate_python(payload) - - -class RepositoryMerges: - """DP ``/v1/repositories/{id}/merges`` namespace.""" - - def __init__(self, http: _HttpClient) -> None: - self._http = http - - def create( - self, - repository_id: UUID | str, - base: str, - head: str, - commit_message: str | None = None, - ) -> RepositoryMerge | None: - """Merge ``head`` (a branch or a full commit sha) into ``base``. - - Returns the merge commit, or ``None`` when ``base`` already contains - ``head``. A conflict raises :class:`ConflictError` with nothing - changed. A merge still running after 60s raises - :class:`SandboxUnavailableError`; calling again with the same - ``base`` and ``head`` attaches to it. ``commit_message`` defaults - server-side to ``Merge {head} into {base}``. - """ - response = self._http.request( - "POST", - _merges_path(repository_id), - json=_merge_body(base, head, commit_message), - expect="raw", - ) - return _merge(response) - - -class Repositories: - """CP ``/v1/repositories`` namespace, plus DP :attr:`contents`, - commits and :attr:`merges`.""" - - contents: RepositoryContents - merges: RepositoryMerges - - def __init__(self, http: _HttpClient, dp_http: _HttpClient) -> None: - self._http = http - self._dp_http = dp_http - self.contents = RepositoryContents(dp_http) - self.merges = RepositoryMerges(dp_http) - - def list( - self, - *, - project: str | UUID | None = None, - slug: str | None = None, - ) -> builtins.list[Repository]: - """The repositories linked to the project. - - ``slug`` narrows to one: ``owner/repo`` for GitHub, the Runtime slug - for a hosted repository. - """ - payload = self._http.request( - "GET", - "/v1/repositories", - params={"project": project, "slug": slug}, - ) - return _REPOSITORY_LIST.validate_python(payload) - - def get( - self, - repository_id: UUID | str, - *, - project: str | UUID | None = None, - ) -> Repository: - payload = self._http.request( - "GET", - f"/v1/repositories/{repository_id}", - params={"project": project}, - ) - return Repository.model_validate(payload) - - def commits( - self, - repository_id: UUID | str, - sha: str | None = None, - path: str | None = None, - limit: int | None = None, - ) -> Pager[RepositoryCommit, Paginated[RepositoryCommit]]: - """The history from ``sha`` (a branch, tag or commit; the default - branch when omitted) across pages, narrowed to commits touching - ``path`` when set.""" - - def fetch(cursor: str | None) -> Paginated[RepositoryCommit]: - payload = self._dp_http.request( - "GET", - _commits_path(repository_id), - params={ - "sha": sha, - "path": path, - "cursor": cursor, - "limit": limit, - }, - ) - return Paginated[RepositoryCommit].model_validate(payload) - - return cursor_paginate(fetch) - - def commit( - self, repository_id: UUID | str, sha: str - ) -> RepositoryCommitDetail: - """One commit with its changed files and unified diff.""" - payload = self._dp_http.request( - "GET", _commit_path(repository_id, sha) - ) - return RepositoryCommitDetail.model_validate(payload) - - -class AsyncRepositoryContents: - """Async twin of :class:`RepositoryContents`.""" - def __init__(self, http: _AsyncHttpClient) -> None: self._http = http @@ -302,7 +132,7 @@ async def get( class AsyncRepositoryMerges: - """Async twin of :class:`RepositoryMerges`.""" + """DP ``/v1/repositories/{id}/merges`` namespace.""" def __init__(self, http: _AsyncHttpClient) -> None: self._http = http @@ -314,7 +144,15 @@ async def create( head: str, commit_message: str | None = None, ) -> RepositoryMerge | None: - """Async twin of :meth:`RepositoryMerges.create`.""" + """Merge ``head`` (a branch or a full commit sha) into ``base``. + + Returns the merge commit, or ``None`` when ``base`` already contains + ``head``. A conflict raises :class:`ConflictError` with nothing + changed. A merge still running after 60s raises + :class:`SandboxUnavailableError`; calling again with the same + ``base`` and ``head`` attaches to it. ``commit_message`` defaults + server-side to ``Merge {head} into {base}``. + """ response = await self._http.request( "POST", _merges_path(repository_id), @@ -325,7 +163,8 @@ async def create( class AsyncRepositories: - """Async twin of :class:`Repositories`.""" + """CP ``/v1/repositories`` namespace, plus DP :attr:`contents`, + commits and :attr:`merges`.""" contents: AsyncRepositoryContents merges: AsyncRepositoryMerges @@ -344,7 +183,11 @@ async def list( project: str | UUID | None = None, slug: str | None = None, ) -> builtins.list[Repository]: - """Async twin of :meth:`Repositories.list`.""" + """The repositories linked to the project. + + ``slug`` narrows to one: ``owner/repo`` for GitHub, the Runtime slug + for a hosted repository. + """ payload = await self._http.request( "GET", "/v1/repositories", @@ -372,7 +215,9 @@ def commits( path: str | None = None, limit: int | None = None, ) -> AsyncPager[RepositoryCommit, Paginated[RepositoryCommit]]: - """Async twin of :meth:`Repositories.commits`.""" + """The history from ``sha`` (a branch, tag or commit; the default + branch when omitted) across pages, narrowed to commits touching + ``path`` when set.""" async def fetch(cursor: str | None) -> Paginated[RepositoryCommit]: payload = await self._dp_http.request( @@ -392,18 +237,8 @@ async def fetch(cursor: str | None) -> Paginated[RepositoryCommit]: async def commit( self, repository_id: UUID | str, sha: str ) -> RepositoryCommitDetail: - """Async twin of :meth:`Repositories.commit`.""" + """One commit with its changed files and unified diff.""" payload = await self._dp_http.request( "GET", _commit_path(repository_id, sha) ) return RepositoryCommitDetail.model_validate(payload) - - -__all__ = [ - "AsyncRepositories", - "AsyncRepositoryContents", - "AsyncRepositoryMerges", - "Repositories", - "RepositoryContents", - "RepositoryMerges", -] diff --git a/introspection_sdk/_async/resources/runtimes.py b/introspection_sdk/_async/resources/runtimes.py index fee6415..13415c7 100644 --- a/introspection_sdk/_async/resources/runtimes.py +++ b/introspection_sdk/_async/resources/runtimes.py @@ -1,14 +1,4 @@ -"""``client.runtimes`` — read, resolve, and run runtimes. - -``client.runtimes`` is the :class:`Runtimes` instance; calling -``client.runtimes(runtime)`` returns a :class:`RuntimeHandle` -which exposes ``.run()``. A slug goes straight to -``POST /v1/runtimes/{slug}/run``, which resolves it in the caller's project, -so opening a runner needs no list call. A UUID selector is a runtime group -id and is resolved on the caller's project on every use. Neither is cached, -so a version withdrawn after the handle was built cannot strand it. -Concrete runtime row IDs are used only by explicit ``*_id`` methods. -""" +"""Re-exported by :mod:`introspection_sdk.resources.runtimes`.""" from __future__ import annotations @@ -18,259 +8,37 @@ from uuid import UUID from introspection_sdk._errors import NotFoundError -from introspection_sdk._http import _AsyncHttpClient, _HttpClient +from introspection_sdk._http import _AsyncHttpClient from introspection_sdk.pagination import ( AsyncPager, - Pager, async_cursor_paginate, - cursor_paginate, ) -from introspection_sdk.runner import AsyncRunner, Runner +from introspection_sdk.resources._runtimes_common import ( + AsyncRuntimeHandle, +) from introspection_sdk.schemas.pagination import Paginated from introspection_sdk.schemas.runner import ( - RunCaller, - RunnerIdentity, RunnerSpec, RunRequest, ) from introspection_sdk.schemas.runtimes import Runtime -class Runtimes: - """CP ``/v1/runtimes`` namespace. - - Also callable: ``client.runtimes("runtime")`` returns a - :class:`RuntimeHandle` for that runtime. - """ - - def __init__( - self, - http: _HttpClient, - *, - additional_headers: Mapping[str, str] | None = None, - ) -> None: - self._http = http - self._additional_headers = additional_headers - - def __call__( - self, runtime: str | UUID, *, project: str | None = None - ) -> RuntimeHandle: - # The project is scoped by the API key server-side; `project` is an - # explicit per-call override only — there is no client-level default. - return RuntimeHandle( - self, - runtime=runtime, - project=project, - ) - - def handle(self, runtime_id: UUID) -> RuntimeHandle: - """Bind a handle to a concrete ``runtime_id``, skipping resolution. - - ``client.runtimes(selector)`` holds a slug or runtime group id and - resolves it on every use to find what the group currently serves. Pass a - runtime id there and the lookup answers nothing: the ``runtime`` - filter matches slugs and group ids, not the row's own id. Use this - when the id is already known — from ``resolve()``, ``get()``, or a - broker that handed it over. - """ - return RuntimeHandle( - self, - runtime=runtime_id, - project=None, - resolved=True, - ) - - # Runtime lifecycle and version selection are managed by the CLI and - # platform. The SDK intentionally exposes only read, resolve, and run. - - def list( - self, - *, - project: str | None = None, - runtime: str | None = None, - recipe_id: UUID | None = None, - environment: str | None = None, - limit: int = 100, - next: str | None = None, - ) -> Pager[Runtime, Paginated[Runtime]]: - """List runtimes. Iterate the returned :class:`Pager` to stream - every runtime across pages, or call ``.page()`` for the first page - only. - - Pass ``environment`` to restrict to runtimes serving that lane. An - API key already selects its environment, so passing both is a 400.""" - - def fetch(cursor: str | None) -> Paginated[Runtime]: - params: dict[str, Any] = { - "project": project, - "runtime": runtime, - "recipe_id": recipe_id, - "environment": environment, - "limit": limit, - "next": cursor, - } - payload = self._http.request("GET", "/v1/runtimes", params=params) - return Paginated[Runtime].model_validate(payload) - - return cursor_paginate(fetch, start=next) - - def get(self, runtime_id: UUID, *, project: str | None = None) -> Runtime: - payload = self._http.request( - "GET", - f"/v1/runtimes/{runtime_id}", - params={"project": project}, - ) - return Runtime.model_validate(payload) - - def resolve( - self, runtime: str | UUID, *, project: str | None = None - ) -> Runtime: - """Resolve a runtime by slug or runtime group id. - - The standalone form of ``client.runtimes(runtime)`` resolution — - handy for a server broker that resolves a concrete ``runtime_id`` to hand - to a browser client (which talks only to the Data Plane and never - resolves runtimes itself). The project is scoped by the token - server-side; pass ``project`` only to override it. - - The route answers newest-first and has no notion of a single - "active" row for a development credential, so a slug that has - been published more than once resolves to its newest runtime - rather than failing as ambiguous. - - Raises :class:`~introspection_sdk.NotFoundError` if nothing matches - the slug or runtime group id — the same error - :meth:`get` raises for a runtime that is not there, so one - ``except`` covers both ways of asking for one. - """ - page = self.list( - runtime=str(runtime), - limit=1, - project=project, - ).page() - if not page.records: - raise _no_runtime(runtime) - return page.records[0] - - # --- /run -------------------------------------------------------- - - def _post_run( - self, - runtime: UUID | str, - options: RunRequest, - *, - project: str | None = None, - ) -> RunnerSpec: - body: dict[str, Any] = options.model_dump( - exclude_none=True, mode="json" - ) - payload = self._http.request( - "POST", - _run_path(runtime), - params={"project": project}, - json=body, - ) - return RunnerSpec.model_validate(payload) - - -class RuntimeHandle: - """Handle for a specific runtime slug or runtime group id. - - Holds the stable selector and resolves it on each use. ``run()`` posts a - slug straight to ``/v1/runtimes/{slug}/run``, which resolves it in the - caller's project, so a credential refused the list (a ``customer`` - signed in by email code) can still open a runner. A runtime group id has - no such route and is resolved by listing on the caller's project. Built - by ``client.runtimes(runtime)``. - - The resolved row id is deliberately not cached. A runtime group's versions - change underneath a long-lived handle: deploys add them, and yanking or - deleting retires them. A handle that remembered its first answer went on - using a version that could since have been withdrawn, so every later - ``run()`` failed with a 404 no caller could clear short of rebuilding the - handle. Holding the selector instead costs one lookup per use and always - reaches whatever the group currently serves. - """ - - def __init__( - self, - runtimes: Runtimes, - *, - runtime: str | UUID, - project: str | None, - resolved: bool = False, - ) -> None: - self._runtimes = runtimes - self._project = project - self._raw = runtime - self._resolved = resolved - - @property - def runtime_id(self) -> UUID: - return self._resolve() - - def _resolve(self) -> UUID: - if self._resolved: - return UUID(str(self._raw)) - return self._runtimes.resolve(str(self._raw), project=self._project).id - - def _run_target(self) -> UUID | str: - if self._resolved or _is_uuid(self._raw): - return self._resolve() - return str(self._raw) - - def run( - self, - *, - identity: RunnerIdentity | dict[str, Any] | None = None, - caller: RunCaller | dict[str, Any] | None = None, - agent_name: str | None = None, - ttl_seconds: int | None = 3600, - scope: str | None = None, - ) -> Runner: - ident: RunnerIdentity | None - if identity is None: - ident = None - elif isinstance(identity, RunnerIdentity): - ident = identity - else: - ident = RunnerIdentity.model_validate(identity) - call: RunCaller | None - if caller is None: - call = None - elif isinstance(caller, RunCaller): - call = caller - else: - call = RunCaller.model_validate(caller) - options = RunRequest( - identity=ident, - caller=call, - agent_name=agent_name, - ttl_seconds=ttl_seconds, - scope=scope, - ) - target = self._run_target() - - def refresher() -> RunnerSpec: - return self._runtimes._post_run( - target, options, project=self._project - ) - - spec = refresher() - return Runner( - spec, - refresher=refresher, - additional_headers=self._runtimes._additional_headers, - ) - - class AsyncRuntimes: + # unasync: async-only (the sync twin's docstring follows) """Async twin of :class:`Runtimes` (CP ``/v1/runtimes``). Also callable: ``client.runtimes("runtime")`` returns an :class:`AsyncRuntimeHandle` for that runtime. """ + # unasync: end + """CP ``/v1/runtimes`` namespace. + + Also callable: ``client.runtimes("runtime")`` returns a + :class:`RuntimeHandle` for that runtime. + """ + def __init__( self, http: _AsyncHttpClient, @@ -321,10 +89,18 @@ def list( limit: int = 100, next: str | None = None, ) -> AsyncPager[Runtime, Paginated[Runtime]]: + # unasync: async-only (the sync twin's docstring follows) """List runtimes. ``await`` the returned :class:`AsyncPager` for the first page, or ``async for`` it to stream every runtime across pages. + Pass ``environment`` to restrict to runtimes serving that lane. An + API key already selects its environment, so passing both is a 400.""" + # unasync: end + """List runtimes. Iterate the returned :class:`Pager` to stream + every runtime across pages, or call ``.page()`` for the first page + only. + Pass ``environment`` to restrict to runtimes serving that lane. An API key already selects its environment, so passing both is a 400.""" @@ -357,6 +133,7 @@ async def get( async def resolve( self, runtime: str | UUID, *, project: str | None = None ) -> Runtime: + # unasync: async-only (the sync twin's docstring follows) """Async twin of :meth:`Runtimes.resolve`. Resolve a runtime by slug or runtime group id on the caller's project — the @@ -365,6 +142,25 @@ async def resolve( browser client. Resolves to the newest runtime for the selector; raises :class:`~introspection_sdk.NotFoundError` if nothing matches. """ + # unasync: end + """Resolve a runtime by slug or runtime group id. + + The standalone form of ``client.runtimes(runtime)`` resolution — + handy for a server broker that resolves a concrete ``runtime_id`` to hand + to a browser client (which talks only to the Data Plane and never + resolves runtimes itself). The project is scoped by the token + server-side; pass ``project`` only to override it. + + The route answers newest-first and has no notion of a single + "active" row for a development credential, so a slug that has + been published more than once resolves to its newest runtime + rather than failing as ambiguous. + + Raises :class:`~introspection_sdk.NotFoundError` if nothing matches + the slug or runtime group id — the same error + :meth:`get` raises for a runtime that is not there, so one + ``except`` covers both ways of asking for one. + """ page = await self.list( runtime=str(runtime), limit=1, @@ -395,105 +191,6 @@ async def _post_run( return RunnerSpec.model_validate(payload) -class AsyncRuntimeHandle: - """Async twin of :class:`RuntimeHandle`. - - Holds the stable selector and resolves it on each use: ``run()`` posts a - slug straight to ``/v1/runtimes/{slug}/run`` and lists only to resolve a - runtime group id. Built by ``client.runtimes(runtime)``. See - :class:`RuntimeHandle` for why the resolved id is not cached. - """ - - def __init__( - self, - runtimes: AsyncRuntimes, - *, - runtime: str | UUID, - project: str | None, - resolved: bool = False, - ) -> None: - self._runtimes = runtimes - self._project = project - self._raw = runtime - self._resolved = resolved - - async def runtime_id(self) -> UUID: - """Resolve the concrete runtime id this handle currently serves. - - The async twin of :attr:`RuntimeHandle.runtime_id`. A coroutine - rather than a property because resolving costs a request, and a - property cannot be awaited. Without it the async client had no - public way to read the id a handle resolves to — the broker case - the sync property exists for. - """ - return await self._resolve() - - async def _resolve(self) -> UUID: - if self._resolved: - return UUID(str(self._raw)) - resolved = await self._runtimes.resolve( - str(self._raw), project=self._project - ) - return resolved.id - - async def _run_target(self) -> UUID | str: - if self._resolved or _is_uuid(self._raw): - return await self._resolve() - return str(self._raw) - - async def run( - self, - *, - identity: RunnerIdentity | dict[str, Any] | None = None, - caller: RunCaller | dict[str, Any] | None = None, - agent_name: str | None = None, - ttl_seconds: int | None = 3600, - scope: str | None = None, - ) -> AsyncRunner: - ident: RunnerIdentity | None - if identity is None: - ident = None - elif isinstance(identity, RunnerIdentity): - ident = identity - else: - ident = RunnerIdentity.model_validate(identity) - call: RunCaller | None - if caller is None: - call = None - elif isinstance(caller, RunCaller): - call = caller - else: - call = RunCaller.model_validate(caller) - options = RunRequest( - identity=ident, - caller=call, - agent_name=agent_name, - ttl_seconds=ttl_seconds, - scope=scope, - ) - target = await self._run_target() - - async def refresher() -> RunnerSpec: - return await self._runtimes._post_run( - target, options, project=self._project - ) - - spec = await refresher() - return AsyncRunner( - spec, - refresher=refresher, - additional_headers=self._runtimes._additional_headers, - ) - - -__all__ = [ - "AsyncRuntimeHandle", - "AsyncRuntimes", - "RuntimeHandle", - "Runtimes", -] - - def _no_runtime(runtime: str | UUID) -> NotFoundError: """The error a resolve-by-selector miss raises. @@ -511,15 +208,5 @@ def _no_runtime(runtime: str | UUID) -> NotFoundError: ) -def _is_uuid(selector: str | UUID) -> bool: - if isinstance(selector, UUID): - return True - try: - UUID(selector) - except ValueError: - return False - return True - - def _run_path(runtime: UUID | str) -> str: return f"/v1/runtimes/{quote(str(runtime), safe='')}/run" diff --git a/introspection_sdk/_async/resources/tags.py b/introspection_sdk/_async/resources/tags.py index 64dc47e..b9c25ea 100644 --- a/introspection_sdk/_async/resources/tags.py +++ b/introspection_sdk/_async/resources/tags.py @@ -1,4 +1,4 @@ -"""The project tag catalogue: the values a share, a member tag, or an annotation carries.""" +"""Re-exported by :mod:`introspection_sdk.resources.tags`.""" from __future__ import annotations @@ -7,12 +7,10 @@ from pydantic import TypeAdapter from introspection_sdk._errors import ValidationError -from introspection_sdk._http import _AsyncHttpClient, _HttpClient +from introspection_sdk._http import _AsyncHttpClient from introspection_sdk.pagination import ( AsyncPager, - Pager, async_cursor_paginate, - cursor_paginate, ) from introspection_sdk.schemas.pagination import Paginated from introspection_sdk.schemas.tags import ( @@ -55,56 +53,6 @@ def _update_body(kind: TagKind, patch: TagUpdate) -> dict[str, object]: return patch.model_dump(mode="json", exclude_unset=True) -class Tags: - def __init__(self, http: _HttpClient) -> None: - self._http = http - - def list( - self, - kind: TagKind, - *, - search: str | None = None, - limit: int | None = None, - next: str | None = None, - ) -> Pager[Tag, Paginated[Tag]]: - """List one catalogue. `kind` is required: no listing spans both.""" - - def fetch(cursor: str | None) -> Paginated[Tag]: - payload = self._http.request( - "GET", - "/v1/tags", - params={ - "kind": kind.value, - "search": search, - "limit": limit, - "next": cursor, - }, - ) - return Paginated[Tag].model_validate(payload) - - return cursor_paginate(fetch, start=next) - - def create(self, tag: TagCreate) -> Tag: - """Add a tag. An existing one comes back as it is, a deleted one revives.""" - return Tag.model_validate( - self._http.request("POST", "/v1/tags", json=_create_body(tag)) - ) - - def get(self, kind: TagKind, name: str) -> Tag: - return Tag.model_validate(self._http.request("GET", _path(kind, name))) - - def update(self, kind: TagKind, name: str, patch: TagUpdate) -> Tag: - return Tag.model_validate( - self._http.request( - "PATCH", _path(kind, name), json=_update_body(kind, patch) - ) - ) - - def delete(self, kind: TagKind, name: str) -> None: - """Remove a tag from the catalogue. Shares, member tags and annotations keep theirs.""" - self._http.request("DELETE", _path(kind, name), expect="empty") - - class AsyncTags: def __init__(self, http: _AsyncHttpClient) -> None: self._http = http @@ -157,6 +105,3 @@ async def update(self, kind: TagKind, name: str, patch: TagUpdate) -> Tag: async def delete(self, kind: TagKind, name: str) -> None: """Remove a tag from the catalogue. Shares, member tags and annotations keep theirs.""" await self._http.request("DELETE", _path(kind, name), expect="empty") - - -__all__ = ["AsyncTags", "Tags"] diff --git a/introspection_sdk/_async/runner.py b/introspection_sdk/_async/runner.py index d2cee65..4aae855 100644 --- a/introspection_sdk/_async/runner.py +++ b/introspection_sdk/_async/runner.py @@ -1,20 +1,4 @@ -"""DP-facing :class:`Runner` returned by ``runtimes(...).run()`` and -``experiments(...).run()``. - -The Runner is an agent-session with a runtime context attached. It -owns the DP endpoint + session-locator JWT minted by the CP -``/run`` call and exposes ``runner.tasks``, ``runner.files``, -``runner.automations``, ``runner.connections`` and -the read-only ``runner.conversations`` namespaces that target that -endpoint. The -DP-side agent-session machinery materializes the real access token -from the session lookup on each request, so the SDK does not need to -drive refresh itself — -``runner.refresh()`` stays as a manual escape hatch that re-calls -CP ``/run``. ``runner.close()`` flips a local ``_closed`` flag -and tears down the underlying HTTP client; server-side revoke is -a follow-up. -""" +"""Re-exported by :mod:`introspection_sdk.runner`.""" from __future__ import annotations @@ -22,7 +6,7 @@ from datetime import datetime from introspection_sdk._errors import RunnerExpiredError -from introspection_sdk._http import _AsyncHttpClient, _HttpClient +from introspection_sdk._http import _AsyncHttpClient from introspection_sdk.runner_resources import ( AsyncAutomations, AsyncConversations, @@ -32,14 +16,6 @@ AsyncRunnerAppConnections, AsyncShares, AsyncTasks, - Automations, - Conversations, - Events, - Files, - Metrics, - RunnerAppConnections, - Shares, - Tasks, ) from introspection_sdk.schemas.runner import ( RunnerContext, @@ -48,185 +24,8 @@ ) -class Runner: - """Handle for talking to a running runtime/experiment on the DP. - - Constructed indirectly by ``client.runtimes(...).run()`` / - ``client.experiments(...).run()``. Holds a private HTTP client - pointed at ``spec.deployment.endpoint`` with the bearer JWT - picked from ``spec.session_token``. - """ - - def __init__( - self, - spec: RunnerSpec, - *, - refresher: Callable[[], RunnerSpec], - additional_headers: Mapping[str, str] | None = None, - timeout: float = 30.0, - ) -> None: - self._spec = spec - self._refresher = refresher - self._additional_headers = ( - dict(additional_headers) if additional_headers else None - ) - self._timeout = timeout - self._closed = False - self._http = self._build_http(spec) - self._tasks = Tasks(self._http) - self._files = Files(self._http) - self._conversations = Conversations(self._http) - self._events = Events(self._http) - self._metrics = Metrics(self._http) - self._shares = Shares(self._http) - self._automations = Automations(self._http) - self._connections = RunnerAppConnections( - self._http, spec.runtime_context.runtime_group_id - ) - - def _build_http(self, spec: RunnerSpec) -> _HttpClient: - return _HttpClient( - api_url=spec.deployment.endpoint, - token=spec.session_token, - additional_headers=self._additional_headers, - timeout=self._timeout, - ) - - def _check_open(self) -> None: - if self._closed: - raise RunnerExpiredError( - "Runner has been closed; create a new one via " - "client.runtimes(...).run() or client.experiments(...).run().", - status_code=0, - ) - - @property - def tasks(self) -> Tasks: - """DP ``/v1/tasks`` namespace bound to this Runner.""" - self._check_open() - return self._tasks - - @property - def files(self) -> Files: - """DP ``/v1/files`` namespace bound to this Runner.""" - self._check_open() - return self._files - - @property - def conversations(self) -> Conversations: - """Read-only DP ``/v1/conversations`` namespace bound to this Runner.""" - self._check_open() - return self._conversations - - @property - def events(self) -> Events: - """Read-only DP ``/v1/events`` namespace bound to this Runner.""" - self._check_open() - return self._events - - @property - def metrics(self) -> Metrics: - """Read-only DP ``/v1/metrics`` namespace bound to this Runner.""" - self._check_open() - return self._metrics - - @property - def shares(self) -> Shares: - """DP ``/v1/shares`` read-sharing namespace bound to this Runner.""" - self._check_open() - return self._shares - - @property - def automations(self) -> Automations: - """DP ``/v1/automations`` namespace bound to this Runner.""" - self._check_open() - return self._automations - - @property - def connections(self) -> RunnerAppConnections: - """DP ``/v1/connections`` namespace bound to this Runner. ``create`` - defaults ``runtime`` to the runner's runtime group.""" - self._check_open() - return self._connections - - @property - def context(self) -> RunnerContext: - """Resolved runtime/arm/recipe/identity/caller context.""" - return self._spec.runtime_context - - @property - def deployment(self) -> RunnerDeployment: - """DP deployment descriptor (endpoint, slug, region).""" - return self._spec.deployment - - @property - def dp_endpoint(self) -> str: - """Convenience accessor for ``spec.deployment.endpoint``.""" - return self._spec.deployment.endpoint - - @property - def expires_at(self) -> datetime: - return self._spec.expires_at - - @property - def session_id(self) -> str: - return self._spec.session_id - - @property - def spec(self) -> RunnerSpec: - return self._spec - - def refresh(self) -> None: - """Manually re-mint the RunnerSpec via CP ``/run``. - - The DP-side agent-session materializer rotates the access - token transparently, so callers typically don't need this. - It stays as an escape hatch when the caller wants to force - a fresh spec (new identity, new TTL, etc.). Swaps in the - new spec and rebuilds the underlying HTTP client; the - previously rented client is closed best-effort. - """ - self._check_open() - new_spec = self._refresher() - old_http = self._http - self._spec = new_spec - self._http = self._build_http(new_spec) - self._tasks = Tasks(self._http) - self._files = Files(self._http) - self._conversations = Conversations(self._http) - self._events = Events(self._http) - self._metrics = Metrics(self._http) - self._shares = Shares(self._http) - self._automations = Automations(self._http) - self._connections = RunnerAppConnections( - self._http, new_spec.runtime_context.runtime_group_id - ) - try: - old_http.close() - except Exception: # noqa: BLE001 — best-effort cleanup - pass - - def close(self) -> None: - """Mark the Runner closed and tear down its HTTP client. - - Server-side revoke is a follow-up; for now ``close()`` just - flips a local flag so further ``runner.tasks`` / - ``runner.files`` calls fail loudly. - """ - self._closed = True - try: - self._http.close() - except Exception: # noqa: BLE001 — best-effort cleanup - pass - - def __enter__(self) -> Runner: - return self - - def __exit__(self, *_exc_info: object) -> None: - self.close() - - class AsyncRunner: + # unasync: async-only (the sync twin's docstring follows) """Async twin of :class:`Runner`. Constructed indirectly by ``client.runtimes(...).run()`` / @@ -237,6 +36,15 @@ class AsyncRunner: ``await runner.refresh()``, and ``async with`` support. """ + # unasync: end + """Handle for talking to a running runtime/experiment on the DP. + + Constructed indirectly by ``client.runtimes(...).run()`` / + ``client.experiments(...).run()``. Holds a private HTTP client + pointed at ``spec.deployment.endpoint`` with the bearer JWT + picked from ``spec.session_token``. + """ + def __init__( self, spec: RunnerSpec, @@ -294,8 +102,11 @@ def files(self) -> AsyncFiles: @property def conversations(self) -> AsyncConversations: + # unasync: async-only (the sync twin's docstring follows) """Read-only DP ``/v1/conversations`` namespace bound to this Runner.""" + # unasync: end + """Read-only DP ``/v1/conversations`` namespace bound to this Runner.""" self._check_open() return self._conversations @@ -405,6 +216,3 @@ async def __aenter__(self) -> AsyncRunner: async def __aexit__(self, *_exc_info: object) -> None: await self.close() - - -__all__ = ["AsyncRunner", "Runner"] diff --git a/introspection_sdk/_async/runner_resources/__init__.py b/introspection_sdk/_async/runner_resources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/introspection_sdk/_async/runner_resources/_reads.py b/introspection_sdk/_async/runner_resources/_reads.py index eceb185..5aba01f 100644 --- a/introspection_sdk/_async/runner_resources/_reads.py +++ b/introspection_sdk/_async/runner_resources/_reads.py @@ -1,33 +1,9 @@ -"""Shared helpers for the runner-scoped DP telemetry list reads. - -Two concerns live here, common to ``runner.conversations`` and -``runner.events``: - -* **Ergonomic window params.** The DP list routes take ``start_date`` / - ``end_date`` (ISO datetimes) and ``direction`` (``asc``/``desc``). The - SDK also accepts the friendlier ``start`` / ``end`` / ``order`` aliases - plus a relative ``lookback`` (e.g. ``"24h"``) that resolves to - ``start_date = now - lookback``. ``lookback`` is mutually exclusive with - ``start`` / ``end`` — passing both raises ``ValueError`` locally, before - any request is sent. -* **Optional Arrow decode.** With ``format="arrow"`` the list read sends - ``Accept: application/vnd.apache.arrow.stream`` and the server returns an - Arrow IPC *stream* (envelope columns plus a typed ``payload`` struct - column) with the pagination metadata on response headers. - :func:`decode_arrow_page` rebuilds the identical - :class:`~introspection_sdk.schemas.pagination.Paginated` envelope the JSON - path produces, so the auto-paging ``Pager`` drives both formats - unchanged. -* **Columnar page iteration.** The ``.list_arrow()`` accessors return an - :class:`ArrowPageIterator` / :class:`AsyncArrowPageIterator` yielding one - ``pyarrow.Table`` per server page (constant memory), with a - ``read_all()`` convenience concatenating every page into one Table. -""" +"""Re-exported by :mod:`introspection_sdk.runner_resources._reads`.""" from __future__ import annotations import re -from collections.abc import AsyncIterator, Awaitable, Callable, Iterator +from collections.abc import AsyncIterator, Awaitable, Callable from datetime import UTC, datetime, timedelta from types import ModuleType from typing import TYPE_CHECKING, Any, Literal, TypeVar @@ -186,7 +162,7 @@ def decode_arrow_page( return page -class ArrowPageIterator: +class AsyncArrowPageIterator: """Lazily iterate a list read as one ``pyarrow.Table`` per page. Yields each server page as a Table (constant memory — one page held @@ -195,38 +171,6 @@ class ArrowPageIterator: concatenates every page into a single Table. """ - def __init__( - self, - fetch: Callable[[str | None], RawResponse], - *, - start: str | None = None, - ) -> None: - self._fetch = fetch - self._start = start - - def __iter__(self) -> Iterator[pa.Table]: - cursor = self._start - while True: - raw = self._fetch(cursor) - table = decode_arrow_table(raw) - if table is not None: - yield table - cursor = next_cursor_header(raw) - if cursor is None: - return - - def read_all(self) -> pa.Table: - """Fetch every page and concatenate into one ``pyarrow.Table``.""" - pa = import_pyarrow() - tables = list(self) - if not tables: - return pa.table({}) - return pa.concat_tables(tables) - - -class AsyncArrowPageIterator: - """Async twin of :class:`ArrowPageIterator`.""" - def __init__( self, fetch: Callable[[str | None], Awaitable[RawResponse]], diff --git a/introspection_sdk/_async/runner_resources/automations.py b/introspection_sdk/_async/runner_resources/automations.py index 797a34d..0ce8b69 100644 --- a/introspection_sdk/_async/runner_resources/automations.py +++ b/introspection_sdk/_async/runner_resources/automations.py @@ -1,23 +1,4 @@ -"""``client.automations`` / ``runner.automations`` — DP namespace for -``/v1/automations``. - -An automation is scheduled work on a project: a prompt a person set up -(``kind=None``), which creates a task per firing or posts into an -existing one (``task_id``), or platform work (``observation_synthesis``, -``observation_clustering``, ``project_check_in``). Every firing is -recorded as an ``introspection.automation.triggered`` or -``introspection.automation.skipped`` event, readable through -``client.events`` with the ``automation_id`` / ``task_id`` filters. - -The server serves these routes to project administrators only today and -answers anyone else with a 403. introspection-cloud#3137 (not yet -shipped) opens them to members for their own automations that post into -one of their own tasks. - -``runner.automations`` sends the runner's token. The platform is being -changed so that a runner a member opens for themself carries -``automations:read`` and ``automations:write``. -""" +"""Re-exported by :mod:`introspection_sdk.runner_resources.automations`.""" from __future__ import annotations @@ -26,12 +7,10 @@ from urllib.parse import quote from uuid import UUID -from introspection_sdk._http import _AsyncHttpClient, _HttpClient +from introspection_sdk._http import _AsyncHttpClient from introspection_sdk.pagination import ( AsyncPager, - Pager, async_cursor_paginate, - cursor_paginate, ) from introspection_sdk.schemas.automations import ( Automation, @@ -136,10 +115,10 @@ def _update_body( ).model_dump(mode="json", exclude_none=True) -class Automations: +class AsyncAutomations: """DP ``/v1/automations`` namespace.""" - def __init__(self, http: _HttpClient) -> None: + def __init__(self, http: _AsyncHttpClient) -> None: self._http = http def list( @@ -151,7 +130,13 @@ def list( task_id: UUID | str | None = None, limit: int | None = None, next: str | None = None, - ) -> Pager[Automation, Paginated[Automation]]: + ) -> AsyncPager[Automation, Paginated[Automation]]: + # unasync: async-only (the sync twin's docstring follows) + """List the project's automations. ``await`` the returned + :class:`AsyncPager` for the first page, or ``async for`` it to + stream every automation across pages. See :meth:`Automations.list` + for the filters.""" + # unasync: end """List the project's automations. Iterate the returned :class:`Pager` to stream every automation across pages, or call ``.page()`` for the first page only. @@ -162,7 +147,7 @@ def list( introspection-cloud#3137 ships. ``limit`` is the page size (1-1000, server default 100).""" - def fetch(cursor: str | None) -> Paginated[Automation]: + async def fetch(cursor: str | None) -> Paginated[Automation]: params = _list_params( kind=kind, enabled=enabled, @@ -171,20 +156,20 @@ def fetch(cursor: str | None) -> Paginated[Automation]: limit=limit, cursor=cursor, ) - payload = self._http.request( + payload = await self._http.request( "GET", "/v1/automations", params=params ) return Paginated[Automation].model_validate(payload) - return cursor_paginate(fetch, start=next) + return async_cursor_paginate(fetch, start=next) - def get(self, automation_id: UUID | str) -> Automation: + async def get(self, automation_id: UUID | str) -> Automation: """Read one automation, soft-deleted ones included.""" return Automation.model_validate( - self._http.request("GET", _path(automation_id)) + await self._http.request("GET", _path(automation_id)) ) - def create( + async def create( self, *, name: str, @@ -209,7 +194,7 @@ def create( (or ``metadata.cron_schedules``) and must not send a slot. ``metadata`` takes an :class:`AutomationMetadata` or a plain dict. """ - payload = self._http.request( + payload = await self._http.request( "POST", "/v1/automations", json=_create_body( @@ -228,7 +213,7 @@ def create( ) return Automation.model_validate(payload) - def update( + async def update( self, automation_id: UUID | str, *, @@ -246,7 +231,7 @@ def update( leaves a field as it is, so nothing can be cleared. ``kind`` and ``trigger_type`` are immutable and ``metadata`` replaces wholesale. ``enabled=False`` pauses and keeps the slot.""" - payload = self._http.request( + payload = await self._http.request( "PATCH", _path(automation_id), json=_update_body( @@ -263,134 +248,10 @@ def update( ) return Automation.model_validate(payload) - def delete(self, automation_id: UUID | str) -> None: + async def delete(self, automation_id: UUID | str) -> None: """Soft-delete an automation. A project default (``observation_synthesis``, ``project_check_in``) cannot be deleted and answers 409; disable it instead.""" - self._http.request("DELETE", _path(automation_id), expect="empty") - - def trigger(self, automation_id: UUID | str) -> AutomationTriggerResponse: - """Run an automation now (``POST /v1/automations/{id}/trigger``). - - Returns the task it created or posted into. A ``skipped`` status - carries its ``reason``. Neither reads nor clears a scheduled slot. - """ - payload = self._http.request("POST", f"{_path(automation_id)}/trigger") - return AutomationTriggerResponse.model_validate(payload) - - -class AsyncAutomations: - """Async twin of :class:`Automations` (DP ``/v1/automations``).""" - - def __init__(self, http: _AsyncHttpClient) -> None: - self._http = http - - def list( - self, - *, - kind: AutomationKind | str | None = None, - enabled: bool | None = None, - scheduled: bool | None = None, - task_id: UUID | str | None = None, - limit: int | None = None, - next: str | None = None, - ) -> AsyncPager[Automation, Paginated[Automation]]: - """List the project's automations. ``await`` the returned - :class:`AsyncPager` for the first page, or ``async for`` it to - stream every automation across pages. See :meth:`Automations.list` - for the filters.""" - - async def fetch(cursor: str | None) -> Paginated[Automation]: - params = _list_params( - kind=kind, - enabled=enabled, - scheduled=scheduled, - task_id=task_id, - limit=limit, - cursor=cursor, - ) - payload = await self._http.request( - "GET", "/v1/automations", params=params - ) - return Paginated[Automation].model_validate(payload) - - return async_cursor_paginate(fetch, start=next) - - async def get(self, automation_id: UUID | str) -> Automation: - """Async twin of :meth:`Automations.get`.""" - return Automation.model_validate( - await self._http.request("GET", _path(automation_id)) - ) - - async def create( - self, - *, - name: str, - trigger_type: AutomationTriggerType | str, - description: str | None = None, - cron_schedule: str | None = None, - kind: AutomationKind | str | None = None, - prompt: str | None = None, - runtime_group_id: UUID | str | None = None, - task_id: UUID | str | None = None, - next_trigger_at: datetime | None = None, - metadata: MetadataInput | None = None, - enabled: bool | None = None, - ) -> Automation: - """Async twin of :meth:`Automations.create`.""" - payload = await self._http.request( - "POST", - "/v1/automations", - json=_create_body( - name=name, - trigger_type=trigger_type, - description=description, - cron_schedule=cron_schedule, - kind=kind, - prompt=prompt, - runtime_group_id=runtime_group_id, - task_id=task_id, - next_trigger_at=next_trigger_at, - metadata=metadata, - enabled=enabled, - ), - ) - return Automation.model_validate(payload) - - async def update( - self, - automation_id: UUID | str, - *, - name: str | None = None, - description: str | None = None, - cron_schedule: str | None = None, - prompt: str | None = None, - runtime_group_id: UUID | str | None = None, - task_id: UUID | str | None = None, - next_trigger_at: datetime | None = None, - metadata: MetadataInput | None = None, - enabled: bool | None = None, - ) -> Automation: - """Async twin of :meth:`Automations.update`.""" - payload = await self._http.request( - "PATCH", - _path(automation_id), - json=_update_body( - name=name, - description=description, - cron_schedule=cron_schedule, - prompt=prompt, - runtime_group_id=runtime_group_id, - task_id=task_id, - next_trigger_at=next_trigger_at, - metadata=metadata, - enabled=enabled, - ), - ) - return Automation.model_validate(payload) - - async def delete(self, automation_id: UUID | str) -> None: - """Async twin of :meth:`Automations.delete`.""" await self._http.request( "DELETE", _path(automation_id), expect="empty" ) @@ -398,7 +259,11 @@ async def delete(self, automation_id: UUID | str) -> None: async def trigger( self, automation_id: UUID | str ) -> AutomationTriggerResponse: - """Async twin of :meth:`Automations.trigger`.""" + """Run an automation now (``POST /v1/automations/{id}/trigger``). + + Returns the task it created or posted into. A ``skipped`` status + carries its ``reason``. Neither reads nor clears a scheduled slot. + """ payload = await self._http.request( "POST", f"{_path(automation_id)}/trigger" ) diff --git a/introspection_sdk/_async/runner_resources/connections.py b/introspection_sdk/_async/runner_resources/connections.py index 48fd7e0..335ba8d 100644 --- a/introspection_sdk/_async/runner_resources/connections.py +++ b/introspection_sdk/_async/runner_resources/connections.py @@ -1,16 +1,4 @@ -"""``client.connections`` / ``runner.connections`` — DP namespace for -``/v1/connections``: the apps (Gmail, Slack, ...) members connected for -themselves, which the agent then acts with in that member's sessions. - -These are distinct from a connector's connections -(``client.connectors.connections``, CP ``/v1/connectors/{id}/connections``), -which an integrator administers for the project. - -The routes take the ``connections:read``, ``connections:write`` and -``connections:delete`` scopes. A caller who is not a project administrator -only ever sees and manages their own connections, whatever ``member_id`` -it asks for. -""" +"""Re-exported by :mod:`introspection_sdk.runner_resources.connections`.""" from __future__ import annotations @@ -18,12 +6,10 @@ from urllib.parse import quote from uuid import UUID -from introspection_sdk._http import _AsyncHttpClient, _HttpClient +from introspection_sdk._http import _AsyncHttpClient from introspection_sdk.pagination import ( AsyncPager, - Pager, async_cursor_paginate, - cursor_paginate, ) from introspection_sdk.schemas.connections import ( Connection, @@ -75,10 +61,10 @@ def _runner_runtime( return runtime_group_id -class AppConnections: +class AsyncAppConnections: """DP ``/v1/connections`` namespace.""" - def __init__(self, http: _HttpClient) -> None: + def __init__(self, http: _AsyncHttpClient) -> None: self._http = http def list( @@ -88,7 +74,7 @@ def list( app: str | None = None, limit: int | None = None, next: str | None = None, - ) -> Pager[Connection, Paginated[Connection]]: + ) -> AsyncPager[Connection, Paginated[Connection]]: """List connections. Iterate the returned :class:`Pager` to stream every connection across pages, or call ``.page()`` for the first page only. @@ -97,18 +83,18 @@ def list( not a project administrator always gets only their own. ``app`` narrows to one provider application slug.""" - def fetch(cursor: str | None) -> Paginated[Connection]: + async def fetch(cursor: str | None) -> Paginated[Connection]: params = _list_params( member_id=member_id, app=app, limit=limit, cursor=cursor ) - payload = self._http.request( + payload = await self._http.request( "GET", CONNECTIONS_PATH, params=params ) return Paginated[Connection].model_validate(payload) - return cursor_paginate(fetch, start=next) + return async_cursor_paginate(fetch, start=next) - def create(self, *, app: str, runtime: str | UUID) -> ConnectPage: + async def create(self, *, app: str, runtime: str | UUID) -> ConnectPage: """A connect page for one app, for the caller themself. ``app`` is the provider application slug, such as ``gmail`` @@ -117,92 +103,27 @@ def create(self, *, app: str, runtime: str | UUID) -> ConnectPage: Hand the member :attr:`ConnectPage.authorize_url`; it ends on a page saying the app is connected. """ - payload = self._http.request( - "POST", CONNECTIONS_PATH, json=_create_body(app, runtime) - ) - return ConnectPage.model_validate(payload) - - def get(self, connection_id: UUID | str) -> Connection: - """Read one connection.""" - return Connection.model_validate( - self._http.request("GET", _path(connection_id)) - ) - - def delete(self, connection_id: UUID | str) -> None: - """Remove one connection.""" - self._http.request("DELETE", _path(connection_id), expect="empty") - - -class RunnerAppConnections(AppConnections): - """``runner.connections``: :class:`AppConnections` whose ``create`` - defaults ``runtime`` to the runner's runtime group.""" - - def __init__( - self, http: _HttpClient, runtime_group_id: UUID | None - ) -> None: - super().__init__(http) - self._runtime_group_id = runtime_group_id - - def create( - self, *, app: str, runtime: str | UUID | None = None - ) -> ConnectPage: - """A connect page for one app, used by this runner's runtime group - unless ``runtime`` names another. Raises :class:`ValueError` when - neither is known.""" - return super().create( - app=app, runtime=_runner_runtime(runtime, self._runtime_group_id) - ) - - -class AsyncAppConnections: - """Async twin of :class:`AppConnections` (DP ``/v1/connections``).""" - - def __init__(self, http: _AsyncHttpClient) -> None: - self._http = http - - def list( - self, - *, - member_id: UUID | str | None = None, - app: str | None = None, - limit: int | None = None, - next: str | None = None, - ) -> AsyncPager[Connection, Paginated[Connection]]: - """Async twin of :meth:`AppConnections.list`.""" - - async def fetch(cursor: str | None) -> Paginated[Connection]: - params = _list_params( - member_id=member_id, app=app, limit=limit, cursor=cursor - ) - payload = await self._http.request( - "GET", CONNECTIONS_PATH, params=params - ) - return Paginated[Connection].model_validate(payload) - - return async_cursor_paginate(fetch, start=next) - - async def create(self, *, app: str, runtime: str | UUID) -> ConnectPage: - """Async twin of :meth:`AppConnections.create`.""" payload = await self._http.request( "POST", CONNECTIONS_PATH, json=_create_body(app, runtime) ) return ConnectPage.model_validate(payload) async def get(self, connection_id: UUID | str) -> Connection: - """Async twin of :meth:`AppConnections.get`.""" + """Read one connection.""" return Connection.model_validate( await self._http.request("GET", _path(connection_id)) ) async def delete(self, connection_id: UUID | str) -> None: - """Async twin of :meth:`AppConnections.delete`.""" + """Remove one connection.""" await self._http.request( "DELETE", _path(connection_id), expect="empty" ) class AsyncRunnerAppConnections(AsyncAppConnections): - """Async twin of :class:`RunnerAppConnections`.""" + """``runner.connections``: :class:`AppConnections` whose ``create`` + defaults ``runtime`` to the runner's runtime group.""" def __init__( self, http: _AsyncHttpClient, runtime_group_id: UUID | None @@ -213,7 +134,9 @@ def __init__( async def create( self, *, app: str, runtime: str | UUID | None = None ) -> ConnectPage: - """Async twin of :meth:`RunnerAppConnections.create`.""" + """A connect page for one app, used by this runner's runtime group + unless ``runtime`` names another. Raises :class:`ValueError` when + neither is known.""" return await super().create( app=app, runtime=_runner_runtime(runtime, self._runtime_group_id) ) diff --git a/introspection_sdk/_async/runner_resources/conversations.py b/introspection_sdk/_async/runner_resources/conversations.py index 51c1f14..4522a05 100644 --- a/introspection_sdk/_async/runner_resources/conversations.py +++ b/introspection_sdk/_async/runner_resources/conversations.py @@ -1,39 +1,27 @@ -"""`runner.conversations.*` namespace: read-only conversation reads. - -Bound to a :class:`~introspection_sdk.runner.Runner` — every call targets -the runner's DP endpoint with its short-lived JWT. The surface is -read-only. - -Both conversation summaries and conversation items walk opaque ``next`` -cursors. The item envelope retains OpenAI-style ``first_id`` / ``last_id`` -metadata, but those identifiers do not drive pagination. - -Shares are ambient: a conversation shared with the caller appears in these -reads without naming the share. -""" +"""Re-exported by :mod:`introspection_sdk.runner_resources.conversations`.""" from __future__ import annotations import builtins -from collections.abc import AsyncIterator, Iterator +from collections.abc import AsyncIterator from datetime import datetime -from typing import Any, Literal, TypedDict, Unpack +from typing import Any, Literal, Unpack from urllib.parse import quote from uuid import UUID from pydantic import TypeAdapter -from introspection_sdk._http import RawResponse, _AsyncHttpClient, _HttpClient +from introspection_sdk._http import RawResponse, _AsyncHttpClient from introspection_sdk.pagination import ( AsyncPager, - Pager, async_cursor_paginate, - cursor_paginate, +) +from introspection_sdk.runner_resources._conversations_common import ( + ConversationExportParams, ) from introspection_sdk.runner_resources._reads import ( ARROW_ACCEPT_HEADERS, TRAJECTORY_ACCEPT_HEADERS, - ArrowPageIterator, AsyncArrowPageIterator, ReadFormat, decode_arrow_page, @@ -59,17 +47,6 @@ """Wire representation accepted by the complete conversation export route.""" -class ConversationExportParams(TypedDict, total=False): - """Filters accepted by the complete conversation export route.""" - - agent: str - service_name: str - operation_name: str - lookback_days: int - start_date: str | datetime - end_date: str | datetime - - JSON_EXPORT_HEADERS = {"Accept": "application/json"} @@ -198,12 +175,6 @@ def build_conversation_params( } -# The message-family `include` values are gone: the items read returns the -# full history by default, so there is nothing left for them to gate. Asking -# for context you already have was the old shape's sharpest edge — forget the -# parameter and you silently forked a conversation with one turn of context. - - def _normalize_part(part: Any) -> Any: """Map a legacy ``tool_call_response`` ``result`` key to ``response``. @@ -270,14 +241,22 @@ def _conversation_items_next(page: GenAiSpanList) -> str | None: return page.next -class ConversationItems: +class AsyncConversationItems: + # unasync: async-only (the sync twin's docstring follows) + """Async twin of :class:`ConversationItems`. Read-only. + + The returned :class:`~introspection_sdk.pagination.AsyncPager` passes each + page's opaque ``next`` token back unchanged. + """ + + # unasync: end """Items of a conversation (``/v1/conversations/{id}/items``). Read-only. The returned :class:`~introspection_sdk.pagination.Pager` passes each page's opaque ``next`` token back unchanged. """ - def __init__(self, http: _HttpClient) -> None: + def __init__(self, http: _AsyncHttpClient) -> None: self._http = http def list( @@ -293,10 +272,11 @@ def list( start_date: str | None = None, end_date: str | None = None, lookback_days: int | None = None, - ) -> Pager[GenAiSpan, GenAiSpanList]: - """List conversation items using an opaque ``next`` cursor. Iterate - the returned :class:`Pager` to stream every item - across pages, or call ``.page()`` for the first page only. + ) -> AsyncPager[GenAiSpan, GenAiSpanList]: + # unasync: async-only (the sync twin's docstring follows) + """List conversation items using an opaque ``next`` cursor. ``await`` + the returned :class:`AsyncPager` for the first + page, or ``async for`` it to stream every item across pages. Items are always returned newest-first: the route hardcodes a descending sort and rejects a cursor that disagrees, so there is no @@ -306,452 +286,10 @@ def list( messages new to that turn. Use :meth:`get` for the full input history of a span. """ - - def fetch(cursor: str | None) -> GenAiSpanList: - params: dict[str, Any] = { - "limit": limit, - "next": cursor, - "include": include, - "agent": agent, - "service_name": service_name, - "operation_name": operation_name, - "start_date": start_date, - "end_date": end_date, - "lookback_days": lookback_days, - } - payload = self._http.request( - "GET", - f"/v1/conversations/{conversation_id}/items", - params=params, - ) - if isinstance(payload, dict) and isinstance( - payload.get("data"), list - ): - payload = { - **payload, - "data": [ - _normalize_item_payload(d) for d in payload["data"] - ], - } - return GenAiSpanList.model_validate(payload) - - return Pager( - fetch, - items=lambda page: page.data, - next_cursor=_conversation_items_next, - start=next, - ) - - def get( - self, - conversation_id: str, - item_id: str, - *, - include: builtins.list[ConversationItemInclude] | None = None, - ) -> GenAiSpan: - """Fetch a single conversation item. Unlike the list route, the - detail's ``input_messages`` is the FULL input history for that - span.""" - params: dict[str, Any] = {"include": include} - payload = self._http.request( - "GET", - f"/v1/conversations/{conversation_id}/items/{item_id}", - params=params, - ) - return GenAiSpan.model_validate(_normalize_item_payload(payload)) - - -class Conversations: - """Read-only Conversations API (``/v1/conversations``). - - Both :meth:`list` and :meth:`items.list ` - return an auto-paging :class:`~introspection_sdk.pagination.Pager` and - pass each page's opaque ``next`` token back unchanged. - """ - - def __init__(self, http: _HttpClient) -> None: - self._http = http - self.items = ConversationItems(http) - - def list( - self, - *, - limit: int = 100, - next: str | None = None, - conversation_id: str | None = None, - sort: ConversationSortField | None = None, - direction: Literal["asc", "desc"] | None = None, - model: str | None = None, - agent_name: str | None = None, - status: SpanStatus | None = None, - service_name: str | None = None, - service_names: builtins.list[str] | None = None, - environment: str | None = None, - runtime_id: UUID | None = None, - runtime_group_id: UUID | None = None, - experiment_id: UUID | None = None, - recipe_git_commit_sha: str | None = None, - conversation_ids: builtins.list[str] | None = None, - resolution: ConversationResolution | None = None, - sentiment: ConversationSentiment | None = None, - owner_key: str | None = None, - metadata: dict[str, str] | None = None, - start_date: str | datetime | None = None, - end_date: str | datetime | None = None, - order: Literal["asc", "desc"] | None = None, - start: str | datetime | None = None, - end: str | datetime | None = None, - lookback: str | None = None, - format: ReadFormat = "json", - ) -> Pager[Conversation, Paginated[Conversation]]: - """List conversation summaries (cursor envelope). Iterate the - returned :class:`Pager` to stream every summary across pages, or - call ``.page()`` for the first page only. - - ``order`` is an alias for ``direction``; ``start`` / ``end`` for - ``start_date`` / ``end_date``; ``lookback`` (e.g. ``"24h"``) sets - ``start_date = now - lookback`` and is mutually exclusive with - ``start`` / ``end``. ``format="arrow"`` negotiates the columnar Arrow - stream, decoded back into the same envelope.""" - resolved_start, resolved_end = resolve_window( - start=start, - end=end, - lookback=lookback, - start_date=start_date, - end_date=end_date, - ) - - def fetch(cursor: str | None) -> Paginated[Conversation]: - params = build_conversation_params( - limit=limit, - cursor=cursor, - conversation_id=conversation_id, - sort=sort, - direction=direction or order, - model=model, - agent_name=agent_name, - status=status, - service_name=service_name, - service_names=service_names, - environment=environment, - runtime_id=runtime_id, - runtime_group_id=runtime_group_id, - experiment_id=experiment_id, - recipe_git_commit_sha=recipe_git_commit_sha, - conversation_ids=conversation_ids, - resolution=resolution, - sentiment=sentiment, - owner_key=owner_key, - metadata=metadata, - start_date=resolved_start, - end_date=resolved_end, - ) - if format == "arrow": - raw = self._http.request( - "GET", - "/v1/conversations", - params=params, - headers=ARROW_ACCEPT_HEADERS, - expect="raw", - ) - assert isinstance(raw, RawResponse) - return decode_arrow_page(raw, Conversation.model_validate) - payload = self._http.request( - "GET", "/v1/conversations", params=params - ) - return Paginated[Conversation].model_validate(payload) - - return cursor_paginate(fetch, start=next) - - def list_arrow( - self, - *, - limit: int = 100, - next: str | None = None, - conversation_id: str | None = None, - sort: ConversationSortField | None = None, - direction: Literal["asc", "desc"] | None = None, - model: str | None = None, - agent_name: str | None = None, - status: SpanStatus | None = None, - service_name: str | None = None, - service_names: builtins.list[str] | None = None, - environment: str | None = None, - runtime_id: UUID | None = None, - runtime_group_id: UUID | None = None, - experiment_id: UUID | None = None, - recipe_git_commit_sha: str | None = None, - conversation_ids: builtins.list[str] | None = None, - resolution: ConversationResolution | None = None, - sentiment: ConversationSentiment | None = None, - owner_key: str | None = None, - metadata: dict[str, str] | None = None, - start_date: str | datetime | None = None, - end_date: str | datetime | None = None, - order: Literal["asc", "desc"] | None = None, - start: str | datetime | None = None, - end: str | datetime | None = None, - lookback: str | None = None, - ) -> ArrowPageIterator: - """Columnar accessor: iterate one ``pyarrow.Table`` per server page - (constant memory), or call ``.read_all()`` to concatenate every - page into one Table. Same filters as :meth:`list`; requires the - ``[arrow]`` extra.""" - resolved_start, resolved_end = resolve_window( - start=start, - end=end, - lookback=lookback, - start_date=start_date, - end_date=end_date, - ) - - def fetch(cursor: str | None) -> RawResponse: - params = build_conversation_params( - limit=limit, - cursor=cursor, - conversation_id=conversation_id, - sort=sort, - direction=direction or order, - model=model, - agent_name=agent_name, - status=status, - service_name=service_name, - service_names=service_names, - environment=environment, - runtime_id=runtime_id, - runtime_group_id=runtime_group_id, - experiment_id=experiment_id, - recipe_git_commit_sha=recipe_git_commit_sha, - conversation_ids=conversation_ids, - resolution=resolution, - sentiment=sentiment, - owner_key=owner_key, - metadata=metadata, - start_date=resolved_start, - end_date=resolved_end, - ) - raw = self._http.request( - "GET", - "/v1/conversations", - params=params, - headers=ARROW_ACCEPT_HEADERS, - expect="raw", - ) - assert isinstance(raw, RawResponse) - return raw - - return ArrowPageIterator(fetch, start=next) - - def iterate( - self, - *, - max_items: int | None = None, - **kwargs: Any, - ) -> Iterator[Conversation]: - """Cursor generator: page through :meth:`list` to exhaustion, yielding - every summary. ``max_items`` bounds the total yielded (``None`` = no - bound). All other keyword args are forwarded to :meth:`list`.""" - if max_items is not None and max_items <= 0: - return - yielded = 0 - for record in self.list(**kwargs): - yield record - yielded += 1 - if max_items is not None and yielded >= max_items: - return - - def get(self, conversation_id: str) -> Conversation: - """Fetch one conversation summary with its complete agent index.""" - payload = self._http.request( - "GET", f"/v1/conversations/{conversation_id}" - ) - return Conversation.model_validate(payload) - - def export_json( - self, - conversation_id: str, - **params: Unpack[ConversationExportParams], - ) -> GenAiSpanList: - """Export one complete conversation as the standard GenAI-span list.""" - payload = self._http.request( - "GET", - _export_path(conversation_id), - params=build_export_params(**params), - headers=JSON_EXPORT_HEADERS, - ) - return GenAiSpanList.model_validate(payload) - - def export_stream( - self, - conversation_id: str, - format: ConversationExportFormat, - **params: Unpack[ConversationExportParams], - ) -> Iterator[bytes]: - """Stream raw complete-export bytes without buffering them in the SDK.""" - return self._http.stream_bytes( - _export_path(conversation_id), - params=build_export_params(**params), - headers=_export_headers(format), - ) - - def export_trajectory( - self, - conversation_id: str, - *, - agent: str | None = None, - service_name: str | None = None, - operation_name: str | None = None, - lookback_days: int | None = None, - start_date: str | datetime | None = None, - end_date: str | datetime | None = None, - ) -> Trajectory: - """Export one complete conversation as trajectory-v1. - - This is not :meth:`ConversationItems.list` in another coat. The - export is assembled server-side over the whole conversation, so it - carries no cursor and no page bound; the keyword args filter what - gets assembled. - - The trajectory is a projection derived on read from the stored - GenAI messages, so a conversation that cannot be represented as - trajectory-v1 raises rather than returning a partial export, and a - conversation with no exportable records raises ``NotFoundError``. - """ - payload = self._http.request( - "GET", - _export_path(conversation_id), - params=build_export_params( - agent=agent, - service_name=service_name, - operation_name=operation_name, - lookback_days=lookback_days, - start_date=start_date, - end_date=end_date, - ), - headers=TRAJECTORY_ACCEPT_HEADERS, - ) - return TRAJECTORY_ADAPTER.validate_python(payload) - - def export_arrow( - self, - conversation_id: str, - *, - agent: str | None = None, - service_name: str | None = None, - operation_name: str | None = None, - lookback_days: int | None = None, - start_date: str | datetime | None = None, - end_date: str | datetime | None = None, - ) -> Any: - """Export one complete conversation as a single ``pyarrow.Table``. - - Unlike :meth:`list_arrow`, this is one table for the whole conversation - rather than an iterator of pages: the export route assembles the - conversation server-side and streams it in one response. Returns - ``None`` for an empty body. - - Requires the optional ``pyarrow`` dependency. - """ - raw = self._http.request( - "GET", - _export_path(conversation_id), - params=build_export_params( - agent=agent, - service_name=service_name, - operation_name=operation_name, - lookback_days=lookback_days, - start_date=start_date, - end_date=end_date, - ), - headers=ARROW_ACCEPT_HEADERS, - expect="raw", - ) - assert isinstance(raw, RawResponse) - return decode_arrow_table(raw) - - def retrieve( - self, conversation_id: str, item_id: str | None = None - ) -> GenAiSpan | None: - """Load the state of a conversation as of one item. - - Returns the span itself. There is no separate response type any more: - the items read already carries the full input history, system - instructions, and tool definitions, so composing a second object from - it would just be copying fields into different names. - - When ``item_id`` is omitted the latest LLM turn is used — the first - item in descending order whose ``gen_ai.operation.name`` is - ``"chat"``, falling back to the first item that produced any output. - Returns ``None`` when the conversation has no items. - - For the full per-turn transcript instead, iterate - ``items.list(conversation_id)``, which is newest-first. - """ - target_id = item_id or self._find_latest_turn_id(conversation_id) - if target_id is None: - return None - return self.items.get(conversation_id, target_id) - - def _find_latest_turn_id(self, conversation_id: str) -> str | None: - """Scan items in descending order for the most recent LLM turn. - - The old heuristic also matched ``node_type == "assistant"``, but - ``node_type`` was a precomputed UI tree hint with no semantic-convention - equivalent and is gone from the wire. ``gen_ai.operation.name`` is the - attribute that actually carried the meaning. - - Bounded by :data:`LATEST_TURN_SCAN_LIMIT`: see its note for why an - unbounded scan was the wrong shape for this lookup. - """ - fallback: GenAiSpan | None = None - scanned = 0 - # The route is descending-only, so the first match is the latest. - for item in self.items.list( - conversation_id, limit=LATEST_TURN_SCAN_LIMIT - ): - gen_ai = item.attributes.gen_ai - operation = ( - gen_ai.operation.name if gen_ai and gen_ai.operation else None - ) - if operation == "chat": - return item.span_id - if fallback is None and item.output_messages: - fallback = item - # Counted after the body, so the loop stops on the last item of - # the page rather than asking the pager for the next one. - scanned += 1 - if scanned >= LATEST_TURN_SCAN_LIMIT: - break - return fallback.span_id if fallback else None - - -class AsyncConversationItems: - """Async twin of :class:`ConversationItems`. Read-only. - - The returned :class:`~introspection_sdk.pagination.AsyncPager` passes each - page's opaque ``next`` token back unchanged. - """ - - def __init__(self, http: _AsyncHttpClient) -> None: - self._http = http - - def list( - self, - conversation_id: str, - *, - limit: int = 100, - next: str | None = None, - include: builtins.list[ConversationItemInclude] | None = None, - agent: str | None = None, - service_name: str | None = None, - operation_name: str | None = None, - start_date: str | None = None, - end_date: str | None = None, - lookback_days: int | None = None, - ) -> AsyncPager[GenAiSpan, GenAiSpanList]: - """List conversation items using an opaque ``next`` cursor. ``await`` - the returned :class:`AsyncPager` for the first - page, or ``async for`` it to stream every item across pages. + # unasync: end + """List conversation items using an opaque ``next`` cursor. Iterate + the returned :class:`Pager` to stream every item + across pages, or call ``.page()`` for the first page only. Items are always returned newest-first: the route hardcodes a descending sort and rejects a cursor that disagrees, so there is no @@ -817,6 +355,7 @@ async def get( class AsyncConversations: + # unasync: async-only (the sync twin's docstring follows) """Async twin of :class:`Conversations`. Read-only (``/v1/conversations``). @@ -825,6 +364,14 @@ class AsyncConversations: and pass each page's opaque ``next`` token back unchanged. """ + # unasync: end + """Read-only Conversations API (``/v1/conversations``). + + Both :meth:`list` and :meth:`items.list ` + return an auto-paging :class:`~introspection_sdk.pagination.Pager` and + pass each page's opaque ``next`` token back unchanged. + """ + def __init__(self, http: _AsyncHttpClient) -> None: self._http = http self.items = AsyncConversationItems(http) @@ -860,10 +407,21 @@ def list( lookback: str | None = None, format: ReadFormat = "json", ) -> AsyncPager[Conversation, Paginated[Conversation]]: + # unasync: async-only (the sync twin's docstring follows) """List conversation summaries (cursor envelope). ``await`` the returned :class:`AsyncPager` for the first page, or ``async for`` it to stream every summary across pages. See :meth:`Conversations.list` for the param semantics.""" + # unasync: end + """List conversation summaries (cursor envelope). Iterate the + returned :class:`Pager` to stream every summary across pages, or + call ``.page()`` for the first page only. + + ``order`` is an alias for ``direction``; ``start`` / ``end`` for + ``start_date`` / ``end_date``; ``lookback`` (e.g. ``"24h"``) sets + ``start_date = now - lookback`` and is mutually exclusive with + ``start`` / ``end``. ``format="arrow"`` negotiates the columnar Arrow + stream, decoded back into the same envelope.""" resolved_start, resolved_end = resolve_window( start=start, end=end, @@ -946,10 +504,16 @@ def list_arrow( end: str | datetime | None = None, lookback: str | None = None, ) -> AsyncArrowPageIterator: + # unasync: async-only (the sync twin's docstring follows) """Columnar accessor: ``async for`` one ``pyarrow.Table`` per server page, or ``await .read_all()`` to concatenate every page into one Table. Same filters as :meth:`list`; requires the ``[arrow]`` extra.""" + # unasync: end + """Columnar accessor: iterate one ``pyarrow.Table`` per server page + (constant memory), or call ``.read_all()`` to concatenate every + page into one Table. Same filters as :meth:`list`; requires the + ``[arrow]`` extra.""" resolved_start, resolved_end = resolve_window( start=start, end=end, @@ -1025,7 +589,7 @@ async def export_json( conversation_id: str, **params: Unpack[ConversationExportParams], ) -> GenAiSpanList: - """Async twin of :meth:`Conversations.export_json`.""" + """Export one complete conversation as the standard GenAI-span list.""" payload = await self._http.request( "GET", _export_path(conversation_id), @@ -1040,7 +604,7 @@ def export_stream( format: ConversationExportFormat, **params: Unpack[ConversationExportParams], ) -> AsyncIterator[bytes]: - """Async byte-stream twin of :meth:`Conversations.export_stream`.""" + """Stream raw complete-export bytes without buffering them in the SDK.""" return self._http.stream_bytes( _export_path(conversation_id), params=build_export_params(**params), @@ -1058,7 +622,18 @@ async def export_trajectory( start_date: str | datetime | None = None, end_date: str | datetime | None = None, ) -> Trajectory: - """Async twin of :meth:`Conversations.export_trajectory`.""" + """Export one complete conversation as trajectory-v1. + + This is not :meth:`ConversationItems.list` in another coat. The + export is assembled server-side over the whole conversation, so it + carries no cursor and no page bound; the keyword args filter what + gets assembled. + + The trajectory is a projection derived on read from the stored + GenAI messages, so a conversation that cannot be represented as + trajectory-v1 raises rather than returning a partial export, and a + conversation with no exportable records raises ``NotFoundError``. + """ payload = await self._http.request( "GET", _export_path(conversation_id), @@ -1085,7 +660,15 @@ async def export_arrow( start_date: str | datetime | None = None, end_date: str | datetime | None = None, ) -> Any: - """Async twin of :meth:`Conversations.export_arrow`.""" + """Export one complete conversation as a single ``pyarrow.Table``. + + Unlike :meth:`list_arrow`, this is one table for the whole conversation + rather than an iterator of pages: the export route assembles the + conversation server-side and streams it in one response. Returns + ``None`` for an empty body. + + Requires the optional ``pyarrow`` dependency. + """ raw = await self._http.request( "GET", _export_path(conversation_id), @@ -1127,6 +710,7 @@ async def retrieve( return await self.items.get(conversation_id, target_id) async def _find_latest_turn_id(self, conversation_id: str) -> str | None: + # unasync: async-only (the sync twin's docstring follows) """Scan items in descending order for the most recent LLM turn. The old heuristic also matched ``node_type == "assistant"``, but @@ -1136,6 +720,17 @@ async def _find_latest_turn_id(self, conversation_id: str) -> str | None: Bounded by :data:`LATEST_TURN_SCAN_LIMIT`, exactly as the sync twin. """ + # unasync: end + """Scan items in descending order for the most recent LLM turn. + + The old heuristic also matched ``node_type == "assistant"``, but + ``node_type`` was a precomputed UI tree hint with no semantic-convention + equivalent and is gone from the wire. ``gen_ai.operation.name`` is the + attribute that actually carried the meaning. + + Bounded by :data:`LATEST_TURN_SCAN_LIMIT`: see its note for why an + unbounded scan was the wrong shape for this lookup. + """ fallback: GenAiSpan | None = None scanned = 0 # The route is descending-only, so the first match is the latest. @@ -1150,6 +745,8 @@ async def _find_latest_turn_id(self, conversation_id: str) -> str | None: return item.span_id if fallback is None and item.output_messages: fallback = item + # Counted after the body, so the loop stops on the last item of + # the page rather than asking the pager for the next one. scanned += 1 if scanned >= LATEST_TURN_SCAN_LIMIT: break diff --git a/introspection_sdk/_async/runner_resources/events.py b/introspection_sdk/_async/runner_resources/events.py index 7506def..f29152e 100644 --- a/introspection_sdk/_async/runner_resources/events.py +++ b/introspection_sdk/_async/runner_resources/events.py @@ -1,53 +1,26 @@ -"""`runner.events.*` namespace: read-only event reads. - -Bound to a :class:`~introspection_sdk.runner.Runner` — every call targets -the runner's DP endpoint with its short-lived JWT. The surface is -read-only and mirrors ``runner.conversations``: one cursor-paginated list -read over the append-only ``otel_logs`` store. Every read names its -family — ``event_name`` is **required, exactly one** — so a page is -always homogeneous and each row validates into one member of the -discriminated :data:`~introspection_sdk.schemas.events.Event` union -(common envelope + nested typed ``payload``). - -Three response representations share one request contract: - -* JSON (default) — the standard cursor envelope - (:class:`~introspection_sdk.schemas.pagination.Paginated`). -* Arrow (``format="arrow"``) — the same page as an Arrow IPC stream - (envelope columns + a typed ``payload`` struct column) with pagination - metadata on response headers, decoded back into the identical envelope - (see :mod:`introspection_sdk.runner_resources._reads`). -* Columnar (:meth:`Events.arrow`) — the raw ``pyarrow.Table`` per page, - for callers who want columns rather than models. - -**Unknown-family tolerance:** rows whose ``event_name`` is outside the -SDK's known family set are skipped client-side (never raised), counted on -:data:`UNKNOWN_EVENT_SKIPS`, and debug-logged — so a new server-side -family doesn't break older SDKs. -""" +"""Re-exported by :mod:`introspection_sdk.runner_resources.events`.""" from __future__ import annotations import builtins import json -import logging -from collections.abc import AsyncIterator, Iterator +from collections.abc import AsyncIterator from datetime import datetime from typing import Any, Literal from uuid import UUID from pydantic import TypeAdapter -from introspection_sdk._http import RawResponse, _AsyncHttpClient, _HttpClient +from introspection_sdk._http import RawResponse, _AsyncHttpClient from introspection_sdk.pagination import ( AsyncPager, - Pager, async_cursor_paginate, - cursor_paginate, +) +from introspection_sdk.runner_resources._events_common import ( + UNKNOWN_EVENT_SKIPS, ) from introspection_sdk.runner_resources._reads import ( ARROW_ACCEPT_HEADERS, - ArrowPageIterator, AsyncArrowPageIterator, ReadFormat, decode_arrow_page, @@ -62,32 +35,10 @@ ) from introspection_sdk.schemas.pagination import Paginated -logger = logging.getLogger(__name__) - #: One adapter validates a decoded row into the right union member off the #: top-level ``event_name`` discriminator. EVENT_ADAPTER: TypeAdapter[Event] = TypeAdapter(Event) - -class UnknownEventCounter: - """Counts rows skipped because their family is unknown to this SDK.""" - - def __init__(self) -> None: - self.count = 0 - - def increment(self, event_name: Any) -> None: - self.count += 1 - logger.debug( - "skipping event row with unknown event_name %r " - "(not in this SDK's typed family set; total skipped: %d)", - event_name, - self.count, - ) - - -#: Process-wide skip counter for unknown-family rows (observability hook). -UNKNOWN_EVENT_SKIPS = UnknownEventCounter() - EVENT_PAYLOAD_JSON_FIELDS = frozenset({"metadata", "params", "properties"}) @@ -224,7 +175,7 @@ def build_event_params( } -class Events: +class AsyncEvents: """Read-only Events API (``GET /v1/events``). :meth:`list` returns an auto-paging @@ -234,7 +185,7 @@ class Events: columnar accessor — raw ``pyarrow.Table`` pages instead of models. """ - def __init__(self, http: _HttpClient) -> None: + def __init__(self, http: _AsyncHttpClient) -> None: self._http = http def list( @@ -269,7 +220,13 @@ def list( automation_id: UUID | str | None = None, task_id: UUID | str | None = None, format: ReadFormat = "json", - ) -> Pager[Event, Paginated[Event]]: + ) -> AsyncPager[Event, Paginated[Event]]: + # unasync: async-only (the sync twin's docstring follows) + """List events of one family (cursor envelope). ``await`` the + returned :class:`AsyncPager` for the first page, or ``async for`` + it to stream every event across pages. See :meth:`Events.list` for + the param semantics.""" + # unasync: end """List events of one family (cursor envelope). Iterate the returned :class:`Pager` to stream every event across pages, or call ``.page()`` for the first page only. @@ -292,218 +249,6 @@ def list( ) build_event_params(event_name) # fail fast before any request - def fetch(cursor: str | None) -> Paginated[Event]: - params = build_event_params( - event_name, - limit=limit, - cursor=cursor, - sort=sort, - direction=direction or order, - start_date=resolved_start, - end_date=resolved_end, - conversation_id=conversation_id, - service_name=service_name, - environment=environment, - runtime_group_id=runtime_group_id, - trace_id=trace_id, - span_id=span_id, - owner_key=owner_key, - event_id=event_id, - conversation_ids=conversation_ids, - lens=lens, - pattern_id=pattern_id, - include_superseded=include_superseded, - severities=severities, - runtime_group_unattributed=runtime_group_unattributed, - status=status, - automation_id=automation_id, - task_id=task_id, - ) - if format == "arrow": - raw = self._http.request( - "GET", - "/v1/events", - params=params, - headers=ARROW_ACCEPT_HEADERS, - expect="raw", - ) - assert isinstance(raw, RawResponse) - return decode_arrow_page(raw, validate_event_row) - payload = self._http.request("GET", "/v1/events", params=params) - return validate_event_page(payload) - - return cursor_paginate(fetch, start=next) - - def get(self, event_id: str) -> Event | UnknownEvent: - """Read one event by id, across every family. - - No ``event_name`` is supplied, so the family is not known in - advance and the result widens to :class:`UnknownEvent` for a - family added server-side after this SDK version. Raises - ``NotFoundError`` when the id does not resolve in tenant scope. - """ - payload = self._http.request("GET", f"/v1/events/{event_id}") - return validate_single_event(payload) - - def iterate( - self, - event_name: str | IntrospectionEventName, - *, - max_items: int | None = None, - **kwargs: Any, - ) -> Iterator[Event]: - """Cursor generator: page through :meth:`list` to exhaustion, yielding - every event. ``max_items`` bounds the total yielded (``None`` = no - bound). All other keyword args are forwarded to :meth:`list`. - """ - if max_items is not None and max_items <= 0: - return - yielded = 0 - for record in self.list(event_name, **kwargs): - yield record - yielded += 1 - if max_items is not None and yielded >= max_items: - return - - def list_arrow( - self, - event_name: str | IntrospectionEventName, - *, - limit: int = 100, - next: str | None = None, - sort: EventSortField | None = None, - direction: Literal["asc", "desc"] | None = None, - order: Literal["asc", "desc"] | None = None, - start: str | datetime | None = None, - end: str | datetime | None = None, - lookback: str | None = None, - start_date: str | datetime | None = None, - end_date: str | datetime | None = None, - conversation_id: str | None = None, - service_name: str | None = None, - environment: str | None = None, - runtime_group_id: UUID | None = None, - trace_id: str | None = None, - span_id: str | None = None, - owner_key: str | None = None, - event_id: builtins.list[str] | None = None, - conversation_ids: builtins.list[str] | None = None, - lens: str | None = None, - pattern_id: str | UUID | None = None, - include_superseded: bool | None = None, - severities: builtins.list[str] | None = None, - runtime_group_unattributed: bool | None = None, - status: str | None = None, - automation_id: UUID | str | None = None, - task_id: UUID | str | None = None, - ) -> ArrowPageIterator: - """Columnar accessor: iterate one ``pyarrow.Table`` per server page - (constant memory — envelope columns + the family's typed ``payload`` - struct column), or call ``.read_all()`` to concatenate every page - into one Table. Same filters as :meth:`list`; requires the - ``[arrow]`` extra. - """ - resolved_start, resolved_end = resolve_window( - start=start, - end=end, - lookback=lookback, - start_date=start_date, - end_date=end_date, - ) - build_event_params(event_name) # fail fast before any request - - def fetch(cursor: str | None) -> RawResponse: - params = build_event_params( - event_name, - limit=limit, - cursor=cursor, - sort=sort, - direction=direction or order, - start_date=resolved_start, - end_date=resolved_end, - conversation_id=conversation_id, - service_name=service_name, - environment=environment, - runtime_group_id=runtime_group_id, - trace_id=trace_id, - span_id=span_id, - owner_key=owner_key, - event_id=event_id, - conversation_ids=conversation_ids, - lens=lens, - pattern_id=pattern_id, - include_superseded=include_superseded, - severities=severities, - runtime_group_unattributed=runtime_group_unattributed, - status=status, - automation_id=automation_id, - task_id=task_id, - ) - raw = self._http.request( - "GET", - "/v1/events", - params=params, - headers=ARROW_ACCEPT_HEADERS, - expect="raw", - ) - assert isinstance(raw, RawResponse) - return raw - - return ArrowPageIterator(fetch, start=next) - - -class AsyncEvents: - """Async twin of :class:`Events`. Read-only (``GET /v1/events``).""" - - def __init__(self, http: _AsyncHttpClient) -> None: - self._http = http - - def list( - self, - event_name: str | IntrospectionEventName, - *, - limit: int = 100, - next: str | None = None, - sort: EventSortField | None = None, - direction: Literal["asc", "desc"] | None = None, - order: Literal["asc", "desc"] | None = None, - start: str | datetime | None = None, - end: str | datetime | None = None, - lookback: str | None = None, - start_date: str | datetime | None = None, - end_date: str | datetime | None = None, - conversation_id: str | None = None, - service_name: str | None = None, - environment: str | None = None, - runtime_group_id: UUID | None = None, - trace_id: str | None = None, - span_id: str | None = None, - owner_key: str | None = None, - event_id: builtins.list[str] | None = None, - conversation_ids: builtins.list[str] | None = None, - lens: str | None = None, - pattern_id: str | UUID | None = None, - include_superseded: bool | None = None, - severities: builtins.list[str] | None = None, - runtime_group_unattributed: bool | None = None, - status: str | None = None, - automation_id: UUID | str | None = None, - task_id: UUID | str | None = None, - format: ReadFormat = "json", - ) -> AsyncPager[Event, Paginated[Event]]: - """List events of one family (cursor envelope). ``await`` the - returned :class:`AsyncPager` for the first page, or ``async for`` - it to stream every event across pages. See :meth:`Events.list` for - the param semantics.""" - resolved_start, resolved_end = resolve_window( - start=start, - end=end, - lookback=lookback, - start_date=start_date, - end_date=end_date, - ) - build_event_params(event_name) # fail fast before any request - async def fetch(cursor: str | None) -> Paginated[Event]: params = build_event_params( event_name, @@ -549,7 +294,13 @@ async def fetch(cursor: str | None) -> Paginated[Event]: return async_cursor_paginate(fetch, start=next) async def get(self, event_id: str) -> Event | UnknownEvent: - """Async twin of :meth:`Events.get`.""" + """Read one event by id, across every family. + + No ``event_name`` is supplied, so the family is not known in + advance and the result widens to :class:`UnknownEvent` for a + family added server-side after this SDK version. Raises + ``NotFoundError`` when the id does not resolve in tenant scope. + """ payload = await self._http.request("GET", f"/v1/events/{event_id}") return validate_single_event(payload) @@ -605,10 +356,18 @@ def list_arrow( automation_id: UUID | str | None = None, task_id: UUID | str | None = None, ) -> AsyncArrowPageIterator: + # unasync: async-only (the sync twin's docstring follows) """Columnar accessor: ``async for`` one ``pyarrow.Table`` per server page, or ``await .read_all()`` to concatenate every page into one Table. Same filters as :meth:`list`; requires the ``[arrow]`` extra.""" + # unasync: end + """Columnar accessor: iterate one ``pyarrow.Table`` per server page + (constant memory — envelope columns + the family's typed ``payload`` + struct column), or call ``.read_all()`` to concatenate every page + into one Table. Same filters as :meth:`list`; requires the + ``[arrow]`` extra. + """ resolved_start, resolved_end = resolve_window( start=start, end=end, diff --git a/introspection_sdk/_async/runner_resources/files.py b/introspection_sdk/_async/runner_resources/files.py index cd05d12..a5726cd 100644 --- a/introspection_sdk/_async/runner_resources/files.py +++ b/introspection_sdk/_async/runner_resources/files.py @@ -1,8 +1,4 @@ -"""`runner.files.*` namespace: list / upload / download / versions. - -Bound to a :class:`~introspection_sdk.runner.Runner` — every call -targets the runner's DP endpoint with its short-lived JWT. -""" +"""Re-exported by :mod:`introspection_sdk.runner_resources.files`.""" from __future__ import annotations @@ -14,12 +10,10 @@ from pathlib import Path from typing import IO, Any -from introspection_sdk._http import _AsyncHttpClient, _HttpClient +from introspection_sdk._http import _AsyncHttpClient from introspection_sdk.pagination import ( AsyncPager, - Pager, async_cursor_paginate, - cursor_paginate, ) from introspection_sdk.schemas.files import ( File, @@ -97,217 +91,6 @@ def _upload_form( return data -class FileVersions: - def __init__(self, http: _HttpClient) -> None: - self._http = http - - def list( - self, - file_id: str, - *, - limit: int = 100, - next: str | None = None, - include_total: bool = False, - ) -> Pager[File, Paginated[File]]: - """List versions of a file. Iterate the returned :class:`Pager` to - stream every version across pages, or call ``.page()`` for the - first page only.""" - - def fetch(cursor: str | None) -> Paginated[File]: - params: dict[str, Any] = { - "limit": limit, - "next": cursor, - "include_total": include_total, - } - payload = self._http.request( - "GET", f"/v1/files/{file_id}/versions", params=params - ) - return Paginated[File].model_validate(payload) - - return cursor_paginate(fetch, start=next) - - def get(self, file_id: str, version_id: str) -> File: - payload = self._http.request( - "GET", f"/v1/files/{file_id}/versions/{version_id}" - ) - return File.model_validate(payload) - - def create( - self, - file_id: str, - *, - file: FileLike, - name: str | None = None, - file_type: FileType | str = FileType.OTHER, - content_type: str | None = None, - ) -> File: - with _materialise_upload(file, name, content_type) as ( - n, - body, - ct, - ): - files = {"file": (n, body, ct)} - data = { - "name": n, - "file_type": ( - file_type.value - if isinstance(file_type, FileType) - else file_type - ), - } - payload = self._http.request( - "POST", - f"/v1/files/{file_id}/versions", - files=files, - data=data, - ) - return File.model_validate(payload) - - -class Files: - def __init__(self, http: _HttpClient) -> None: - self._http = http - self.versions = FileVersions(http) - - def list( - self, - *, - limit: int = 100, - next: str | None = None, - include_total: bool = False, - name: str | None = None, - file_type: FileType | str | None = None, - storage_path: str | None = None, - tag: str | None = None, - metadata: dict[str, str] | None = None, - ) -> Pager[File, Paginated[File]]: - """List files. Iterate the returned :class:`Pager` to stream every - file across pages, or call ``.page()`` for the first page only. - - ``metadata`` narrows to files whose metadata holds every pair, each - matched exactly against the string value; it is sent as one repeated - ``metadata=key:value`` param per entry (at most 16). Keys are letters, - digits, ``_`` and ``-``. A server that predates the filter ignores it - and returns the unfiltered list.""" - - def fetch(cursor: str | None) -> Paginated[File]: - params: dict[str, Any] = { - "limit": limit, - "next": cursor, - "include_total": include_total, - "name": name, - "file_type": ( - file_type.value - if isinstance(file_type, FileType) - else file_type - ), - "storage_path": storage_path, - "tag": tag, - "metadata": ( - [f"{key}:{value}" for key, value in metadata.items()] - if metadata - else None - ), - } - payload = self._http.request("GET", "/v1/files", params=params) - return Paginated[File].model_validate(payload) - - return cursor_paginate(fetch, start=next) - - def upload( - self, - *, - file: FileLike, - name: str | None = None, - file_type: FileType | str = FileType.OTHER, - content_type: str | None = None, - metadata: dict[str, Any] | None = None, - tags: builtins.list[str] | None = None, - ) -> File: - """Upload a binary file via multipart. - - ``tags`` and ``metadata`` are stamped when the request creates the - file. A request that adds a new version to an existing file keeps - that file's tags; change them with :meth:`update`. - - Example: - >>> runner.files.upload( - ... file=Path("input.jsonl"), - ... file_type="upload", - ... ) - """ - with _materialise_upload(file, name, content_type) as ( - n, - body, - ct, - ): - files = {"file": (n, body, ct)} - data = _upload_form(n, file_type, metadata, tags) - payload = self._http.request( - "POST", "/v1/files", files=files, data=data - ) - return File.model_validate(payload) - - def create_text( - self, - *, - name: str, - content: str, - mime_type: str = "text/markdown", - metadata: dict[str, Any] | None = None, - tags: builtins.list[str] | None = None, - ) -> File: - """Create a text/markdown file via JSON body. - - ``tags`` and ``metadata`` are stamped when the request creates the - file. A request that adds a new version to an existing file keeps - that file's tags; change them with :meth:`update`.""" - body = FileCreateTextRequest( - name=name, - content=content, - mime_type=mime_type, - metadata=metadata, - tags=tags, - ) - payload = self._http.request( - "POST", "/v1/files", json=body.model_dump(exclude_none=True) - ) - return File.model_validate(payload) - - def get(self, file_id: str) -> File: - payload = self._http.request("GET", f"/v1/files/{file_id}") - return File.model_validate(payload) - - def update( - self, - file_id: str, - *, - name: str | None = None, - metadata: dict[str, Any] | None = None, - tags: builtins.list[str] | None = None, - ) -> File: - # `exclude_none` drops an omitted tag list but keeps an explicit [], - # which is what clears the tags — they replace wholesale. - body = FileUpdateRequest( - name=name, metadata=metadata, tags=tags - ).model_dump(exclude_none=True) - payload = self._http.request( - "PATCH", f"/v1/files/{file_id}", json=body - ) - return File.model_validate(payload) - - def delete(self, file_id: str) -> None: - self._http.request("DELETE", f"/v1/files/{file_id}", expect="empty") - - def download(self, file_id: str) -> bytes: - return self._http.request( - "GET", f"/v1/files/{file_id}/content", expect="bytes" - ) - - def download_stream(self, file_id: str) -> Iterator[bytes]: - return self._http.stream_bytes(f"/v1/files/{file_id}/content") - - class AsyncFileVersions: def __init__(self, http: _AsyncHttpClient) -> None: self._http = http @@ -320,9 +103,14 @@ def list( next: str | None = None, include_total: bool = False, ) -> AsyncPager[File, Paginated[File]]: + # unasync: async-only (the sync twin's docstring follows) """List versions of a file. ``await`` the returned :class:`AsyncPager` for the first page, or ``async for`` it to stream every version across pages.""" + # unasync: end + """List versions of a file. Iterate the returned :class:`Pager` to + stream every version across pages, or call ``.page()`` for the + first page only.""" async def fetch(cursor: str | None) -> Paginated[File]: params: dict[str, Any] = { @@ -392,9 +180,19 @@ def list( tag: str | None = None, metadata: dict[str, str] | None = None, ) -> AsyncPager[File, Paginated[File]]: + # unasync: async-only (the sync twin's docstring follows) """List files. ``await`` the returned :class:`AsyncPager` for the first page, or ``async for`` it to stream every file across pages. + ``metadata`` narrows to files whose metadata holds every pair, each + matched exactly against the string value; it is sent as one repeated + ``metadata=key:value`` param per entry (at most 16). Keys are letters, + digits, ``_`` and ``-``. A server that predates the filter ignores it + and returns the unfiltered list.""" + # unasync: end + """List files. Iterate the returned :class:`Pager` to stream every + file across pages, or call ``.page()`` for the first page only. + ``metadata`` narrows to files whose metadata holds every pair, each matched exactly against the string value; it is sent as one repeated ``metadata=key:value`` param per entry (at most 16). Keys are letters, @@ -437,6 +235,7 @@ async def upload( metadata: dict[str, Any] | None = None, tags: builtins.list[str] | None = None, ) -> File: + # unasync: async-only (the sync twin's docstring follows) """Upload a binary file via multipart. ``tags`` and ``metadata`` are stamped when the request creates the @@ -449,6 +248,19 @@ async def upload( ... file_type="upload", ... ) """ + # unasync: end + """Upload a binary file via multipart. + + ``tags`` and ``metadata`` are stamped when the request creates the + file. A request that adds a new version to an existing file keeps + that file's tags; change them with :meth:`update`. + + Example: + >>> runner.files.upload( + ... file=Path("input.jsonl"), + ... file_type="upload", + ... ) + """ with _materialise_upload(file, name, content_type) as ( n, body, diff --git a/introspection_sdk/_async/runner_resources/metrics.py b/introspection_sdk/_async/runner_resources/metrics.py index 7617ef0..61646ff 100644 --- a/introspection_sdk/_async/runner_resources/metrics.py +++ b/introspection_sdk/_async/runner_resources/metrics.py @@ -1,33 +1,28 @@ -"""`runner.metrics.*` namespace: bounded telemetry aggregation. - -Bound to a :class:`~introspection_sdk.runner.Runner` — every call targets -the runner's DP endpoint with its short-lived JWT. Wraps the closed, -allow-listed ``POST /v1/metrics`` contract: one bounded, read-only aggregation over a -telemetry view. Not a list read — there is no cursor and no Arrow variant; -the request carries an explicit ``from_timestamp`` / ``to_timestamp`` window -and the response is a single :class:`MetricQueryResponse`. -""" +"""Re-exported by :mod:`introspection_sdk.runner_resources.metrics`.""" from __future__ import annotations from typing import Any -from introspection_sdk._http import _AsyncHttpClient, _HttpClient +from introspection_sdk._http import _AsyncHttpClient from introspection_sdk.schemas.metrics import ( MetricQueryRequest, MetricQueryResponse, ) -class Metrics: +class AsyncMetrics: """Read-only Metrics API (``POST /v1/metrics``).""" - def __init__(self, http: _HttpClient) -> None: + def __init__(self, http: _AsyncHttpClient) -> None: self._http = http - def query( + async def query( self, request: MetricQueryRequest | dict[str, Any] ) -> MetricQueryResponse: + # unasync: async-only (the sync twin's docstring follows) + """Execute one bounded metrics query. See :meth:`Metrics.query`.""" + # unasync: end """Execute one bounded metrics query. ``request`` is a :class:`MetricQueryRequest` (or an equivalent dict). @@ -40,24 +35,5 @@ def query( if isinstance(request, MetricQueryRequest) else request ) - payload = self._http.request("POST", "/v1/metrics", json=body) - return MetricQueryResponse.model_validate(payload) - - -class AsyncMetrics: - """Async twin of :class:`Metrics` (``POST /v1/metrics``).""" - - def __init__(self, http: _AsyncHttpClient) -> None: - self._http = http - - async def query( - self, request: MetricQueryRequest | dict[str, Any] - ) -> MetricQueryResponse: - """Execute one bounded metrics query. See :meth:`Metrics.query`.""" - body = ( - request.model_dump(mode="json", exclude_none=True) - if isinstance(request, MetricQueryRequest) - else request - ) payload = await self._http.request("POST", "/v1/metrics", json=body) return MetricQueryResponse.model_validate(payload) diff --git a/introspection_sdk/_async/runner_resources/shares.py b/introspection_sdk/_async/runner_resources/shares.py index 4eb88db..14587fd 100644 --- a/introspection_sdk/_async/runner_resources/shares.py +++ b/introspection_sdk/_async/runner_resources/shares.py @@ -1,26 +1,14 @@ -"""`runner.shares.*` namespace: sharing grants for files, issues and conversations. - -Bound to a :class:`~introspection_sdk.runner.Runner` — every call targets the -runner's DP endpoint with its short-lived JWT. ``create`` / ``list`` / ``get`` / -``update`` / ``delete`` manage grants. Shares are ambient: a shared resource -appears in the grantee's ordinary reads, and a grant's ``url`` is the plain -resource URL. To share with a cohort, create a share with ``granted_tag``. To -fork a new task from a shared conversation, pass ``fork_share_id`` to -``runner.tasks.create(...)``; the API refuses one whose share has -``visible_from`` set. -""" +"""Re-exported by :mod:`introspection_sdk.runner_resources.shares`.""" from __future__ import annotations from datetime import datetime from typing import Any -from introspection_sdk._http import _AsyncHttpClient, _HttpClient +from introspection_sdk._http import _AsyncHttpClient from introspection_sdk.pagination import ( AsyncPager, - Pager, async_cursor_paginate, - cursor_paginate, ) from introspection_sdk.schemas.pagination import Paginated from introspection_sdk.schemas.shares import ( @@ -85,106 +73,6 @@ def _update_body(visible_from: datetime | None) -> dict[str, Any]: ) -class Shares: - """Synchronous `/v1/shares` resource.""" - - def __init__(self, http: _HttpClient) -> None: - self._http = http - - def list( - self, - *, - limit: int = 100, - next: str | None = None, - resource_type: ShareResourceType | str | None = None, - resource_id: str | None = None, - granted_member_id: str | None = None, - granted_tag: str | None = None, - created_by_me: bool = False, - granted_to_me: bool = False, - ) -> Pager[ResourceShare, Paginated[ResourceShare]]: - """List grants the caller created or that name them. - - ``granted_to_me`` matches grants naming the caller's member, a tag the - caller's token carries, or both.""" - - def fetch(cursor: str | None) -> Paginated[ResourceShare]: - payload = self._http.request( - "GET", - "/v1/shares", - params=_list_params( - limit=limit, - cursor=cursor, - resource_type=resource_type, - resource_id=resource_id, - granted_member_id=granted_member_id, - granted_tag=granted_tag, - created_by_me=created_by_me, - granted_to_me=granted_to_me, - ), - ) - return Paginated[ResourceShare].model_validate(payload) - - return cursor_paginate(fetch, start=next) - - def create( - self, - *, - resource_type: ShareResourceType | str, - resource_id: str, - granted_member_id: str | None = None, - granted_tag: str | None = None, - visible_from: datetime | None = None, - ) -> ResourceShare: - """Create a grant. The caller must own the target resource. - - ``granted_member_id`` and ``granted_tag`` are ANDed; omit both for a - project-wide grant. A tag share also requires the caller to hold the - tag (or be an admin), and a duplicate live tag share raises - ``ConflictError``. A share admits; the grantee's token scopes decide - whether they may read, write or delete. ``visible_from`` - (conversations only, timezone-aware, not in the future) hides earlier - spans from callers the share alone admits.""" - payload = self._http.request( - "POST", - "/v1/shares", - json=_create_body( - resource_type=resource_type, - resource_id=resource_id, - granted_member_id=granted_member_id, - granted_tag=granted_tag, - visible_from=visible_from, - ), - ) - return ResourceShare.model_validate(payload) - - def get(self, share_id: str) -> ResourceShare: - payload = self._http.request("GET", f"/v1/shares/{share_id}") - return ResourceShare.model_validate(payload) - - def update( - self, - share_id: str, - *, - visible_from: datetime | None, - ) -> ResourceShare: - """Set or clear a conversation grant's ``visible_from``. - - ``visible_from=None`` clears the cutoff. The grantee cannot change; - revoke and create another to admit someone else. Only the grantor or - an admin may update a grant; anyone else gets ``NotFoundError``, and a - non-conversation share is a ``422``.""" - payload = self._http.request( - "PATCH", - f"/v1/shares/{share_id}", - json=_update_body(visible_from), - ) - return ResourceShare.model_validate(payload) - - def delete(self, share_id: str) -> None: - self._http.request("DELETE", f"/v1/shares/{share_id}", expect="empty") - - class AsyncShares: """Asynchronous `/v1/shares` resource.""" @@ -268,7 +156,12 @@ async def update( *, visible_from: datetime | None, ) -> ResourceShare: - """Async twin of :meth:`Shares.update`.""" + """Set or clear a conversation grant's ``visible_from``. + + ``visible_from=None`` clears the cutoff. The grantee cannot change; + revoke and create another to admit someone else. Only the grantor or + an admin may update a grant; anyone else gets ``NotFoundError``, and a + non-conversation share is a ``422``.""" payload = await self._http.request( "PATCH", f"/v1/shares/{share_id}", diff --git a/introspection_sdk/_async/runner_resources/tasks.py b/introspection_sdk/_async/runner_resources/tasks.py index bba5496..57a4694 100644 --- a/introspection_sdk/_async/runner_resources/tasks.py +++ b/introspection_sdk/_async/runner_resources/tasks.py @@ -1,25 +1,18 @@ -"""`runner.tasks.*` namespace: tasks + runs + cursor-style sugar. - -Bound to a :class:`~introspection_sdk.runner.Runner` — every call -targets the runner's DP endpoint with its short-lived JWT. -""" +"""Re-exported by :mod:`introspection_sdk.runner_resources.tasks`.""" from __future__ import annotations import builtins -from collections.abc import AsyncIterator, Iterator +from collections.abc import AsyncIterator from typing import Any from introspection_sdk._errors import RunFailedError, StreamIncompleteError -from introspection_sdk._http import _AsyncHttpClient, _HttpClient +from introspection_sdk._http import _AsyncHttpClient from introspection_sdk.pagination import ( AsyncPager, - Pager, async_cursor_paginate, - cursor_paginate, ) from introspection_sdk.resumable import ( - stream_resumable, stream_resumable_async, ) from introspection_sdk.schemas.agui import ( @@ -94,315 +87,8 @@ def _snapshot_text(event: MessagesSnapshotEvent) -> list[str]: ] -class RunHandle: - """Returned by ``Tasks.start(...)`` and ``TaskRuns.create(...)``. - - Mirrors the Cursor SDK shape: ``handle.stream()`` to iterate validated - AG-UI events, ``handle.text()`` to collect text deltas into a string, - ``handle.cancel()`` for the default abort behavior, with typed options for - explicit abort or drain cancellation. - """ - - def __init__( - self, - task: Task | None, - run: TaskRun, - runs: TaskRuns, - ) -> None: - self.task = task - self.run = run - self._runs = runs - - def stream( - self, - *, - max_reconnects: int = 5, - backoff: float = 0.5, - timeout: float = 300.0, - ) -> Iterator[AGUIEvent]: - """Stream this run's AG-UI events, reconnecting on a dropped - connection under the recovery contract in - :mod:`introspection_sdk.resumable`.""" - return self._runs.stream( - str(self.run.task_id), - self.run.id, - max_reconnects=max_reconnects, - backoff=backoff, - timeout=timeout, - ) - - def cancel( - self, - options: TaskCancelRequest | dict[str, Any] | None = None, - ) -> TaskCancelResponse: - return self._runs.cancel( - str(self.run.task_id), self.run.id, options=options - ) - - def text(self) -> str: - """Collect this run's assistant text from :meth:`stream`. - - Never returns partial output: raises ``RunFailedError`` on - ``RUN_ERROR`` and ``StreamIncompleteError`` wherever output may be - missing (a ``410``, a ``resume_gap``, or a run that settled without - the stream confirming it). A ``MESSAGES_SNAPSHOT`` replaces the text - collected so far with the snapshot's. The transcript is not read to - fill a gap, so streaming needs no ``conversations:read`` scope; read - ``runner.conversations`` yourself when you need the output after - such an error. - """ - out: list[str] = [] - for ev in self.stream(): - if isinstance(ev, RunErrorEvent): - raise RunFailedError(ev.message, code=ev.code) - if isinstance(ev, CustomEvent) and ev.name == "resume_gap": - raise StreamIncompleteError( - "The replay buffer lost output; read the conversation transcript" - ) - if isinstance(ev, MessagesSnapshotEvent): - out = _snapshot_text(ev) - elif isinstance( - ev, TextMessageContentEvent | TextMessageChunkEvent - ): - out.append(str(ev.delta or "")) - return "".join(out) - - -class TaskRuns: - def __init__(self, http: _HttpClient) -> None: - self._http = http - - def create( - self, - task_id: str, - *, - prompt: TaskPrompt | dict[str, Any] | None = None, - kind: TaskRunKind | str | None = None, - metadata: dict[str, Any] | None = None, - files: list[TaskFileRef | dict[str, Any]] | None = None, - ) -> RunHandle: - body: dict[str, Any] = {} - if prompt is not None: - body["prompt"] = ( - prompt.model_dump(exclude_none=True) - if isinstance(prompt, TaskPrompt) - else prompt - ) - if kind is not None: - body["kind"] = ( - kind.value if isinstance(kind, TaskRunKind) else kind - ) - if metadata is not None: - body["metadata"] = metadata - if files: - body["files"] = _file_refs(files) - payload = self._http.request( - "POST", f"/v1/tasks/{task_id}/runs", json=body - ) - res = TaskRunResponse.model_validate(payload) - return RunHandle(None, res.run, self) - - def resume( - self, - task_id: str, - *, - resume: list[ResumeEntry | dict[str, Any]], - ) -> RunHandle: - payload = self._http.request( - "POST", f"/v1/tasks/{task_id}/runs", json=_resume_body(resume) - ) - res = TaskRunResponse.model_validate(payload) - return RunHandle(None, res.run, self) - - def get(self, task_id: str, run_id: str) -> TaskRun: - payload = self._http.request( - "GET", f"/v1/tasks/{task_id}/runs/{run_id}" - ) - return TaskRun.model_validate(payload) - - def cancel( - self, - task_id: str, - run_id: str, - options: TaskCancelRequest | dict[str, Any] | None = None, - ) -> TaskCancelResponse: - if options is not None: - request = ( - options - if isinstance(options, TaskCancelRequest) - else TaskCancelRequest.model_validate(options) - ) - return self._cancel_with(task_id, run_id, request) - payload = self._http.request( - "POST", f"/v1/tasks/{task_id}/runs/{run_id}/cancel" - ) - return TaskCancelResponse.model_validate(payload) - - def _cancel_with( - self, task_id: str, run_id: str, request: TaskCancelRequest - ) -> TaskCancelResponse: - payload = self._http.request( - "POST", - f"/v1/tasks/{task_id}/runs/{run_id}/cancel", - json=request.model_dump(exclude_none=True, mode="json"), - ) - return TaskCancelResponse.model_validate(payload) - - def stream( - self, - task_id: str, - run_id: str, - *, - max_reconnects: int = 5, - backoff: float = 0.5, - timeout: float = 300.0, - ) -> Iterator[AGUIEvent]: - """Stream a run's AG-UI events, reconnecting on a dropped connection - under the recovery contract in :mod:`introspection_sdk.resumable`. - The keyword args bound recovery attempts.""" - return stream_resumable( - self._http, - task_id, - run_id, - max_reconnects=max_reconnects, - backoff=backoff, - timeout=timeout, - ) - - -class Tasks: - def __init__(self, http: _HttpClient) -> None: - self._http = http - self.runs = TaskRuns(http) - - def list( - self, - *, - limit: int = 100, - next: str | None = None, - include_total: bool = False, - statuses: builtins.list[str] | None = None, - require_automation_id: bool | None = None, - tag: str | None = None, - ) -> Pager[Task, Paginated[Task]]: - """List tasks. Iterate the returned :class:`Pager` to stream every - task across pages, or call ``.page()`` for the first page only.""" - - def fetch(cursor: str | None) -> Paginated[Task]: - params: dict[str, Any] = { - "limit": limit, - "next": cursor, - "include_total": include_total, - "tag": tag, - } - if statuses: - params["statuses"] = statuses - if require_automation_id is not None: - params["require_automation_id"] = require_automation_id - payload = self._http.request("GET", "/v1/tasks", params=params) - return Paginated[Task].model_validate(payload) - - return cursor_paginate(fetch, start=next) - - def create( - self, - *, - title: str | None = None, - prompt: str | None = None, - agent_name: str | None = None, - repositories: builtins.list[TaskRepoRequest | dict[str, Any]] - | None = None, - metadata: dict[str, Any] | None = None, - conversation_metadata: dict[str, str] | None = None, - idle_timeout_seconds: int | None = None, - fork_share_id: str | None = None, - files: builtins.list[TaskFileRef | dict[str, Any]] | None = None, - tags: builtins.list[str] | None = None, - ) -> TaskCreateResponse: - body: dict[str, Any] = { - "title": title, - "prompt": prompt, - "agent_name": agent_name, - "metadata": metadata, - "conversation_metadata": conversation_metadata, - "idle_timeout_seconds": idle_timeout_seconds, - "fork_share_id": fork_share_id, - "files": _file_refs(files) if files else None, - "repositories": _repo_refs(repositories) if repositories else None, - "tags": tags, - } - body = {k: v for k, v in body.items() if v is not None} - payload = self._http.request("POST", "/v1/tasks", json=body) - return TaskCreateResponse.model_validate(payload) - - def get(self, task_id: str) -> Task: - payload = self._http.request("GET", f"/v1/tasks/{task_id}") - return Task.model_validate(payload) - - def update( - self, - task_id: str, - *, - title: str | None = None, - is_archived: bool | None = None, - metadata: dict[str, Any] | None = None, - tags: builtins.list[str] | None = None, - ) -> Task: - body: dict[str, Any] = {} - if title is not None: - body["title"] = title - if is_archived is not None: - body["is_archived"] = is_archived - if metadata is not None: - body["metadata"] = metadata - if tags is not None: - # Replaces wholesale, so an explicit [] must reach the wire to - # clear — `if tags:` would silently drop it. - body["tags"] = tags - payload = self._http.request( - "PATCH", f"/v1/tasks/{task_id}", json=body - ) - return Task.model_validate(payload) - - def delete(self, task_id: str) -> None: - """Soft-delete a task. Requires ``tasks:delete`` scope. - - Note: dashboard-minted API keys (Tasks switch) do NOT grant - ``tasks:delete`` per the PR #678 scope model. Calls will return - ``IntrospectionAPIError(status_code=403)`` unless the caller - holds a wildcard or explicitly-elevated key. - """ - self._http.request("DELETE", f"/v1/tasks/{task_id}", expect="empty") - - def archive(self, task_id: str) -> None: - self._http.request( - "POST", f"/v1/tasks/{task_id}/archive", expect="empty" - ) - - def unarchive(self, task_id: str) -> None: - self._http.request( - "POST", f"/v1/tasks/{task_id}/unarchive", expect="empty" - ) - - def start(self, *, prompt: str, **kwargs: Any) -> RunHandle: - """Cursor-style sugar: create a task + return a handle on its initial run. - - Takes everything :meth:`create` takes, with ``prompt`` required — - ``start`` exists to stream a turn, and a task with no prompt has no - turn to stream. Forwarding ``**kwargs`` rather than restating the - parameters is deliberate: a hand-copied subset silently drops every - field added to ``create`` afterwards, which is how ``fork_share_id`` - and ``tags`` became unreachable from here. - - Example: - >>> run = runner.tasks.start(prompt="Summarize this repo") - >>> print(run.text()) - """ - res = self.create(prompt=prompt, **kwargs) - return RunHandle(res.task, res.run, self.runs) - - class AsyncRunHandle: + # unasync: async-only (the sync twin's docstring follows) """Async twin of :class:`RunHandle`. Returned by ``AsyncTasks.start(...)`` and ``AsyncTaskRuns.create(...)``. @@ -412,6 +98,15 @@ class AsyncRunHandle: behavior and accepts typed options for explicit abort or drain cancellation. """ + # unasync: end + """Returned by ``Tasks.start(...)`` and ``TaskRuns.create(...)``. + + Mirrors the Cursor SDK shape: ``handle.stream()`` to iterate validated + AG-UI events, ``handle.text()`` to collect text deltas into a string, + ``handle.cancel()`` for the default abort behavior, with typed options for + explicit abort or drain cancellation. + """ + def __init__( self, task: Task | None, @@ -566,8 +261,13 @@ def stream( backoff: float = 0.5, timeout: float = 300.0, ) -> AsyncIterator[AGUIEvent]: + # unasync: async-only (the sync twin's docstring follows) """Stream a run's AG-UI events with transparent resume — async twin of :meth:`TaskRuns.stream`.""" + # unasync: end + """Stream a run's AG-UI events, reconnecting on a dropped connection + under the recovery contract in :mod:`introspection_sdk.resumable`. + The keyword args bound recovery attempts.""" return stream_resumable_async( self._http, task_id, @@ -593,8 +293,12 @@ def list( require_automation_id: bool | None = None, tag: str | None = None, ) -> AsyncPager[Task, Paginated[Task]]: + # unasync: async-only (the sync twin's docstring follows) """List tasks. ``await`` the returned :class:`AsyncPager` for the first page, or ``async for`` it to stream every task across pages.""" + # unasync: end + """List tasks. Iterate the returned :class:`Pager` to stream every + task across pages, or call ``.page()`` for the first page only.""" async def fetch(cursor: str | None) -> Paginated[Task]: params: dict[str, Any] = { @@ -697,6 +401,7 @@ async def unarchive(self, task_id: str) -> None: ) async def start(self, *, prompt: str, **kwargs: Any) -> AsyncRunHandle: + # unasync: async-only (the sync twin's docstring follows) """Cursor-style sugar: create a task + return a handle on its initial run. @@ -711,5 +416,19 @@ async def start(self, *, prompt: str, **kwargs: Any) -> AsyncRunHandle: >>> run = await runner.tasks.start(prompt="Summarize this repo") >>> print(await run.text()) """ + # unasync: end + """Cursor-style sugar: create a task + return a handle on its initial run. + + Takes everything :meth:`create` takes, with ``prompt`` required — + ``start`` exists to stream a turn, and a task with no prompt has no + turn to stream. Forwarding ``**kwargs`` rather than restating the + parameters is deliberate: a hand-copied subset silently drops every + field added to ``create`` afterwards, which is how ``fork_share_id`` + and ``tags`` became unreachable from here. + + Example: + >>> run = runner.tasks.start(prompt="Summarize this repo") + >>> print(run.text()) + """ res = await self.create(prompt=prompt, **kwargs) return AsyncRunHandle(res.task, res.run, self.runs) diff --git a/introspection_sdk/_async/streaming.py b/introspection_sdk/_async/streaming.py index e97d35d..27b191c 100644 --- a/introspection_sdk/_async/streaming.py +++ b/introspection_sdk/_async/streaming.py @@ -1,87 +1,17 @@ -"""AG-UI event parsing over the task run SSE transport. - -The HTTP response still uses Server-Sent Events as a transport detail, but -the public SDK surface yields validated AG-UI protocol events only. -""" +"""Re-exported by :mod:`introspection_sdk.streaming`.""" from __future__ import annotations import json -from collections.abc import AsyncIterable, AsyncIterator, Iterable, Iterator -from dataclasses import dataclass, field -from typing import Any +from collections.abc import AsyncIterable, AsyncIterator +from introspection_sdk._streaming_common import ( + _SseAccumulator, + _SseEvent, +) from introspection_sdk.schemas.agui import AGUIEvent, validate_ag_ui_event -@dataclass(slots=True) -class _SseEvent: - event: str = "message" - data: str = "" - id: str | None = None - retry: int | None = None - _data_parts: list[str] = field(default_factory=list) - - def _finalize(self) -> _SseEvent: - self.data = "\n".join(self._data_parts) - return self - - -class _SseAccumulator: - """Incremental SSE line parser shared by the sync and async drivers. - - Feed wire lines one at a time via :meth:`feed`; it returns a - finalized SSE frame whenever a blank line closes a non-empty - event, else ``None``. Call :meth:`flush` once the stream ends to emit - any trailing event. - """ - - def __init__(self) -> None: - self._cur = _SseEvent() - self._has_content = False - - def feed(self, line: str) -> _SseEvent | None: - if line == "": - event = self._cur._finalize() if self._has_content else None - self._cur = _SseEvent() - self._has_content = False - return event - if line.startswith(":"): - return None - field_name, _, value = line.partition(":") - if value.startswith(" "): - value = value[1:] - if field_name == "event": - self._cur.event = value - self._has_content = True - elif field_name == "data": - self._cur._data_parts.append(value) - self._has_content = True - elif field_name == "id": - self._cur.id = value - self._has_content = True - elif field_name == "retry": - try: - self._cur.retry = int(value) - except ValueError: - pass - return None - - def flush(self) -> _SseEvent | None: - return self._cur._finalize() if self._has_content else None - - -def _parse_sse(lines: Iterable[str]) -> Iterator[_SseEvent]: - acc = _SseAccumulator() - for line in lines: - event = acc.feed(line) - if event is not None: - yield event - tail = acc.flush() - if tail is not None: - yield tail - - async def _parse_sse_async( lines: AsyncIterable[str], ) -> AsyncIterator[_SseEvent]: @@ -95,24 +25,15 @@ async def _parse_sse_async( yield tail -def parse_ag_ui_events(lines: Iterable[str]) -> Iterator[AGUIEvent]: +async def parse_ag_ui_events_async( + lines: AsyncIterable[str], +) -> AsyncIterator[AGUIEvent]: """Yield AG-UI events from ``event: ag_ui`` SSE frames. Non-AG-UI transport frames, such as heartbeats, are ignored. """ - for event in _parse_sse(lines): - if event.event != "ag_ui": - continue - payload = json.loads(event.data) - yield validate_ag_ui_event(payload) - - -async def parse_ag_ui_events_async( - lines: AsyncIterable[str], -) -> AsyncIterator[AGUIEvent]: - """Async twin of :func:`parse_ag_ui_events`.""" async for event in _parse_sse_async(lines): if event.event != "ag_ui": continue - payload: Any = json.loads(event.data) + payload = json.loads(event.data) yield validate_ag_ui_event(payload) diff --git a/introspection_sdk/_auth_common.py b/introspection_sdk/_auth_common.py new file mode 100644 index 0000000..9bf3ee6 --- /dev/null +++ b/introspection_sdk/_auth_common.py @@ -0,0 +1,225 @@ +"""Code shared by the sync and async twins in :mod:`introspection_sdk.auth`.""" + +from __future__ import annotations + +import base64 +import binascii +import json +import os +from collections.abc import Callable +from datetime import UTC, datetime, timedelta +from typing import Any + +from pydantic import BaseModel, ConfigDict + +from introspection_sdk._errors import ( + AuthenticationError, +) + +_DEFAULT_BASE_API_URL = "https://api.introspection.dev" + + +class OAuthToken(BaseModel): + """CP ``POST /v1/oauth/token`` response. + + The machine and federated grants issue no refresh token — re-mint (call + the helper again) once it expires. ``email_code`` and ``refresh_token`` + return the refresh token plus the ``session_id`` / ``org_id`` that key the + next refresh. Additional wire fields are preserved but left unmodelled. + """ + + model_config = ConfigDict(extra="allow") + + #: Project-scoped RS256 access token (``Authorization: Bearer …``). + access_token: str + #: Always ``"Bearer"``. + token_type: str = "Bearer" + #: Token lifetime in seconds. + expires_in: int + #: The granted (scope-capped) scope, when the CP returns one. + scope: str | None = None + #: Data Plane API base URL for the token's project, resolved by the + #: CP. ``None`` when no deployment resolves; the caller then needs an + #: explicit DP URL. Hand this to a browser client so it needs no + #: separate Data Plane configuration. + dp_url: str | None = None + #: Rotating refresh token; set only by the session-backed grants. + refresh_token: str | None = None + #: The CP session row (the access token's ``jti``). + session_id: str | None = None + org_id: str | None = None + project_id: str | None = None + member_id: str | None = None + + +def _resolve_base_api_url(base_api_url: str | None) -> str: + resolved = base_api_url or os.getenv( + "INTROSPECTION_BASE_API_URL", _DEFAULT_BASE_API_URL + ) + return resolved.rstrip("/") + + +def _jwt_claims(token: str) -> dict[str, Any]: + """The payload of a JWT, decoded WITHOUT verifying it; ``{}`` if not one.""" + segments = token.split(".") + if len(segments) < 2: + return {} + padded = segments[1] + "=" * (-len(segments[1]) % 4) + try: + claims = json.loads(base64.urlsafe_b64decode(padded)) + except (ValueError, binascii.Error): + return {} + return claims if isinstance(claims, dict) else {} + + +def _claim(claims: dict[str, Any], name: str) -> str | None: + value = claims.get(name) + return value if isinstance(value, str) else None + + +class AuthSession(BaseModel): + """A signed-in member's session: the access token plus what renews it. + + Serializable (``model_dump_json`` / ``model_validate_json``) so an app can + keep it between runs and hand it back as ``EmailCodeAuth(session=...)``. + Treat it as a secret: it holds the refresh token. + """ + + model_config = ConfigDict(extra="allow") + + access_token: str + expires_at: datetime | None = None + #: Rotated by every refresh. + refresh_token: str | None = None + #: The CP session row (the access token's ``jti``). + session_id: str | None = None + org_id: str | None = None + project_id: str | None = None + member_id: str | None = None + #: The Data Plane URL returned at sign-in. + dp_url: str | None = None + scope: str | None = None + + @classmethod + def from_token( + cls, + token: OAuthToken, + *, + received_at: datetime, + previous: AuthSession | None = None, + ) -> AuthSession: + """From a token response; missing ids come from the JWT, then ``previous``.""" + claims = _jwt_claims(token.access_token) + return cls( + access_token=token.access_token, + expires_at=received_at + timedelta(seconds=token.expires_in), + refresh_token=token.refresh_token + or (previous.refresh_token if previous else None), + session_id=token.session_id + or _claim(claims, "jti") + or (previous.session_id if previous else None), + org_id=token.org_id + or _claim(claims, "org_id") + or (previous.org_id if previous else None), + project_id=token.project_id + or _claim(claims, "project_id") + or (previous.project_id if previous else None), + member_id=token.member_id + or _claim(claims, "member_id") + or (previous.member_id if previous else None), + dp_url=token.dp_url or (previous.dp_url if previous else None), + scope=token.scope or (previous.scope if previous else None), + ) + + +class SignInSupersededError(Exception): + """A sign-in or refresh response arrived after a newer sign-in or a + sign-out, so it was discarded rather than overwrite the newer state.""" + + +def _utcnow() -> datetime: + return datetime.now(UTC) + + +SessionListener = Callable[["AuthSession | None"], None] + + +class _EmailCodeAuthBase: + def __init__( + self, + client_id: str, + project: str, + *, + session: AuthSession | None = None, + base_api_url: str | None = None, + leeway: float = 60.0, + on_session_change: SessionListener | None = None, + clock: Callable[[], datetime] = _utcnow, + ) -> None: + self.client_id = client_id + self.project = project + self.base_api_url = _resolve_base_api_url(base_api_url) + self.leeway = leeway + self._session = session + self._generation = 0 + self._on_session_change = on_session_change + self._clock = clock + + @property + def current_session(self) -> AuthSession | None: + """The session as held, without refreshing it.""" + return self._session + + def _expiring(self, session: AuthSession) -> bool: + return ( + session.expires_at is not None + and session.expires_at - timedelta(seconds=self.leeway) + <= self._clock() + ) + + def _refresh_key(self, session: AuthSession) -> tuple[str, str, str]: + if not ( + session.refresh_token and session.session_id and session.org_id + ): + raise AuthenticationError( + "The session has no refresh token", status_code=0 + ) + return session.refresh_token, session.session_id, session.org_id + + def _adopt(self, token: OAuthToken, expected: int | None) -> AuthSession: + """Install a sign-in response unless a newer one superseded it.""" + if expected is not None and self._generation != expected: + raise SignInSupersededError( + "A sign-out or another sign-in completed first" + ) + self._generation += 1 + self._session = AuthSession.from_token( + token, received_at=self._clock() + ) + self._notify(self._session) + return self._session + + def _replace( + self, previous: AuthSession, token: OAuthToken, generation: int + ) -> AuthSession: + if self._generation != generation: + raise SignInSupersededError( + "A sign-out or sign-in completed during the refresh" + ) + self._session = AuthSession.from_token( + token, received_at=self._clock(), previous=previous + ) + self._notify(self._session) + return self._session + + def _clear(self) -> AuthSession | None: + previous = self._session + self._generation += 1 + self._session = None + if previous is not None: + self._notify(None) + return previous + + def _notify(self, session: AuthSession | None) -> None: + if self._on_session_change is not None: + self._on_session_change(session) diff --git a/introspection_sdk/_backoff.py b/introspection_sdk/_backoff.py index df4217b..c9cec06 100644 --- a/introspection_sdk/_backoff.py +++ b/introspection_sdk/_backoff.py @@ -20,7 +20,9 @@ from __future__ import annotations +import asyncio import random +import time #: Cap on the exponential/jitter component of a retry backoff (seconds). #: Deliberately not a cap on a server-supplied ``Retry-After``. @@ -59,3 +61,13 @@ def _is_retryable_status(status: int, idempotent: bool) -> bool: if status == 429: return True return idempotent and status in _IDEMPOTENT_RETRY_STATUSES + + +# The sleep between retries, one per twin: the generated sync client calls +# ``_sleep`` where its async source calls ``_sleep_async``. +def _sleep(seconds: float) -> None: + time.sleep(seconds) + + +async def _sleep_async(seconds: float) -> None: + await asyncio.sleep(seconds) diff --git a/introspection_sdk/_http_common.py b/introspection_sdk/_http_common.py new file mode 100644 index 0000000..720d8fa --- /dev/null +++ b/introspection_sdk/_http_common.py @@ -0,0 +1,20 @@ +"""Code shared by the sync and async twins in :mod:`introspection_sdk._http`.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass + + +@dataclass(frozen=True) +class RawResponse: + """A raw response body plus headers, returned by ``request(expect="raw")``. + + Used by the Arrow list-read path, where the row values arrive in the + columnar body and pagination metadata (``X-Next-Cursor`` / + ``X-Result-Count`` / ``X-Truncated`` / ``X-Total-Count``) moves to + response headers. Keeps ``httpx`` off the resource layer's surface. + """ + + content: bytes + headers: Mapping[str, str] diff --git a/introspection_sdk/_streaming_common.py b/introspection_sdk/_streaming_common.py new file mode 100644 index 0000000..9049b8d --- /dev/null +++ b/introspection_sdk/_streaming_common.py @@ -0,0 +1,62 @@ +"""Code shared by the sync and async twins in :mod:`introspection_sdk.streaming`.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass(slots=True) +class _SseEvent: + event: str = "message" + data: str = "" + id: str | None = None + retry: int | None = None + _data_parts: list[str] = field(default_factory=list) + + def _finalize(self) -> _SseEvent: + self.data = "\n".join(self._data_parts) + return self + + +class _SseAccumulator: + """Incremental SSE line parser shared by the sync and async drivers. + + Feed wire lines one at a time via :meth:`feed`; it returns a + finalized SSE frame whenever a blank line closes a non-empty + event, else ``None``. Call :meth:`flush` once the stream ends to emit + any trailing event. + """ + + def __init__(self) -> None: + self._cur = _SseEvent() + self._has_content = False + + def feed(self, line: str) -> _SseEvent | None: + if line == "": + event = self._cur._finalize() if self._has_content else None + self._cur = _SseEvent() + self._has_content = False + return event + if line.startswith(":"): + return None + field_name, _, value = line.partition(":") + if value.startswith(" "): + value = value[1:] + if field_name == "event": + self._cur.event = value + self._has_content = True + elif field_name == "data": + self._cur._data_parts.append(value) + self._has_content = True + elif field_name == "id": + self._cur.id = value + self._has_content = True + elif field_name == "retry": + try: + self._cur.retry = int(value) + except ValueError: + pass + return None + + def flush(self) -> _SseEvent | None: + return self._cur._finalize() if self._has_content else None diff --git a/introspection_sdk/resources/_runtimes_common.py b/introspection_sdk/resources/_runtimes_common.py new file mode 100644 index 0000000..6d869b2 --- /dev/null +++ b/introspection_sdk/resources/_runtimes_common.py @@ -0,0 +1,214 @@ +"""Code shared by the sync and async twins in :mod:`introspection_sdk.resources.runtimes`. + +:class:`RuntimeHandle` and :class:`AsyncRuntimeHandle` are written by hand +rather than generated: ``runtime_id`` is a property on one and a coroutine +on the other, which unasync cannot express. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any +from uuid import UUID + +from introspection_sdk.runner import AsyncRunner, Runner +from introspection_sdk.schemas.runner import ( + RunCaller, + RunnerIdentity, + RunnerSpec, + RunRequest, +) + +if TYPE_CHECKING: + from introspection_sdk.resources.runtimes import AsyncRuntimes, Runtimes + + +class RuntimeHandle: + """Handle for a specific runtime slug or runtime group id. + + Holds the stable selector and resolves it on each use. ``run()`` posts a + slug straight to ``/v1/runtimes/{slug}/run``, which resolves it in the + caller's project, so a credential refused the list (a ``customer`` + signed in by email code) can still open a runner. A runtime group id has + no such route and is resolved by listing on the caller's project. Built + by ``client.runtimes(runtime)``. + + The resolved row id is deliberately not cached. A runtime group's versions + change underneath a long-lived handle: deploys add them, and yanking or + deleting retires them. A handle that remembered its first answer went on + using a version that could since have been withdrawn, so every later + ``run()`` failed with a 404 no caller could clear short of rebuilding the + handle. Holding the selector instead costs one lookup per use and always + reaches whatever the group currently serves. + """ + + def __init__( + self, + runtimes: Runtimes, + *, + runtime: str | UUID, + project: str | None, + resolved: bool = False, + ) -> None: + self._runtimes = runtimes + self._project = project + self._raw = runtime + self._resolved = resolved + + @property + def runtime_id(self) -> UUID: + return self._resolve() + + def _resolve(self) -> UUID: + if self._resolved: + return UUID(str(self._raw)) + return self._runtimes.resolve(str(self._raw), project=self._project).id + + def _run_target(self) -> UUID | str: + if self._resolved or _is_uuid(self._raw): + return self._resolve() + return str(self._raw) + + def run( + self, + *, + identity: RunnerIdentity | dict[str, Any] | None = None, + caller: RunCaller | dict[str, Any] | None = None, + agent_name: str | None = None, + ttl_seconds: int | None = 3600, + scope: str | None = None, + ) -> Runner: + ident: RunnerIdentity | None + if identity is None: + ident = None + elif isinstance(identity, RunnerIdentity): + ident = identity + else: + ident = RunnerIdentity.model_validate(identity) + call: RunCaller | None + if caller is None: + call = None + elif isinstance(caller, RunCaller): + call = caller + else: + call = RunCaller.model_validate(caller) + options = RunRequest( + identity=ident, + caller=call, + agent_name=agent_name, + ttl_seconds=ttl_seconds, + scope=scope, + ) + target = self._run_target() + + def refresher() -> RunnerSpec: + return self._runtimes._post_run( + target, options, project=self._project + ) + + spec = refresher() + return Runner( + spec, + refresher=refresher, + additional_headers=self._runtimes._additional_headers, + ) + + +class AsyncRuntimeHandle: + """Async twin of :class:`RuntimeHandle`. + + Holds the stable selector and resolves it on each use: ``run()`` posts a + slug straight to ``/v1/runtimes/{slug}/run`` and lists only to resolve a + runtime group id. Built by ``client.runtimes(runtime)``. See + :class:`RuntimeHandle` for why the resolved id is not cached. + """ + + def __init__( + self, + runtimes: AsyncRuntimes, + *, + runtime: str | UUID, + project: str | None, + resolved: bool = False, + ) -> None: + self._runtimes = runtimes + self._project = project + self._raw = runtime + self._resolved = resolved + + async def runtime_id(self) -> UUID: + """Resolve the concrete runtime id this handle currently serves. + + The async twin of :attr:`RuntimeHandle.runtime_id`. A coroutine + rather than a property because resolving costs a request, and a + property cannot be awaited. Without it the async client had no + public way to read the id a handle resolves to — the broker case + the sync property exists for. + """ + return await self._resolve() + + async def _resolve(self) -> UUID: + if self._resolved: + return UUID(str(self._raw)) + resolved = await self._runtimes.resolve( + str(self._raw), project=self._project + ) + return resolved.id + + async def _run_target(self) -> UUID | str: + if self._resolved or _is_uuid(self._raw): + return await self._resolve() + return str(self._raw) + + async def run( + self, + *, + identity: RunnerIdentity | dict[str, Any] | None = None, + caller: RunCaller | dict[str, Any] | None = None, + agent_name: str | None = None, + ttl_seconds: int | None = 3600, + scope: str | None = None, + ) -> AsyncRunner: + ident: RunnerIdentity | None + if identity is None: + ident = None + elif isinstance(identity, RunnerIdentity): + ident = identity + else: + ident = RunnerIdentity.model_validate(identity) + call: RunCaller | None + if caller is None: + call = None + elif isinstance(caller, RunCaller): + call = caller + else: + call = RunCaller.model_validate(caller) + options = RunRequest( + identity=ident, + caller=call, + agent_name=agent_name, + ttl_seconds=ttl_seconds, + scope=scope, + ) + target = await self._run_target() + + async def refresher() -> RunnerSpec: + return await self._runtimes._post_run( + target, options, project=self._project + ) + + spec = await refresher() + return AsyncRunner( + spec, + refresher=refresher, + additional_headers=self._runtimes._additional_headers, + ) + + +def _is_uuid(selector: str | UUID) -> bool: + if isinstance(selector, UUID): + return True + try: + UUID(selector) + except ValueError: + return False + return True diff --git a/introspection_sdk/runner_resources/_conversations_common.py b/introspection_sdk/runner_resources/_conversations_common.py new file mode 100644 index 0000000..ef0cd87 --- /dev/null +++ b/introspection_sdk/runner_resources/_conversations_common.py @@ -0,0 +1,17 @@ +"""Code shared by the sync and async twins in :mod:`introspection_sdk.runner_resources.conversations`.""" + +from __future__ import annotations + +from datetime import datetime +from typing import TypedDict + + +class ConversationExportParams(TypedDict, total=False): + """Filters accepted by the complete conversation export route.""" + + agent: str + service_name: str + operation_name: str + lookback_days: int + start_date: str | datetime + end_date: str | datetime diff --git a/introspection_sdk/runner_resources/_events_common.py b/introspection_sdk/runner_resources/_events_common.py new file mode 100644 index 0000000..548018b --- /dev/null +++ b/introspection_sdk/runner_resources/_events_common.py @@ -0,0 +1,29 @@ +"""Code shared by the sync and async twins in :mod:`introspection_sdk.runner_resources.events`.""" + +from __future__ import annotations + +import logging +from typing import Any + +# The logger of the public module, so log records keep their old name. +_logger = logging.getLogger("introspection_sdk.runner_resources.events") + + +class UnknownEventCounter: + """Counts rows skipped because their family is unknown to this SDK.""" + + def __init__(self) -> None: + self.count = 0 + + def increment(self, event_name: Any) -> None: + self.count += 1 + _logger.debug( + "skipping event row with unknown event_name %r " + "(not in this SDK's typed family set; total skipped: %d)", + event_name, + self.count, + ) + + +#: Process-wide skip counter for unknown-family rows (observability hook). +UNKNOWN_EVENT_SKIPS = UnknownEventCounter() From 0eea1c38dfe4c45f12747b504a0a36eb1c7611cc Mon Sep 17 00:00:00 2001 From: Marcus Schiesser Date: Wed, 21 Oct 2026 19:20:46 +0700 Subject: [PATCH 3/5] refactor: re-export both twins from the public modules Restore each public module at its old path as a stub that re-exports the async twin from _async/ and the sync twin from _sync/ (and __all__), so every import path and public name stays as it was. Co-Authored-By: Claude Opus 5.5 --- introspection_sdk/_http.py | 36 ++ introspection_sdk/auth.py | 487 ++++++++++++++++++ introspection_sdk/client.py | 26 + introspection_sdk/protocols.py | 72 +++ introspection_sdk/proxy/_transport.py | 23 + introspection_sdk/proxy/httpx.py | 12 + introspection_sdk/proxy/httpx2.py | 12 + introspection_sdk/resources/connectors.py | 31 ++ introspection_sdk/resources/experiments.py | 28 + introspection_sdk/resources/members.py | 28 + introspection_sdk/resources/recipes.py | 21 + introspection_sdk/resources/repositories.py | 36 ++ introspection_sdk/resources/runtimes.py | 31 ++ introspection_sdk/resources/tags.py | 12 + introspection_sdk/runner.py | 28 + introspection_sdk/runner_resources/_reads.py | 67 +++ .../runner_resources/automations.py | 36 ++ .../runner_resources/connections.py | 33 ++ .../runner_resources/conversations.py | 51 ++ introspection_sdk/runner_resources/events.py | 66 +++ introspection_sdk/runner_resources/files.py | 28 + introspection_sdk/runner_resources/metrics.py | 23 + introspection_sdk/runner_resources/shares.py | 25 + introspection_sdk/runner_resources/tasks.py | 27 + introspection_sdk/streaming.py | 23 + 25 files changed, 1262 insertions(+) create mode 100644 introspection_sdk/_http.py create mode 100644 introspection_sdk/auth.py create mode 100644 introspection_sdk/client.py create mode 100644 introspection_sdk/protocols.py create mode 100644 introspection_sdk/proxy/_transport.py create mode 100644 introspection_sdk/proxy/httpx.py create mode 100644 introspection_sdk/proxy/httpx2.py create mode 100644 introspection_sdk/resources/connectors.py create mode 100644 introspection_sdk/resources/experiments.py create mode 100644 introspection_sdk/resources/members.py create mode 100644 introspection_sdk/resources/recipes.py create mode 100644 introspection_sdk/resources/repositories.py create mode 100644 introspection_sdk/resources/runtimes.py create mode 100644 introspection_sdk/resources/tags.py create mode 100644 introspection_sdk/runner.py create mode 100644 introspection_sdk/runner_resources/_reads.py create mode 100644 introspection_sdk/runner_resources/automations.py create mode 100644 introspection_sdk/runner_resources/connections.py create mode 100644 introspection_sdk/runner_resources/conversations.py create mode 100644 introspection_sdk/runner_resources/events.py create mode 100644 introspection_sdk/runner_resources/files.py create mode 100644 introspection_sdk/runner_resources/metrics.py create mode 100644 introspection_sdk/runner_resources/shares.py create mode 100644 introspection_sdk/runner_resources/tasks.py create mode 100644 introspection_sdk/streaming.py diff --git a/introspection_sdk/_http.py b/introspection_sdk/_http.py new file mode 100644 index 0000000..0f6c2ea --- /dev/null +++ b/introspection_sdk/_http.py @@ -0,0 +1,36 @@ +"""Small HTTP clients used by the REST API namespaces. + +:class:`_HttpClient` wraps ``httpx.Client`` and :class:`_AsyncHttpClient` +wraps ``httpx.AsyncClient``; both centralise base-URL joining, +``Authorization`` header injection, error translation, and SSE +streaming. Kept private so the public surface stays the REST namespace +classes themselves. + +``httpx`` throughout this package is ``httpx2`` under an alias --- the +Pydantic-maintained successor, which the OpenAI, Anthropic and MCP SDKs are +also built on. Callers passing their own ``transport=`` must pass an httpx2 +one; see docs/advanced.md. The two clients are kept deliberately symmetric so +the sync and async resource namespaces can mirror each other line for +line. +""" + +from __future__ import annotations + +from introspection_sdk._async._http import ( + DEFAULT_MAX_RETRIES, + DEFAULT_RETRY_BASE, +) +from introspection_sdk._async._http import _AsyncHttpClient as _AsyncHttpClient +from introspection_sdk._async._http import ( + _clean_params as _clean_params, +) +from introspection_sdk._http_common import ( + RawResponse, +) +from introspection_sdk._sync._http import _HttpClient as _HttpClient + +__all__ = [ + "DEFAULT_MAX_RETRIES", + "DEFAULT_RETRY_BASE", + "RawResponse", +] diff --git a/introspection_sdk/auth.py b/introspection_sdk/auth.py new file mode 100644 index 0000000..295ad62 --- /dev/null +++ b/introspection_sdk/auth.py @@ -0,0 +1,487 @@ +"""OAuth helpers for every Application type, plus native email-code sign-in. + +These mint a short-lived, project-scoped Introspection access token from +the Control Plane ``POST /v1/oauth/token`` endpoint, so callers no longer +hand-roll a form-encoded token POST. An Application has exactly one type, +and each type has one way in: + +* ``service_account`` — :func:`service_account_token`, the OAuth 2.0 + ``client_credentials`` grant for a confidential machine client. The + headless counterpart to a long-lived API key: the ``client_id`` / + ``client_secret`` stay server-side and you re-mint when the token + expires (no refresh token is issued). +* ``jwks`` — :func:`token_exchange`, RFC 8693 token-exchange: trade an end + user's JWT from your own identity provider for a project-scoped access + token for a federated ``customer`` member. No refresh token is issued; + exchange again before it expires. +* ``spa`` — :func:`authorization_code_token`, the RFC 6749 / PKCE + ``authorization_code`` exchange for the hosted-login callback. An ``spa`` + with a brokered identity provider also exchanges that login's + ``id_token`` through :func:`token_exchange`. +* ``native`` — email-code sign-in for a ``customer`` member: + :class:`EmailCodeAuth` / :class:`AsyncEmailCodeAuth` send the code, + verify it, and keep the refreshable session current. The one-shot + calls underneath are :func:`send_email_code`, :func:`email_code_token`, + :func:`refresh_access_token` and :func:`revoke_session`. + +A token issued to an end user (``jwks``, ``spa``, ``native``) carries at +most the Application's ``allowed_scopes``. + +Every token helper returns the shared :class:`OAuthToken` shape, which +carries ``dp_url`` — the Data Plane endpoint the CP resolved for the +token's project — so a client needs no separately configured Data Plane +URL. + +Each function has an ``async_`` twin (:func:`async_service_account_token` +etc.) for use from :class:`~introspection_sdk.AsyncIntrospectionClient` +and other ``asyncio`` callers. A ``service_account`` token is an ordinary +CP bearer token, so it drops straight into +:class:`~introspection_sdk.IntrospectionClient`, or use +:meth:`IntrospectionClient.from_service_account` to mint and construct in +one call. A ``native`` email-code token is a Data Plane credential: +Control Plane routes reject it. +""" + +from __future__ import annotations + +import asyncio +import threading +from collections.abc import Callable +from datetime import datetime +from typing import TYPE_CHECKING + +import httpx2 as httpx + +from introspection_sdk._async.auth import ( + GRANT_EMAIL_CODE, + AsyncCredentialProvider, + async_authorization_code_token, + async_email_code_token, + async_refresh_access_token, + async_revoke_session, + async_send_email_code, + async_service_account_token, + async_token_exchange, +) +from introspection_sdk._auth_common import ( + AuthSession, + OAuthToken, + SessionListener, + SignInSupersededError, + _EmailCodeAuthBase, + _utcnow, +) +from introspection_sdk._errors import ( + AuthenticationError, + IntrospectionAPIError, + ValidationError, +) +from introspection_sdk._sync.auth import ( + CredentialProvider, + authorization_code_token, + email_code_token, + refresh_access_token, + revoke_session, + send_email_code, + service_account_token, + token_exchange, +) + +if TYPE_CHECKING: + from introspection_sdk.client import ( + AsyncIntrospectionClient, + IntrospectionClient, + ) + +__all__ = [ + "GRANT_EMAIL_CODE", + "AsyncCredentialProvider", + "AsyncEmailCodeAuth", + "AuthSession", + "CredentialProvider", + "EmailCodeAuth", + "OAuthToken", + "SignInSupersededError", + "async_authorization_code_token", + "async_email_code_token", + "async_refresh_access_token", + "async_revoke_session", + "async_send_email_code", + "async_service_account_token", + "async_token_exchange", + "authorization_code_token", + "email_code_token", + "refresh_access_token", + "revoke_session", + "send_email_code", + "service_account_token", + "token_exchange", +] + + +def _is_rejection(error: IntrospectionAPIError) -> bool: + """The server refused the session, as opposed to a failure worth retrying.""" + return ( + isinstance(error, AuthenticationError | ValidationError) + or error.status_code == 403 + or error.code == "invalid_grant" + ) + + +def _rejected(error: IntrospectionAPIError) -> AuthenticationError: + return AuthenticationError( + f"The session is no longer valid: {error}", + status_code=error.status_code, + code=error.code, + request_id=error.request_id, + body=error.body, + ) + + +def _not_signed_in() -> AuthenticationError: + return AuthenticationError("Not signed in", status_code=0) + + +# EmailCodeAuth and AsyncEmailCodeAuth are written by hand rather than +# generated from one async source: the sync twin guards its state with +# thread locks that the async twin, on one event loop, does not take. +class EmailCodeAuth(_EmailCodeAuthBase): + """Native email-code sign-in for a ``native`` Application's end users. + + Send a code, verify it, and use the session: the access token is renewed + ``leeway`` seconds before it expires and after a ``401``, with concurrent + callers sharing one refresh. A sign-in or refresh response that arrives + after a newer sign-in or a sign-out is dropped and raises + :class:`SignInSupersededError`:: + + auth = EmailCodeAuth(client_id="intro_app_…", project="ark") + auth.send_code("user@example.com") + auth.verify_code("user@example.com", input("Code: ")) + client = auth.client() + + Pass ``session=`` to restore a session kept from an earlier run and + ``on_session_change`` to persist every change (``None`` on sign-out). + The token is a Data Plane credential for a ``customer`` member scoped to + the Application's ``allowed_scopes``: Control Plane routes reject it. + """ + + def __init__( + self, + client_id: str, + project: str, + *, + session: AuthSession | None = None, + base_api_url: str | None = None, + leeway: float = 60.0, + on_session_change: SessionListener | None = None, + transport: httpx.BaseTransport | None = None, + clock: Callable[[], datetime] = _utcnow, + ) -> None: + super().__init__( + client_id, + project, + session=session, + base_api_url=base_api_url, + leeway=leeway, + on_session_change=on_session_change, + clock=clock, + ) + self.transport = transport + self._state = threading.RLock() + self._refreshing = threading.Lock() + + def send_code(self, email: str) -> None: + """Email a one-time code to ``email``.""" + send_email_code( + email, + self.client_id, + self.project, + base_api_url=self.base_api_url, + transport=self.transport, + ) + + def verify_code(self, email: str, code: str) -> AuthSession: + """Verify the code sent to ``email`` and sign in.""" + with self._state: + generation = self._generation + token = email_code_token( + email, + code, + self.client_id, + self.project, + base_api_url=self.base_api_url, + transport=self.transport, + ) + with self._state: + return self._adopt(token, generation) + + def set_session(self, token: OAuthToken | AuthSession) -> AuthSession: + """Adopt a session obtained elsewhere.""" + with self._state: + if isinstance(token, OAuthToken): + return self._adopt(token, None) + self._generation += 1 + self._session = token + self._notify(token) + return token + + def get_session(self) -> AuthSession | None: + """The current session, refreshed first when within ``leeway`` of + expiry; ``None`` when signed out.""" + session = self._session + if session is None: + return None + if self._expiring(session): + return self.refresh() + return session + + def refresh(self) -> AuthSession: + """Renew the session now, or wait for the renewal in flight. A + refresh the server rejects signs out and raises + :class:`~introspection_sdk.AuthenticationError`.""" + with self._state: + session, generation = self._session, self._generation + if session is None: + raise _not_signed_in() + with self._refreshing: + with self._state: + if self._generation != generation: + raise SignInSupersededError( + "A sign-out or sign-in completed during the refresh" + ) + if self._session is not session and self._session is not None: + return self._session + refresh_token, session_id, org_id = self._refresh_key(session) + try: + token = refresh_access_token( + refresh_token, + self.client_id, + session_id, + org_id, + base_api_url=self.base_api_url, + transport=self.transport, + ) + except IntrospectionAPIError as exc: + if not _is_rejection(exc): + raise + with self._state: + if self._generation != generation: + raise SignInSupersededError( + "A sign-out or sign-in completed during the refresh" + ) from exc + self._clear() + raise _rejected(exc) from exc + with self._state: + return self._replace(session, token, generation) + + def sign_out(self) -> None: + """Forget the session, then revoke it on the Control Plane. The local + session is cleared even when revocation fails.""" + with self._state: + session = self._clear() + if session is None or session.refresh_token is None: + return + if session.session_id and session.org_id: + revoke_session( + session.session_id, + session.org_id, + self.client_id, + base_api_url=self.base_api_url, + transport=self.transport, + ) + + def authorization(self) -> str | None: + session = self.get_session() + return f"Bearer {session.access_token}" if session else None + + def refresh_after_unauthorized(self, rejected: str | None) -> bool: + session = self.get_session() + if session is None: + return False + if ( + rejected is not None + and rejected != f"Bearer {session.access_token}" + ): + return True + self.refresh() + return True + + def client( + self, + *, + dp_url: str | None = None, + additional_headers: dict[str, str] | None = None, + ) -> IntrospectionClient: + """A client authenticated as the signed-in member, pointed at the + session's ``dp_url`` unless ``dp_url`` overrides it. Only its Data + Plane namespaces accept the token.""" + # Imported here: the client module imports this one. + from introspection_sdk.client import IntrospectionClient + + session = self.get_session() + if session is None: + raise _not_signed_in() + return IntrospectionClient( + credentials=self, + base_api_url=self.base_api_url, + dp_url=dp_url or session.dp_url, + additional_headers=additional_headers, + transport=self.transport, + ) + + +class AsyncEmailCodeAuth(_EmailCodeAuthBase): + """Async twin of :class:`EmailCodeAuth`.""" + + def __init__( + self, + client_id: str, + project: str, + *, + session: AuthSession | None = None, + base_api_url: str | None = None, + leeway: float = 60.0, + on_session_change: SessionListener | None = None, + transport: httpx.AsyncBaseTransport | None = None, + clock: Callable[[], datetime] = _utcnow, + ) -> None: + super().__init__( + client_id, + project, + session=session, + base_api_url=base_api_url, + leeway=leeway, + on_session_change=on_session_change, + clock=clock, + ) + self.transport = transport + self._refreshing = asyncio.Lock() + + async def send_code(self, email: str) -> None: + """Email a one-time code to ``email``.""" + await async_send_email_code( + email, + self.client_id, + self.project, + base_api_url=self.base_api_url, + transport=self.transport, + ) + + async def verify_code(self, email: str, code: str) -> AuthSession: + """Verify the code sent to ``email`` and sign in.""" + generation = self._generation + token = await async_email_code_token( + email, + code, + self.client_id, + self.project, + base_api_url=self.base_api_url, + transport=self.transport, + ) + return self._adopt(token, generation) + + def set_session(self, token: OAuthToken | AuthSession) -> AuthSession: + """Adopt a session obtained elsewhere.""" + if isinstance(token, OAuthToken): + return self._adopt(token, None) + self._generation += 1 + self._session = token + self._notify(token) + return token + + async def get_session(self) -> AuthSession | None: + """The current session, refreshed first when within ``leeway`` of + expiry; ``None`` when signed out.""" + session = self._session + if session is None: + return None + if self._expiring(session): + return await self.refresh() + return session + + async def refresh(self) -> AuthSession: + """Renew the session now, or wait for the renewal in flight. A + refresh the server rejects signs out and raises + :class:`~introspection_sdk.AuthenticationError`.""" + session, generation = self._session, self._generation + if session is None: + raise _not_signed_in() + async with self._refreshing: + if self._generation != generation: + raise SignInSupersededError( + "A sign-out or sign-in completed during the refresh" + ) + if self._session is not session and self._session is not None: + return self._session + refresh_token, session_id, org_id = self._refresh_key(session) + try: + token = await async_refresh_access_token( + refresh_token, + self.client_id, + session_id, + org_id, + base_api_url=self.base_api_url, + transport=self.transport, + ) + except IntrospectionAPIError as exc: + if not _is_rejection(exc): + raise + if self._generation != generation: + raise SignInSupersededError( + "A sign-out or sign-in completed during the refresh" + ) from exc + self._clear() + raise _rejected(exc) from exc + return self._replace(session, token, generation) + + async def sign_out(self) -> None: + """Forget the session, then revoke it on the Control Plane. The local + session is cleared even when revocation fails.""" + session = self._clear() + if session is None or session.refresh_token is None: + return + if session.session_id and session.org_id: + await async_revoke_session( + session.session_id, + session.org_id, + self.client_id, + base_api_url=self.base_api_url, + transport=self.transport, + ) + + async def authorization(self) -> str | None: + session = await self.get_session() + return f"Bearer {session.access_token}" if session else None + + async def refresh_after_unauthorized(self, rejected: str | None) -> bool: + session = await self.get_session() + if session is None: + return False + if ( + rejected is not None + and rejected != f"Bearer {session.access_token}" + ): + return True + await self.refresh() + return True + + async def client( + self, + *, + dp_url: str | None = None, + additional_headers: dict[str, str] | None = None, + ) -> AsyncIntrospectionClient: + """Async twin of :meth:`EmailCodeAuth.client`.""" + # Imported here: the client module imports this one. + from introspection_sdk.client import AsyncIntrospectionClient + + session = await self.get_session() + if session is None: + raise _not_signed_in() + return AsyncIntrospectionClient( + credentials=self, + base_api_url=self.base_api_url, + dp_url=dp_url or session.dp_url, + additional_headers=additional_headers, + transport=self.transport, + ) diff --git a/introspection_sdk/client.py b/introspection_sdk/client.py new file mode 100644 index 0000000..dfb6198 --- /dev/null +++ b/introspection_sdk/client.py @@ -0,0 +1,26 @@ +"""REST-only Introspection client. + +This module exposes the CP REST surface +(:class:`~introspection_sdk.resources.Runtimes`, +:class:`~introspection_sdk.resources.Experiments`) plus the DP +:class:`~introspection_sdk.runner.Runner` flow, **without** importing +OpenTelemetry. + +For OpenTelemetry-based emission of ``track`` / ``feedback`` / +``identify`` events, install the ``[otel]`` extra and use +:class:`introspection_sdk.IntrospectionLogs`. For trace export, attach +:class:`introspection_sdk.IntrospectionSpanProcessor` to your +``TracerProvider`` — or call :func:`introspection_sdk.otel.init` to get +both surfaces at once. +""" + +from __future__ import annotations + +from introspection_sdk._async.client import ( + AsyncIntrospectionClient, +) +from introspection_sdk._sync.client import ( + IntrospectionClient, +) + +__all__ = ["AsyncIntrospectionClient", "IntrospectionClient"] diff --git a/introspection_sdk/protocols.py b/introspection_sdk/protocols.py new file mode 100644 index 0000000..9a20cf3 --- /dev/null +++ b/introspection_sdk/protocols.py @@ -0,0 +1,72 @@ +"""Typing Protocols for the data-plane namespaces. + +:class:`DataPlaneResources` is the interface the client and the +:class:`~introspection_sdk.runner.Runner` share on the data plane, and +:class:`AsyncDataPlaneResources` its async twin. Each namespace is a +Protocol of its methods too, so code written against these runs with +either handle, and the type checker fails when the two drift apart. + +The two differ in credential, not in shape: the client sends its own token +(an API key, a service account or a member's token) and the Runner sends +the session token minted for it. ``runner.connections.create`` takes +``runtime`` as optional, defaulting to the runner's runtime group, which +still satisfies the Protocol, where ``runtime`` is required. +""" + +from __future__ import annotations + +from introspection_sdk._async.protocols import ( + AsyncAutomationsResource, + AsyncConnectionsResource, + AsyncConversationItemsResource, + AsyncConversationsResource, + AsyncDataPlaneResources, + AsyncEventsResource, + AsyncFilesResource, + AsyncFileVersionsResource, + AsyncMetricsResource, + AsyncSharesResource, + AsyncTaskRunsResource, + AsyncTasksResource, +) +from introspection_sdk._sync.protocols import ( + AutomationsResource, + ConnectionsResource, + ConversationItemsResource, + ConversationsResource, + DataPlaneResources, + EventsResource, + FilesResource, + FileVersionsResource, + MetricsResource, + SharesResource, + TaskRunsResource, + TasksResource, +) + +__all__ = [ + "AsyncAutomationsResource", + "AsyncConnectionsResource", + "AsyncConversationItemsResource", + "AsyncConversationsResource", + "AsyncDataPlaneResources", + "AsyncEventsResource", + "AsyncFileVersionsResource", + "AsyncFilesResource", + "AsyncMetricsResource", + "AsyncSharesResource", + "AsyncTaskRunsResource", + "AsyncTasksResource", + "AutomationsResource", + "ConnectionsResource", + "ConversationItemsResource", + "ConversationsResource", + "DataPlaneResources", + "EventsResource", + "FileVersionsResource", + "FilesResource", + "MetricsResource", + "SharesResource", + "TaskRunsResource", + "TasksResource", +] diff --git a/introspection_sdk/proxy/_transport.py b/introspection_sdk/proxy/_transport.py new file mode 100644 index 0000000..93f8c49 --- /dev/null +++ b/introspection_sdk/proxy/_transport.py @@ -0,0 +1,23 @@ +"""HTTP-library-neutral transport routing implementation.""" + +from __future__ import annotations + +from introspection_sdk._async.proxy._transport import ( + egress_request, + forward_proxy, + handle_async_request, + select_transport, + unique_transports, +) +from introspection_sdk._sync.proxy._transport import ( + handle_request, +) + +__all__ = [ + "egress_request", + "forward_proxy", + "handle_async_request", + "handle_request", + "select_transport", + "unique_transports", +] diff --git a/introspection_sdk/proxy/httpx.py b/introspection_sdk/proxy/httpx.py new file mode 100644 index 0000000..741b7cd --- /dev/null +++ b/introspection_sdk/proxy/httpx.py @@ -0,0 +1,12 @@ +"""Legacy :mod:`httpx` transports for Harbor and vendor SDK compatibility.""" + +from __future__ import annotations + +from introspection_sdk._async.proxy.httpx import ( + AsyncIntrospectionTransport, +) +from introspection_sdk._sync.proxy.httpx import ( + IntrospectionTransport, +) + +__all__ = ["AsyncIntrospectionTransport", "IntrospectionTransport"] diff --git a/introspection_sdk/proxy/httpx2.py b/introspection_sdk/proxy/httpx2.py new file mode 100644 index 0000000..782ad2b --- /dev/null +++ b/introspection_sdk/proxy/httpx2.py @@ -0,0 +1,12 @@ +"""Native :mod:`httpx2` transports for Introspection egress.""" + +from __future__ import annotations + +from introspection_sdk._async.proxy.httpx2 import ( + AsyncIntrospectionTransport, +) +from introspection_sdk._sync.proxy.httpx2 import ( + IntrospectionTransport, +) + +__all__ = ["AsyncIntrospectionTransport", "IntrospectionTransport"] diff --git a/introspection_sdk/resources/connectors.py b/introspection_sdk/resources/connectors.py new file mode 100644 index 0000000..c8012b7 --- /dev/null +++ b/introspection_sdk/resources/connectors.py @@ -0,0 +1,31 @@ +"""``client.connectors`` — CP namespace for ``/v1/connectors``. + +Connectors are provider integrations (Slack, Gmail, Stripe, ...) a +project owns; connections are the authorized subjects living under +them (``client.connectors.connections.*``). ``authorize`` mints the +consent URL a Business hands its customer — the SDK presents it as a +connector operation even though the route lives under ``/v1/oauth/``. + +``client_secret`` / ``signing_secret`` are write-only: accepted on +create and update, absent from every response, and omitting them on +update means "unchanged". Project scope comes from the authenticated +credential; connector calls do not take a separate project selector. +""" + +from __future__ import annotations + +from introspection_sdk._async.resources.connectors import ( + AsyncConnections, + AsyncConnectors, +) +from introspection_sdk._sync.resources.connectors import ( + Connections, + Connectors, +) + +__all__ = [ + "AsyncConnections", + "AsyncConnectors", + "Connections", + "Connectors", +] diff --git a/introspection_sdk/resources/experiments.py b/introspection_sdk/resources/experiments.py new file mode 100644 index 0000000..0fbe06a --- /dev/null +++ b/introspection_sdk/resources/experiments.py @@ -0,0 +1,28 @@ +"""``client.experiments`` — CP CRUD + lifecycle + ``.run()`` returning a +:class:`Runner`. + +Request documents for create/update are passed through to the Control Plane, +whose experiment schema is the source of truth. + +``client.experiments`` is the :class:`Experiments` instance; calling +``client.experiments(id)`` returns an :class:`ExperimentHandle` with +``.run()``, ``.start()``, ``.end(...)``, and ``.cancel()``. +""" + +from __future__ import annotations + +from introspection_sdk._async.resources.experiments import ( + AsyncExperimentHandle, + AsyncExperiments, +) +from introspection_sdk._sync.resources.experiments import ( + ExperimentHandle, + Experiments, +) + +__all__ = [ + "AsyncExperimentHandle", + "AsyncExperiments", + "ExperimentHandle", + "Experiments", +] diff --git a/introspection_sdk/resources/members.py b/introspection_sdk/resources/members.py new file mode 100644 index 0000000..06a6ee5 --- /dev/null +++ b/introspection_sdk/resources/members.py @@ -0,0 +1,28 @@ +"""``client.members`` — CP namespace for ``/v1/members``. + +Members are the organization's principals: ``business`` humans, +``agent`` members, and ``customer`` members minted from an asserted +identity (:class:`~introspection_sdk.schemas.runner.RunnerIdentity`). +``create`` invites a human by email, or (owner only) records a +``customer`` member by its ``external_user_id``; customer members are +also minted by identity assertion. + +Reads need ``members:read``. ``update`` needs ``members:manage`` (owner), +as does seeding ``tags`` or ``policy_attributes`` on ``create``: a member +tag is access-bearing, and policy attributes decide what the member's agents +may do. ``metadata`` grants nothing. +""" + +from __future__ import annotations + +from introspection_sdk._async.resources.members import ( + AsyncMembers, +) +from introspection_sdk._sync.resources.members import ( + Members, +) + +__all__ = [ + "AsyncMembers", + "Members", +] diff --git a/introspection_sdk/resources/recipes.py b/introspection_sdk/resources/recipes.py new file mode 100644 index 0000000..531a430 --- /dev/null +++ b/introspection_sdk/resources/recipes.py @@ -0,0 +1,21 @@ +"""``client.recipes`` — CP read namespace for ``/v1/recipes``. + +Read-only, no handle subtype. A runner resolves the recipe it is running +under; authoring one is a project-authoring act and lives in the CLI. +Recipes are immutable snapshots of a git repository at a specific commit; +runtimes / experiment arms refer to recipes by id. +""" + +from __future__ import annotations + +from introspection_sdk._async.resources.recipes import ( + AsyncRecipes, +) +from introspection_sdk._sync.resources.recipes import ( + Recipes, +) + +__all__ = [ + "AsyncRecipes", + "Recipes", +] diff --git a/introspection_sdk/resources/repositories.py b/introspection_sdk/resources/repositories.py new file mode 100644 index 0000000..7d51ff2 --- /dev/null +++ b/introspection_sdk/resources/repositories.py @@ -0,0 +1,36 @@ +"""``client.repositories`` — repository lookup (CP), contents, commits and +merges (DP). + +A runner resolves the repository behind the recipe it runs, it does not +register one. Linking a repository to a project is a project-authoring act +and lives in the CLI. + +Unlike the other CP lists, ``GET /v1/repositories`` answers a bare JSON +array rather than the cursor envelope, so :meth:`Repositories.list` returns +a ``list``. Contents are read on the data plane, which resolves ``ref`` to +one commit and reads every page of a listing at it. Commits page the +history from ``sha`` (the default branch when omitted). Merges mirror +GitHub's ``POST /repos/{owner}/{repo}/merges``. +""" + +from __future__ import annotations + +from introspection_sdk._async.resources.repositories import ( + AsyncRepositories, + AsyncRepositoryContents, + AsyncRepositoryMerges, +) +from introspection_sdk._sync.resources.repositories import ( + Repositories, + RepositoryContents, + RepositoryMerges, +) + +__all__ = [ + "AsyncRepositories", + "AsyncRepositoryContents", + "AsyncRepositoryMerges", + "Repositories", + "RepositoryContents", + "RepositoryMerges", +] diff --git a/introspection_sdk/resources/runtimes.py b/introspection_sdk/resources/runtimes.py new file mode 100644 index 0000000..a9dbaf0 --- /dev/null +++ b/introspection_sdk/resources/runtimes.py @@ -0,0 +1,31 @@ +"""``client.runtimes`` — read, resolve, and run runtimes. + +``client.runtimes`` is the :class:`Runtimes` instance; calling +``client.runtimes(runtime)`` returns a :class:`RuntimeHandle` +which exposes ``.run()``. A slug goes straight to +``POST /v1/runtimes/{slug}/run``, which resolves it in the caller's project, +so opening a runner needs no list call. A UUID selector is a runtime group +id and is resolved on the caller's project on every use. Neither is cached, +so a version withdrawn after the handle was built cannot strand it. +Concrete runtime row IDs are used only by explicit ``*_id`` methods. +""" + +from __future__ import annotations + +from introspection_sdk._async.resources.runtimes import ( + AsyncRuntimes, +) +from introspection_sdk._sync.resources.runtimes import ( + Runtimes, +) +from introspection_sdk.resources._runtimes_common import ( + AsyncRuntimeHandle, + RuntimeHandle, +) + +__all__ = [ + "AsyncRuntimeHandle", + "AsyncRuntimes", + "RuntimeHandle", + "Runtimes", +] diff --git a/introspection_sdk/resources/tags.py b/introspection_sdk/resources/tags.py new file mode 100644 index 0000000..fc4d151 --- /dev/null +++ b/introspection_sdk/resources/tags.py @@ -0,0 +1,12 @@ +"""The project tag catalogue: the values a share, a member tag, or an annotation carries.""" + +from __future__ import annotations + +from introspection_sdk._async.resources.tags import ( + AsyncTags, +) +from introspection_sdk._sync.resources.tags import ( + Tags, +) + +__all__ = ["AsyncTags", "Tags"] diff --git a/introspection_sdk/runner.py b/introspection_sdk/runner.py new file mode 100644 index 0000000..5e2390b --- /dev/null +++ b/introspection_sdk/runner.py @@ -0,0 +1,28 @@ +"""DP-facing :class:`Runner` returned by ``runtimes(...).run()`` and +``experiments(...).run()``. + +The Runner is an agent-session with a runtime context attached. It +owns the DP endpoint + session-locator JWT minted by the CP +``/run`` call and exposes ``runner.tasks``, ``runner.files``, +``runner.automations``, ``runner.connections`` and +the read-only ``runner.conversations`` namespaces that target that +endpoint. The +DP-side agent-session machinery materializes the real access token +from the session lookup on each request, so the SDK does not need to +drive refresh itself — +``runner.refresh()`` stays as a manual escape hatch that re-calls +CP ``/run``. ``runner.close()`` flips a local ``_closed`` flag +and tears down the underlying HTTP client; server-side revoke is +a follow-up. +""" + +from __future__ import annotations + +from introspection_sdk._async.runner import ( + AsyncRunner, +) +from introspection_sdk._sync.runner import ( + Runner, +) + +__all__ = ["AsyncRunner", "Runner"] diff --git a/introspection_sdk/runner_resources/_reads.py b/introspection_sdk/runner_resources/_reads.py new file mode 100644 index 0000000..675f12f --- /dev/null +++ b/introspection_sdk/runner_resources/_reads.py @@ -0,0 +1,67 @@ +"""Shared helpers for the runner-scoped DP telemetry list reads. + +Two concerns live here, common to ``runner.conversations`` and +``runner.events``: + +* **Ergonomic window params.** The DP list routes take ``start_date`` / + ``end_date`` (ISO datetimes) and ``direction`` (``asc``/``desc``). The + SDK also accepts the friendlier ``start`` / ``end`` / ``order`` aliases + plus a relative ``lookback`` (e.g. ``"24h"``) that resolves to + ``start_date = now - lookback``. ``lookback`` is mutually exclusive with + ``start`` / ``end`` — passing both raises ``ValueError`` locally, before + any request is sent. +* **Optional Arrow decode.** With ``format="arrow"`` the list read sends + ``Accept: application/vnd.apache.arrow.stream`` and the server returns an + Arrow IPC *stream* (envelope columns plus a typed ``payload`` struct + column) with the pagination metadata on response headers. + :func:`decode_arrow_page` rebuilds the identical + :class:`~introspection_sdk.schemas.pagination.Paginated` envelope the JSON + path produces, so the auto-paging ``Pager`` drives both formats + unchanged. +* **Columnar page iteration.** The ``.list_arrow()`` accessors return an + :class:`ArrowPageIterator` / :class:`AsyncArrowPageIterator` yielding one + ``pyarrow.Table`` per server page (constant memory), with a + ``read_all()`` convenience concatenating every page into one Table. +""" + +from __future__ import annotations + +from introspection_sdk._async.runner_resources._reads import ( + ARROW_ACCEPT_HEADERS, + ARROW_STREAM_MEDIA_TYPE, + LOOKBACK_RE, + LOOKBACK_UNITS, + TRAJECTORY_ACCEPT_HEADERS, + TRAJECTORY_MEDIA_TYPE, + AsyncArrowPageIterator, + ReadFormat, + T, + decode_arrow_page, + decode_arrow_table, + import_pyarrow, + next_cursor_header, + parse_lookback, + resolve_window, +) +from introspection_sdk._sync.runner_resources._reads import ( + ArrowPageIterator, +) + +__all__ = [ + "ARROW_ACCEPT_HEADERS", + "ARROW_STREAM_MEDIA_TYPE", + "ArrowPageIterator", + "AsyncArrowPageIterator", + "LOOKBACK_RE", + "LOOKBACK_UNITS", + "ReadFormat", + "T", + "TRAJECTORY_ACCEPT_HEADERS", + "TRAJECTORY_MEDIA_TYPE", + "decode_arrow_page", + "decode_arrow_table", + "import_pyarrow", + "next_cursor_header", + "parse_lookback", + "resolve_window", +] diff --git a/introspection_sdk/runner_resources/automations.py b/introspection_sdk/runner_resources/automations.py new file mode 100644 index 0000000..e59b757 --- /dev/null +++ b/introspection_sdk/runner_resources/automations.py @@ -0,0 +1,36 @@ +"""``client.automations`` / ``runner.automations`` — DP namespace for +``/v1/automations``. + +An automation is scheduled work on a project: a prompt a person set up +(``kind=None``), which creates a task per firing or posts into an +existing one (``task_id``), or platform work (``observation_synthesis``, +``observation_clustering``, ``project_check_in``). Every firing is +recorded as an ``introspection.automation.triggered`` or +``introspection.automation.skipped`` event, readable through +``client.events`` with the ``automation_id`` / ``task_id`` filters. + +The server serves these routes to project administrators only today and +answers anyone else with a 403. introspection-cloud#3137 (not yet +shipped) opens them to members for their own automations that post into +one of their own tasks. + +``runner.automations`` sends the runner's token. The platform is being +changed so that a runner a member opens for themself carries +``automations:read`` and ``automations:write``. +""" + +from __future__ import annotations + +from introspection_sdk._async.runner_resources.automations import ( + AsyncAutomations, + MetadataInput, +) +from introspection_sdk._sync.runner_resources.automations import ( + Automations, +) + +__all__ = [ + "AsyncAutomations", + "Automations", + "MetadataInput", +] diff --git a/introspection_sdk/runner_resources/connections.py b/introspection_sdk/runner_resources/connections.py new file mode 100644 index 0000000..bf68a01 --- /dev/null +++ b/introspection_sdk/runner_resources/connections.py @@ -0,0 +1,33 @@ +"""``client.connections`` / ``runner.connections`` — DP namespace for +``/v1/connections``: the apps (Gmail, Slack, ...) members connected for +themselves, which the agent then acts with in that member's sessions. + +These are distinct from a connector's connections +(``client.connectors.connections``, CP ``/v1/connectors/{id}/connections``), +which an integrator administers for the project. + +The routes take the ``connections:read``, ``connections:write`` and +``connections:delete`` scopes. A caller who is not a project administrator +only ever sees and manages their own connections, whatever ``member_id`` +it asks for. +""" + +from __future__ import annotations + +from introspection_sdk._async.runner_resources.connections import ( + CONNECTIONS_PATH, + AsyncAppConnections, + AsyncRunnerAppConnections, +) +from introspection_sdk._sync.runner_resources.connections import ( + AppConnections, + RunnerAppConnections, +) + +__all__ = [ + "AppConnections", + "AsyncAppConnections", + "AsyncRunnerAppConnections", + "CONNECTIONS_PATH", + "RunnerAppConnections", +] diff --git a/introspection_sdk/runner_resources/conversations.py b/introspection_sdk/runner_resources/conversations.py new file mode 100644 index 0000000..d3d7ef9 --- /dev/null +++ b/introspection_sdk/runner_resources/conversations.py @@ -0,0 +1,51 @@ +"""`runner.conversations.*` namespace: read-only conversation reads. + +Bound to a :class:`~introspection_sdk.runner.Runner` — every call targets +the runner's DP endpoint with its short-lived JWT. The surface is +read-only. + +Both conversation summaries and conversation items walk opaque ``next`` +cursors. The item envelope retains OpenAI-style ``first_id`` / ``last_id`` +metadata, but those identifiers do not drive pagination. + +Shares are ambient: a conversation shared with the caller appears in these +reads without naming the share. +""" + +from __future__ import annotations + +from introspection_sdk._async.runner_resources.conversations import ( + JSON_EXPORT_HEADERS, + LATEST_TURN_SCAN_LIMIT, + TRAJECTORY_ADAPTER, + AsyncConversationItems, + AsyncConversations, + ConversationExportFormat, + ConversationResolution, + ConversationSentiment, + build_conversation_params, + build_export_params, +) +from introspection_sdk._sync.runner_resources.conversations import ( + ConversationItems, + Conversations, +) +from introspection_sdk.runner_resources._conversations_common import ( + ConversationExportParams, +) + +__all__ = [ + "AsyncConversationItems", + "AsyncConversations", + "ConversationExportFormat", + "ConversationExportParams", + "ConversationItems", + "ConversationResolution", + "ConversationSentiment", + "Conversations", + "JSON_EXPORT_HEADERS", + "LATEST_TURN_SCAN_LIMIT", + "TRAJECTORY_ADAPTER", + "build_conversation_params", + "build_export_params", +] diff --git a/introspection_sdk/runner_resources/events.py b/introspection_sdk/runner_resources/events.py new file mode 100644 index 0000000..019877a --- /dev/null +++ b/introspection_sdk/runner_resources/events.py @@ -0,0 +1,66 @@ +"""`runner.events.*` namespace: read-only event reads. + +Bound to a :class:`~introspection_sdk.runner.Runner` — every call targets +the runner's DP endpoint with its short-lived JWT. The surface is +read-only and mirrors ``runner.conversations``: one cursor-paginated list +read over the append-only ``otel_logs`` store. Every read names its +family — ``event_name`` is **required, exactly one** — so a page is +always homogeneous and each row validates into one member of the +discriminated :data:`~introspection_sdk.schemas.events.Event` union +(common envelope + nested typed ``payload``). + +Three response representations share one request contract: + +* JSON (default) — the standard cursor envelope + (:class:`~introspection_sdk.schemas.pagination.Paginated`). +* Arrow (``format="arrow"``) — the same page as an Arrow IPC stream + (envelope columns + a typed ``payload`` struct column) with pagination + metadata on response headers, decoded back into the identical envelope + (see :mod:`introspection_sdk.runner_resources._reads`). +* Columnar (:meth:`Events.arrow`) — the raw ``pyarrow.Table`` per page, + for callers who want columns rather than models. + +**Unknown-family tolerance:** rows whose ``event_name`` is outside the +SDK's known family set are skipped client-side (never raised), counted on +:data:`UNKNOWN_EVENT_SKIPS`, and debug-logged — so a new server-side +family doesn't break older SDKs. +""" + +from __future__ import annotations + +import logging + +from introspection_sdk._async.runner_resources.events import ( + EVENT_ADAPTER, + EVENT_PAYLOAD_JSON_FIELDS, + AsyncEvents, + build_event_params, + normalize_event_arrow_row, + validate_event_page, + validate_event_row, + validate_single_event, +) +from introspection_sdk._sync.runner_resources.events import ( + Events, +) +from introspection_sdk.runner_resources._events_common import ( + UNKNOWN_EVENT_SKIPS, + UnknownEventCounter, +) + +logger = logging.getLogger(__name__) + +__all__ = [ + "AsyncEvents", + "EVENT_ADAPTER", + "EVENT_PAYLOAD_JSON_FIELDS", + "Events", + "UNKNOWN_EVENT_SKIPS", + "UnknownEventCounter", + "build_event_params", + "logger", + "normalize_event_arrow_row", + "validate_event_page", + "validate_event_row", + "validate_single_event", +] diff --git a/introspection_sdk/runner_resources/files.py b/introspection_sdk/runner_resources/files.py new file mode 100644 index 0000000..3fd867c --- /dev/null +++ b/introspection_sdk/runner_resources/files.py @@ -0,0 +1,28 @@ +"""`runner.files.*` namespace: list / upload / download / versions. + +Bound to a :class:`~introspection_sdk.runner.Runner` — every call +targets the runner's DP endpoint with its short-lived JWT. +""" + +from __future__ import annotations + +from introspection_sdk._async.runner_resources.files import ( + AsyncFiles, + AsyncFileVersions, + FileLike, +) +from introspection_sdk._async.runner_resources.files import ( + _materialise_upload as _materialise_upload, +) +from introspection_sdk._sync.runner_resources.files import ( + Files, + FileVersions, +) + +__all__ = [ + "AsyncFileVersions", + "AsyncFiles", + "FileLike", + "FileVersions", + "Files", +] diff --git a/introspection_sdk/runner_resources/metrics.py b/introspection_sdk/runner_resources/metrics.py new file mode 100644 index 0000000..3f1b0d3 --- /dev/null +++ b/introspection_sdk/runner_resources/metrics.py @@ -0,0 +1,23 @@ +"""`runner.metrics.*` namespace: bounded telemetry aggregation. + +Bound to a :class:`~introspection_sdk.runner.Runner` — every call targets +the runner's DP endpoint with its short-lived JWT. Wraps the closed, +allow-listed ``POST /v1/metrics`` contract: one bounded, read-only aggregation over a +telemetry view. Not a list read — there is no cursor and no Arrow variant; +the request carries an explicit ``from_timestamp`` / ``to_timestamp`` window +and the response is a single :class:`MetricQueryResponse`. +""" + +from __future__ import annotations + +from introspection_sdk._async.runner_resources.metrics import ( + AsyncMetrics, +) +from introspection_sdk._sync.runner_resources.metrics import ( + Metrics, +) + +__all__ = [ + "AsyncMetrics", + "Metrics", +] diff --git a/introspection_sdk/runner_resources/shares.py b/introspection_sdk/runner_resources/shares.py new file mode 100644 index 0000000..cba9955 --- /dev/null +++ b/introspection_sdk/runner_resources/shares.py @@ -0,0 +1,25 @@ +"""`runner.shares.*` namespace: sharing grants for files, issues and conversations. + +Bound to a :class:`~introspection_sdk.runner.Runner` — every call targets the +runner's DP endpoint with its short-lived JWT. ``create`` / ``list`` / ``get`` / +``update`` / ``delete`` manage grants. Shares are ambient: a shared resource +appears in the grantee's ordinary reads, and a grant's ``url`` is the plain +resource URL. To share with a cohort, create a share with ``granted_tag``. To +fork a new task from a shared conversation, pass ``fork_share_id`` to +``runner.tasks.create(...)``; the API refuses one whose share has +``visible_from`` set. +""" + +from __future__ import annotations + +from introspection_sdk._async.runner_resources.shares import ( + AsyncShares, +) +from introspection_sdk._sync.runner_resources.shares import ( + Shares, +) + +__all__ = [ + "AsyncShares", + "Shares", +] diff --git a/introspection_sdk/runner_resources/tasks.py b/introspection_sdk/runner_resources/tasks.py new file mode 100644 index 0000000..0f2c6ea --- /dev/null +++ b/introspection_sdk/runner_resources/tasks.py @@ -0,0 +1,27 @@ +"""`runner.tasks.*` namespace: tasks + runs + cursor-style sugar. + +Bound to a :class:`~introspection_sdk.runner.Runner` — every call +targets the runner's DP endpoint with its short-lived JWT. +""" + +from __future__ import annotations + +from introspection_sdk._async.runner_resources.tasks import ( + AsyncRunHandle, + AsyncTaskRuns, + AsyncTasks, +) +from introspection_sdk._sync.runner_resources.tasks import ( + RunHandle, + TaskRuns, + Tasks, +) + +__all__ = [ + "AsyncRunHandle", + "AsyncTaskRuns", + "AsyncTasks", + "RunHandle", + "TaskRuns", + "Tasks", +] diff --git a/introspection_sdk/streaming.py b/introspection_sdk/streaming.py new file mode 100644 index 0000000..c5e2215 --- /dev/null +++ b/introspection_sdk/streaming.py @@ -0,0 +1,23 @@ +"""AG-UI event parsing over the task run SSE transport. + +The HTTP response still uses Server-Sent Events as a transport detail, but +the public SDK surface yields validated AG-UI protocol events only. +""" + +from __future__ import annotations + +from introspection_sdk._async.streaming import ( + _parse_sse_async as _parse_sse_async, +) +from introspection_sdk._async.streaming import ( + parse_ag_ui_events_async, +) +from introspection_sdk._sync.streaming import _parse_sse as _parse_sse +from introspection_sdk._sync.streaming import ( + parse_ag_ui_events, +) + +__all__ = [ + "parse_ag_ui_events", + "parse_ag_ui_events_async", +] From f1d15f7d7c7ea24746be36df6de6817860a0fc47 Mon Sep 17 00:00:00 2001 From: Marcus Schiesser Date: Wed, 21 Oct 2026 19:20:46 +0700 Subject: [PATCH 4/5] build(unasync): generate the sync twins into _sync/ with scripts/gen_sync.py Add scripts/gen_sync.py, which deletes introspection_sdk/_sync/, writes the sync twin of each _async/ module into it with unasync plus a token map and a few substitutions, then runs ruff over the folder, and check in its output. Every file there is generated. ruff's UP028 and C416 are off for _sync/, so the loops an `async for` leaves stay as written. Co-Authored-By: Claude Opus 5.5 --- introspection_sdk/_sync/__init__.py | 6 + introspection_sdk/_sync/_http.py | 259 +++++++ introspection_sdk/_sync/auth.py | 333 +++++++++ introspection_sdk/_sync/client.py | 189 +++++ introspection_sdk/_sync/protocols.py | 626 ++++++++++++++++ introspection_sdk/_sync/proxy/__init__.py | 2 + introspection_sdk/_sync/proxy/_transport.py | 104 +++ introspection_sdk/_sync/proxy/httpx.py | 70 ++ introspection_sdk/_sync/proxy/httpx2.py | 65 ++ introspection_sdk/_sync/resources/__init__.py | 2 + .../_sync/resources/connectors.py | 567 ++++++++++++++ .../_sync/resources/experiments.py | 251 +++++++ introspection_sdk/_sync/resources/members.py | 194 +++++ introspection_sdk/_sync/resources/recipes.py | 56 ++ .../_sync/resources/repositories.py | 244 ++++++ introspection_sdk/_sync/resources/runtimes.py | 184 +++++ introspection_sdk/_sync/resources/tags.py | 105 +++ introspection_sdk/_sync/runner.py | 204 +++++ .../_sync/runner_resources/__init__.py | 2 + .../_sync/runner_resources/_reads.py | 202 +++++ .../_sync/runner_resources/automations.py | 260 +++++++ .../_sync/runner_resources/connections.py | 142 ++++ .../_sync/runner_resources/conversations.py | 700 ++++++++++++++++++ .../_sync/runner_resources/events.py | 405 ++++++++++ .../_sync/runner_resources/files.py | 304 ++++++++ .../_sync/runner_resources/metrics.py | 38 + .../_sync/runner_resources/shares.py | 175 +++++ .../_sync/runner_resources/tasks.py | 397 ++++++++++ introspection_sdk/_sync/streaming.py | 41 + pyproject.toml | 5 + scripts/gen_sync.py | 102 +++ 31 files changed, 6234 insertions(+) create mode 100644 introspection_sdk/_sync/__init__.py create mode 100644 introspection_sdk/_sync/_http.py create mode 100644 introspection_sdk/_sync/auth.py create mode 100644 introspection_sdk/_sync/client.py create mode 100644 introspection_sdk/_sync/protocols.py create mode 100644 introspection_sdk/_sync/proxy/__init__.py create mode 100644 introspection_sdk/_sync/proxy/_transport.py create mode 100644 introspection_sdk/_sync/proxy/httpx.py create mode 100644 introspection_sdk/_sync/proxy/httpx2.py create mode 100644 introspection_sdk/_sync/resources/__init__.py create mode 100644 introspection_sdk/_sync/resources/connectors.py create mode 100644 introspection_sdk/_sync/resources/experiments.py create mode 100644 introspection_sdk/_sync/resources/members.py create mode 100644 introspection_sdk/_sync/resources/recipes.py create mode 100644 introspection_sdk/_sync/resources/repositories.py create mode 100644 introspection_sdk/_sync/resources/runtimes.py create mode 100644 introspection_sdk/_sync/resources/tags.py create mode 100644 introspection_sdk/_sync/runner.py create mode 100644 introspection_sdk/_sync/runner_resources/__init__.py create mode 100644 introspection_sdk/_sync/runner_resources/_reads.py create mode 100644 introspection_sdk/_sync/runner_resources/automations.py create mode 100644 introspection_sdk/_sync/runner_resources/connections.py create mode 100644 introspection_sdk/_sync/runner_resources/conversations.py create mode 100644 introspection_sdk/_sync/runner_resources/events.py create mode 100644 introspection_sdk/_sync/runner_resources/files.py create mode 100644 introspection_sdk/_sync/runner_resources/metrics.py create mode 100644 introspection_sdk/_sync/runner_resources/shares.py create mode 100644 introspection_sdk/_sync/runner_resources/tasks.py create mode 100644 introspection_sdk/_sync/streaming.py create mode 100644 scripts/gen_sync.py diff --git a/introspection_sdk/_sync/__init__.py b/introspection_sdk/_sync/__init__.py new file mode 100644 index 0000000..69c0e9a --- /dev/null +++ b/introspection_sdk/_sync/__init__.py @@ -0,0 +1,6 @@ +# Generated by scripts/gen_sync.py from introspection_sdk/_async/__init__.py. Do not edit: +# change the async source and run `uv run python scripts/gen_sync.py`. +"""Re-exported by the public modules. + +See "Sync code generation" in AGENTS.md. +""" diff --git a/introspection_sdk/_sync/_http.py b/introspection_sdk/_sync/_http.py new file mode 100644 index 0000000..b3c79a7 --- /dev/null +++ b/introspection_sdk/_sync/_http.py @@ -0,0 +1,259 @@ +# Generated by scripts/gen_sync.py from introspection_sdk/_async/_http.py. Do not edit: +# change the async source and run `uv run python scripts/gen_sync.py`. +"""Re-exported by :mod:`introspection_sdk._http`.""" + +from __future__ import annotations + +from collections.abc import Iterator, Mapping +from datetime import date, datetime +from typing import TYPE_CHECKING, Any + +import httpx2 as httpx + +from introspection_sdk._backoff import ( + _is_retryable_status, + _retry_delay, + _sleep, +) +from introspection_sdk._errors import ( + NetworkError, + RunnerExpiredError, + _parse_retry_after, + error_from_response, +) +from introspection_sdk._http_common import ( + RawResponse, +) + +if TYPE_CHECKING: + from introspection_sdk.auth import ( + CredentialProvider, + ) + +#: Default automatic retries for unary REST calls: ``429`` on every method, +#: ``502``/``503``/``504`` on ``GET`` only (see +#: :func:`introspection_sdk._backoff._is_retryable_status`). ``Retry-After`` +#: is honoured as a backoff floor when present. ``0`` disables retrying. +#: Streaming has its own resume budget (see +#: :mod:`introspection_sdk.resumable`). +DEFAULT_MAX_RETRIES = 2 +#: Default base step (seconds) of the capped-exponential retry backoff. +DEFAULT_RETRY_BASE = 0.5 + + +class _HttpClient: + """Thin wrapper around ``httpx.Client`` for REST calls. + + Used both for the CP-facing client (on ``IntrospectionClient``) + and for the DP-facing client (on a ``Runner``). The two only + differ in their base URL and bearer token. + """ + + def __init__( + self, + *, + api_url: str, + token: str, + cp_session: str | None = None, + additional_headers: Mapping[str, str] | None = None, + timeout: float = 30.0, + transport: httpx.BaseTransport | None = None, + max_retries: int = DEFAULT_MAX_RETRIES, + retry_base: float = DEFAULT_RETRY_BASE, + credentials: CredentialProvider | None = None, + ) -> None: + self._closed = False + self._credentials = credentials + self._client = httpx.Client( + base_url=api_url.rstrip("/"), + timeout=timeout, + transport=transport, + ) + self._auth_headers: dict[str, str] = ( + {"Cookie": f"intro_cp_session={cp_session}"} + if cp_session + else {} + if credentials is not None + else {"Authorization": f"Bearer {token}"} + ) + if additional_headers: + self._auth_headers.update(additional_headers) + self._max_retries = max_retries + self._retry_base = retry_base + + def close(self) -> None: + self._closed = True + self._client.close() + + def _check_open(self) -> None: + """Raise a typed error once this client has been closed. + + ``Runner.refresh()`` closes the client it replaces, and + ``Runner.close()`` closes the live one, but a namespace handle the + caller already holds keeps a reference to it. Without this the next + call surfaced httpx's bare ``RuntimeError: Cannot send a request, as + the client has been closed``, bypassing the typed-error guarantee + ``_check_open`` on the Runner exists to give. + """ + if self._closed: + raise RunnerExpiredError( + "This client has been closed (the Runner was closed or " + "refreshed); take a fresh namespace handle from the Runner.", + status_code=0, + ) + + def _request_headers( + self, extra: Mapping[str, str] | None + ) -> dict[str, str]: + req_headers = dict(self._auth_headers) + if self._credentials is not None and "Cookie" not in req_headers: + authorization = self._credentials.authorization() + if authorization: + req_headers["Authorization"] = authorization + if extra: + req_headers.update(extra) + return req_headers + + def request( + self, + method: str, + path: str, + *, + params: Mapping[str, Any] | None = None, + json: Any = None, + files: Mapping[str, Any] | None = None, + data: Mapping[str, Any] | None = None, + headers: Mapping[str, str] | None = None, + expect: str = "json", + ) -> Any: + self._check_open() + # Auto-retry retryable statuses, honouring ``Retry-After`` as a + # backoff floor when present: ``429`` on any method (the request was + # rejected and never processed, so retrying is side-effect-safe for + # writes too) and ``502``/``503``/``504`` on idempotent ``GET`` calls + # only. Multipart uploads aren't retried. + retries = 0 if files is not None else self._max_retries + idempotent = method.upper() == "GET" + attempt = 0 + reauthorized = files is not None + while True: + req_headers = self._request_headers(headers) + try: + res = self._client.request( + method, + path, + params=_clean_params(params), + json=json, + files=files, + data=data, + headers=req_headers, + ) + except httpx.HTTPError as exc: + raise NetworkError(str(exc)) from exc + if ( + res.status_code == 401 + and self._credentials is not None + and not reauthorized + ): + reauthorized = True + if self._credentials.refresh_after_unauthorized( + req_headers.get("Authorization") + ): + continue + if ( + _is_retryable_status(res.status_code, idempotent) + and attempt < retries + ): + delay = _retry_delay( + attempt, + _parse_retry_after(res.headers.get("retry-after")), + self._retry_base, + ) + attempt += 1 + _sleep(delay) + continue + if res.status_code >= 400: + raise error_from_response(res) + if expect == "empty": + return None + if expect == "bytes": + return res.content + if expect == "raw": + return RawResponse(content=res.content, headers=res.headers) + return res.json() + + def stream_bytes( + self, + path: str, + *, + params: Mapping[str, Any] | None = None, + headers: Mapping[str, str] | None = None, + ) -> Iterator[bytes]: + req_headers = self._request_headers(headers) + try: + with self._client.stream( + "GET", + path, + params=_clean_params(params), + headers=req_headers, + ) as res: + if res.status_code >= 400: + res.read() + raise error_from_response(res) + for chunk in res.iter_bytes(): + yield chunk + except httpx.HTTPError as exc: + raise NetworkError(str(exc)) from exc + + def stream_sse_lines( + self, + path: str, + *, + params: Mapping[str, Any] | None = None, + headers: Mapping[str, str] | None = None, + ) -> Iterator[str]: + self._check_open() + req_headers = self._request_headers( + {"Accept": "text/event-stream", **(headers or {})} + ) + try: + with self._client.stream( + "GET", path, params=_clean_params(params), headers=req_headers + ) as res: + if res.status_code >= 400: + res.read() + raise error_from_response(res) + for line in res.iter_lines(): + yield line + except httpx.HTTPError as exc: + raise NetworkError(str(exc)) from exc + + +def _clean_params( + params: Mapping[str, Any] | None, +) -> dict[str, Any] | None: + if params is None: + return None + out: dict[str, Any] = {} + for k, v in params.items(): + if v is None: + continue + out[k] = _param_value(v) + return out + + +def _param_value(value: Any) -> Any: + """Render one query-parameter value the way the API expects it. + + ``httpx`` falls back to ``str()`` for anything it does not know, and + ``str(datetime)`` is the space-separated form ``2025-01-01 12:30:00+00:00`` + -- not ISO-8601. The window parameters (``start`` / ``end`` / + ``start_date`` / ``end_date``, and the ``start_date`` a ``lookback`` + resolves to) accept ``datetime`` by design, so every one of them went out + malformed. ``isoformat()`` puts the ``T`` back. + """ + if isinstance(value, datetime | date): + return value.isoformat() + if isinstance(value, list | tuple): + return [_param_value(item) for item in value] + return value diff --git a/introspection_sdk/_sync/auth.py b/introspection_sdk/_sync/auth.py new file mode 100644 index 0000000..82c4dc6 --- /dev/null +++ b/introspection_sdk/_sync/auth.py @@ -0,0 +1,333 @@ +# Generated by scripts/gen_sync.py from introspection_sdk/_async/auth.py. Do not edit: +# change the async source and run `uv run python scripts/gen_sync.py`. +"""Re-exported by :mod:`introspection_sdk.auth`.""" + +from __future__ import annotations + +from typing import Protocol + +import httpx2 as httpx + +from introspection_sdk._auth_common import ( + OAuthToken, + _resolve_base_api_url, +) +from introspection_sdk._errors import ( + NetworkError, + error_from_response, +) + +_TOKEN_PATH = "/v1/oauth/token" +_GRANT_CLIENT_CREDENTIALS = "client_credentials" +_GRANT_TOKEN_EXCHANGE = "urn:ietf:params:oauth:grant-type:token-exchange" +_GRANT_AUTHORIZATION_CODE = "authorization_code" +_GRANT_REFRESH_TOKEN = "refresh_token" +_SUBJECT_TOKEN_TYPE_ID_TOKEN = "urn:ietf:params:oauth:token-type:id_token" +_EMAIL_CODE_PATH = "/v1/oauth/email/code" +_REVOKE_PATH = "/v1/oauth/revoke" +#: ``grant_type`` of native email-code sign-in at ``POST /v1/oauth/token``. +GRANT_EMAIL_CODE = "urn:introspection:params:oauth:grant-type:email_code" + + +def _service_account_form( + client_id: str, + client_secret: str, + project: str, + scope: str | None, +) -> dict[str, str]: + form = { + "grant_type": _GRANT_CLIENT_CREDENTIALS, + "client_id": client_id, + "client_secret": client_secret, + "project": project, + } + if scope: + form["scope"] = scope + return form + + +def _token_exchange_form( + subject_token: str, + client_id: str, + project: str, + subject_token_type: str | None, + scope: str | None, +) -> dict[str, str]: + form = { + "grant_type": _GRANT_TOKEN_EXCHANGE, + "subject_token": subject_token, + "subject_token_type": subject_token_type + or _SUBJECT_TOKEN_TYPE_ID_TOKEN, + "client_id": client_id, + "project": project, + } + if scope: + form["scope"] = scope + return form + + +def _authorization_code_form( + code: str, + client_id: str, + redirect_uri: str, + code_verifier: str, +) -> dict[str, str]: + return { + "grant_type": _GRANT_AUTHORIZATION_CODE, + "code": code, + "client_id": client_id, + "redirect_uri": redirect_uri, + "code_verifier": code_verifier, + } + + +def _cp_post( + base_api_url: str, + path: str, + *, + data: dict[str, str] | None = None, + json: dict[str, str] | None = None, + transport: httpx.BaseTransport | None = None, +) -> httpx.Response: + try: + with httpx.Client( + base_url=base_api_url, timeout=30.0, transport=transport + ) as client: + res = client.post(path, data=data, json=json) + except httpx.HTTPError as exc: + raise NetworkError(str(exc)) from exc + if res.status_code >= 400: + raise error_from_response(res) + return res + + +def _post_token_form( + base_api_url: str, + form: dict[str, str], + *, + transport: httpx.BaseTransport | None = None, +) -> OAuthToken: + res = _cp_post(base_api_url, _TOKEN_PATH, data=form, transport=transport) + return OAuthToken.model_validate(res.json()) + + +def service_account_token( + client_id: str, + client_secret: str, + project: str, + *, + scope: str | None = None, + base_api_url: str | None = None, + transport: httpx.BaseTransport | None = None, +) -> OAuthToken: + """Mint a project-scoped CP access token from service-account creds. + + ``client_id`` (``intro_app_…``) and ``client_secret`` (``intro_sk_…``) + come from a ``service_account`` Application; ``project`` scopes the + token (the project must belong to the Application's organization). + ``scope`` is capped server-side to the Application's allowed scopes. + + See :func:`async_service_account_token` for the ``asyncio`` twin and + :meth:`IntrospectionClient.from_service_account` to mint and construct + a client in one call. + """ + form = _service_account_form( + client_id=client_id, + client_secret=client_secret, + project=project, + scope=scope, + ) + return _post_token_form( + _resolve_base_api_url(base_api_url), form, transport=transport + ) + + +def token_exchange( + subject_token: str, + client_id: str, + project: str, + *, + subject_token_type: str | None = None, + scope: str | None = None, + base_api_url: str | None = None, + transport: httpx.BaseTransport | None = None, +) -> OAuthToken: + """RFC 8693 token-exchange against CP ``POST /v1/oauth/token``. + + Trade an end user's identity-provider JWT (``subject_token``) for a + project-scoped access token for a federated ``customer`` member. + ``client_id`` is a ``jwks`` Application's id (or a brokered ``spa``'s, + with the brokered login's ``id_token``). The server accepts only the + ``id_token`` subject type, whatever kind of JWT the provider issued, + so leave ``subject_token_type`` at its default. The granted scope is + capped to the Application's ``allowed_scopes``; no refresh token is + issued, so exchange again before ``expires_in`` lapses. + """ + form = _token_exchange_form( + subject_token=subject_token, + client_id=client_id, + project=project, + subject_token_type=subject_token_type, + scope=scope, + ) + return _post_token_form( + _resolve_base_api_url(base_api_url), form, transport=transport + ) + + +def authorization_code_token( + code: str, + client_id: str, + redirect_uri: str, + code_verifier: str, + *, + base_api_url: str | None = None, + transport: httpx.BaseTransport | None = None, +) -> OAuthToken: + """RFC 6749 / PKCE ``authorization_code`` exchange. + + Run this in your backend so the hosted-login callback does not + hand-roll the token POST. ``client_id`` is the ``spa`` + Application; ``code_verifier`` pairs with the authorize-step + challenge; ``redirect_uri`` must match the authorize call. + """ + form = _authorization_code_form( + code=code, + client_id=client_id, + redirect_uri=redirect_uri, + code_verifier=code_verifier, + ) + return _post_token_form( + _resolve_base_api_url(base_api_url), form, transport=transport + ) + + +def _email_code_form( + email: str, code: str, client_id: str, project: str +) -> dict[str, str]: + # The code is opaque: a returning user's is 6 digits, a new user's first + # one is 6 characters of A-Z0-9, so it is never checked for shape here. + return { + "grant_type": GRANT_EMAIL_CODE, + "client_id": client_id, + "email": email, + "code": code.strip(), + "project": project, + } + + +def _refresh_form( + refresh_token: str, client_id: str, session_id: str, org_id: str +) -> dict[str, str]: + return { + "grant_type": _GRANT_REFRESH_TOKEN, + "refresh_token": refresh_token, + "client_id": client_id, + "session_id": session_id, + "org_id": org_id, + } + + +def send_email_code( + email: str, + client_id: str, + project: str, + *, + base_api_url: str | None = None, + transport: httpx.BaseTransport | None = None, +) -> None: + """Email a sign-in code to ``email`` (CP ``POST /v1/oauth/email/code``). + + ``client_id`` is a ``native`` Application's id and ``project`` the + project slug or id the session will be scoped to. The answer is the same + whether or not the email has an account. A rate-limited send raises + :class:`~introspection_sdk.RateLimitError` carrying ``retry_after``. + """ + _cp_post( + _resolve_base_api_url(base_api_url), + _EMAIL_CODE_PATH, + json={"client_id": client_id, "email": email, "project": project}, + transport=transport, + ) + + +def email_code_token( + email: str, + code: str, + client_id: str, + project: str, + *, + base_api_url: str | None = None, + transport: httpx.BaseTransport | None = None, +) -> OAuthToken: + """Exchange an emailed code for a refreshable ``customer`` session. + + Accepts letters as well as digits. A wrong, expired or reused code raises + :class:`~introspection_sdk.ValidationError` with ``code="invalid_grant"``. + The returned token carries ``refresh_token``, ``session_id`` and + ``org_id`` for :func:`refresh_access_token`, and ``dp_url``. + """ + return _post_token_form( + _resolve_base_api_url(base_api_url), + _email_code_form(email, code, client_id, project), + transport=transport, + ) + + +def refresh_access_token( + refresh_token: str, + client_id: str, + session_id: str, + org_id: str, + *, + base_api_url: str | None = None, + transport: httpx.BaseTransport | None = None, +) -> OAuthToken: + """Renew a session-backed token with the ``refresh_token`` grant. + + The refresh token rotates: persist the returned one. The response + carries no ``dp_url``; keep the one from sign-in. + """ + return _post_token_form( + _resolve_base_api_url(base_api_url), + _refresh_form(refresh_token, client_id, session_id, org_id), + transport=transport, + ) + + +def revoke_session( + session_id: str, + org_id: str, + client_id: str, + *, + base_api_url: str | None = None, + transport: httpx.BaseTransport | None = None, +) -> None: + """Revoke a session (CP ``POST /v1/oauth/revoke``); its next refresh fails.""" + _cp_post( + _resolve_base_api_url(base_api_url), + _REVOKE_PATH, + data={ + "client_id": client_id, + "session_id": session_id, + "org_id": org_id, + }, + transport=transport, + ) + + +class CredentialProvider(Protocol): + """Supplies ``Authorization`` to a client and renews it after a ``401``. + + :class:`EmailCodeAuth` implements it; pass one as + ``IntrospectionClient(credentials=...)``. + """ + + def authorization(self) -> str | None: + """The header value to send, or ``None`` to send none.""" + ... + + def refresh_after_unauthorized(self, rejected: str | None) -> bool: + """Called after a ``401`` for a request sent with ``rejected``; + return ``True`` to retry it once with :meth:`authorization`.""" + ... diff --git a/introspection_sdk/_sync/client.py b/introspection_sdk/_sync/client.py new file mode 100644 index 0000000..f8f79c3 --- /dev/null +++ b/introspection_sdk/_sync/client.py @@ -0,0 +1,189 @@ +# Generated by scripts/gen_sync.py from introspection_sdk/_async/client.py. Do not edit: +# change the async source and run `uv run python scripts/gen_sync.py`. +"""Re-exported by :mod:`introspection_sdk.client`.""" + +from __future__ import annotations + +import os + +import httpx2 as httpx + +from introspection_sdk._http import _HttpClient +from introspection_sdk.auth import ( + CredentialProvider, + service_account_token, +) +from introspection_sdk.dev_target import client_headers +from introspection_sdk.resources import ( + Annotations, + Automations, + Connectors, + Experiments, + Members, + Recipes, + Repositories, + Runtimes, + Tags, +) +from introspection_sdk.runner_resources import ( + AppConnections, + Conversations, + Events, + Files, + Metrics, + Shares, + Tasks, +) + + +class IntrospectionClient: + """REST-only Introspection client (no OpenTelemetry). + + Use :attr:`runtimes` / :attr:`experiments` to drive the CP REST + surface. ``client.runtimes(slug).run()`` and + ``client.experiments(id).run()`` mint a + :class:`~introspection_sdk.runner.Runner` for DP traffic + (``runner.tasks`` / ``runner.files``). + + ``credentials`` replaces the static ``token`` with a + :class:`~introspection_sdk.auth.CredentialProvider` that is asked for + the header on every request and renews it after a ``401`` (see + :meth:`EmailCodeAuth.client `). + ``transport`` is an ``httpx2`` transport used by both planes. + + For the OpenTelemetry-based ``track`` / ``feedback`` / ``identify`` + surface, see :class:`introspection_sdk.IntrospectionLogs` (requires + the ``[otel]`` extra). + """ + + runtimes: Runtimes + experiments: Experiments + recipes: Recipes + repositories: Repositories + connectors: Connectors + members: Members + annotations: Annotations + tags: Tags + automations: Automations + events: Events + tasks: Tasks + files: Files + conversations: Conversations + metrics: Metrics + shares: Shares + connections: AppConnections + + def __init__( + self, + *, + token: str | None = None, + cp_session: str | None = None, + base_api_url: str | None = None, + dp_url: str | None = None, + additional_headers: dict[str, str] | None = None, + credentials: CredentialProvider | None = None, + transport: httpx.BaseTransport | None = None, + ) -> None: + self._token = token or os.getenv("INTROSPECTION_TOKEN", "") + self._base_api_url = base_api_url or os.getenv( + "INTROSPECTION_BASE_API_URL", + "https://api.introspection.dev", + ) + # INTROSPECTION_DEV_TARGET rides every request as a header so it reaches + # the paths a runner cannot: a bare `POST /v1/tasks` with a dev API key + # mints its JWT from the key row and has no per-request claim to carry. + # Resolved once here so every resource client below inherits it. + self._additional_headers = client_headers(additional_headers) + self._http = _HttpClient( + api_url=self._base_api_url, + token=self._token, + cp_session=cp_session, + additional_headers=self._additional_headers, + transport=transport, + credentials=credentials, + ) + self._dp_http = _HttpClient( + api_url=dp_url or self._base_api_url, + token=self._token, + additional_headers=self._additional_headers, + transport=transport, + credentials=credentials, + ) + self.runtimes = Runtimes( + self._http, + additional_headers=self._additional_headers, + ) + self.experiments = Experiments( + self._http, + additional_headers=self._additional_headers, + ) + self.recipes = Recipes(self._http) + self.repositories = Repositories(self._http, self._dp_http) + self.connectors = Connectors(self._http) + self.members = Members(self._http) + self.annotations = Annotations(self._http, self._dp_http) + self.tags = Tags(self._dp_http) + self.automations = Automations(self._dp_http) + self.events = Events(self._dp_http) + self.tasks = Tasks(self._dp_http) + self.files = Files(self._dp_http) + self.conversations = Conversations(self._dp_http) + self.metrics = Metrics(self._dp_http) + self.shares = Shares(self._dp_http) + self.connections = AppConnections(self._dp_http) + + @classmethod + def from_service_account( + cls, + client_id: str, + client_secret: str, + project: str, + *, + scope: str | None = None, + base_api_url: str | None = None, + additional_headers: dict[str, str] | None = None, + transport: httpx.BaseTransport | None = None, + ) -> IntrospectionClient: + """Authenticate as a confidential service account and return a + ready client. + + Mints a short-lived, project-scoped CP access token via the + ``client_credentials`` grant (see + :func:`~introspection_sdk.auth.service_account_token`) and wires + it in as the bearer token, so the runtime flow works exactly as + it does with an API key:: + + client = IntrospectionClient.from_service_account( + client_id=os.environ["INTRO_SA_CLIENT_ID"], + client_secret=os.environ["INTRO_SA_CLIENT_SECRET"], + project=os.environ["INTRO_PROJECT"], + ) + runner = client.runtimes("customer-agent").run() + + The token is not auto-refreshed: it lives for ``expires_in`` + seconds, so re-mint (call this again) for long-lived processes + once it lapses. The resolved Data Plane URL is wired into the + annotation and project-label namespaces automatically. + """ + token = service_account_token( + client_id=client_id, + client_secret=client_secret, + project=project, + scope=scope, + base_api_url=base_api_url, + transport=transport, + ) + return cls( + token=token.access_token, + base_api_url=base_api_url, + dp_url=token.dp_url, + additional_headers=additional_headers, + ) + + def shutdown(self) -> None: + """Graceful shutdown — closes the underlying HTTP client.""" + try: + self._http.close() + self._dp_http.close() + except Exception: # noqa: BLE001 — best-effort cleanup + pass diff --git a/introspection_sdk/_sync/protocols.py b/introspection_sdk/_sync/protocols.py new file mode 100644 index 0000000..fe81f60 --- /dev/null +++ b/introspection_sdk/_sync/protocols.py @@ -0,0 +1,626 @@ +# Generated by scripts/gen_sync.py from introspection_sdk/_async/protocols.py. Do not edit: +# change the async source and run `uv run python scripts/gen_sync.py`. +"""Re-exported by :mod:`introspection_sdk.protocols`.""" + +from __future__ import annotations + +import builtins +from collections.abc import Iterator +from datetime import datetime +from typing import Any, Literal, Protocol, Unpack, runtime_checkable +from uuid import UUID + +from introspection_sdk.pagination import Pager +from introspection_sdk.runner_resources._reads import ( + ArrowPageIterator, + ReadFormat, +) +from introspection_sdk.runner_resources.automations import MetadataInput +from introspection_sdk.runner_resources.conversations import ( + ConversationExportFormat, + ConversationExportParams, + ConversationResolution, + ConversationSentiment, +) +from introspection_sdk.runner_resources.files import FileLike +from introspection_sdk.runner_resources.tasks import RunHandle +from introspection_sdk.schemas.agui import AGUIEvent, ResumeEntry +from introspection_sdk.schemas.automations import ( + Automation, + AutomationKind, + AutomationTriggerResponse, + AutomationTriggerType, +) +from introspection_sdk.schemas.connections import Connection, ConnectPage +from introspection_sdk.schemas.conversations import ( + Conversation, + ConversationItemInclude, + ConversationSortField, + SpanStatus, +) +from introspection_sdk.schemas.events import ( + Event, + EventSortField, + IntrospectionEventName, + UnknownEvent, +) +from introspection_sdk.schemas.files import File, FileType +from introspection_sdk.schemas.genai_span import GenAiSpan, GenAiSpanList +from introspection_sdk.schemas.metrics import ( + MetricQueryRequest, + MetricQueryResponse, +) +from introspection_sdk.schemas.pagination import Paginated +from introspection_sdk.schemas.shares import ( + ResourceShare, + ShareResourceType, +) +from introspection_sdk.schemas.tasks import ( + Task, + TaskCancelRequest, + TaskCancelResponse, + TaskCreateResponse, + TaskFileRef, + TaskPrompt, + TaskRepoRequest, + TaskRun, + TaskRunKind, +) +from introspection_sdk.schemas.trajectory import Trajectory + + +@runtime_checkable +class TaskRunsResource(Protocol): + """Protocol of :class:`~introspection_sdk.runner_resources.tasks.TaskRuns`.""" + + def create( + self, + task_id: str, + *, + prompt: TaskPrompt | dict[str, Any] | None = None, + kind: TaskRunKind | str | None = None, + metadata: dict[str, Any] | None = None, + files: list[TaskFileRef | dict[str, Any]] | None = None, + ) -> RunHandle: ... + + def resume( + self, task_id: str, *, resume: list[ResumeEntry | dict[str, Any]] + ) -> RunHandle: ... + + def get(self, task_id: str, run_id: str) -> TaskRun: ... + + def cancel( + self, + task_id: str, + run_id: str, + options: TaskCancelRequest | dict[str, Any] | None = None, + ) -> TaskCancelResponse: ... + + def stream( + self, + task_id: str, + run_id: str, + *, + max_reconnects: int = 5, + backoff: float = 0.5, + timeout: float = 300.0, + ) -> Iterator[AGUIEvent]: ... + + +@runtime_checkable +class TasksResource(Protocol): + """Protocol of :class:`~introspection_sdk.runner_resources.tasks.Tasks`.""" + + @property + def runs(self) -> TaskRunsResource: ... + + def list( + self, + *, + limit: int = 100, + next: str | None = None, + include_total: bool = False, + statuses: builtins.list[str] | None = None, + require_automation_id: bool | None = None, + tag: str | None = None, + ) -> Pager[Task, Paginated[Task]]: ... + + def create( + self, + *, + title: str | None = None, + prompt: str | None = None, + agent_name: str | None = None, + repositories: builtins.list[TaskRepoRequest | dict[str, Any]] + | None = None, + metadata: dict[str, Any] | None = None, + conversation_metadata: dict[str, str] | None = None, + idle_timeout_seconds: int | None = None, + fork_share_id: str | None = None, + files: builtins.list[TaskFileRef | dict[str, Any]] | None = None, + tags: builtins.list[str] | None = None, + ) -> TaskCreateResponse: ... + + def get(self, task_id: str) -> Task: ... + + def update( + self, + task_id: str, + *, + title: str | None = None, + is_archived: bool | None = None, + metadata: dict[str, Any] | None = None, + tags: builtins.list[str] | None = None, + ) -> Task: ... + + def delete(self, task_id: str) -> None: ... + + def archive(self, task_id: str) -> None: ... + + def unarchive(self, task_id: str) -> None: ... + + def start(self, *, prompt: str, **kwargs: Any) -> RunHandle: ... + + +@runtime_checkable +class FileVersionsResource(Protocol): + """Protocol of :class:`~introspection_sdk.runner_resources.files.FileVersions`.""" + + def list( + self, + file_id: str, + *, + limit: int = 100, + next: str | None = None, + include_total: bool = False, + ) -> Pager[File, Paginated[File]]: ... + + def get(self, file_id: str, version_id: str) -> File: ... + + def create( + self, + file_id: str, + *, + file: FileLike, + name: str | None = None, + file_type: FileType | str = FileType.OTHER, + content_type: str | None = None, + ) -> File: ... + + +@runtime_checkable +class FilesResource(Protocol): + """Protocol of :class:`~introspection_sdk.runner_resources.files.Files`.""" + + @property + def versions(self) -> FileVersionsResource: ... + + def list( + self, + *, + limit: int = 100, + next: str | None = None, + include_total: bool = False, + name: str | None = None, + file_type: FileType | str | None = None, + storage_path: str | None = None, + tag: str | None = None, + metadata: dict[str, str] | None = None, + ) -> Pager[File, Paginated[File]]: ... + + def upload( + self, + *, + file: FileLike, + name: str | None = None, + file_type: FileType | str = FileType.OTHER, + content_type: str | None = None, + metadata: dict[str, Any] | None = None, + tags: builtins.list[str] | None = None, + ) -> File: ... + + def create_text( + self, + *, + name: str, + content: str, + mime_type: str = "text/markdown", + metadata: dict[str, Any] | None = None, + tags: builtins.list[str] | None = None, + ) -> File: ... + + def get(self, file_id: str) -> File: ... + + def update( + self, + file_id: str, + *, + name: str | None = None, + metadata: dict[str, Any] | None = None, + tags: builtins.list[str] | None = None, + ) -> File: ... + + def delete(self, file_id: str) -> None: ... + + def download(self, file_id: str) -> bytes: ... + + def download_stream(self, file_id: str) -> Iterator[bytes]: ... + + +@runtime_checkable +class ConversationItemsResource(Protocol): + """Protocol of :class:`~introspection_sdk.runner_resources.conversations.ConversationItems`.""" + + def list( + self, + conversation_id: str, + *, + limit: int = 100, + next: str | None = None, + include: builtins.list[ConversationItemInclude] | None = None, + agent: str | None = None, + service_name: str | None = None, + operation_name: str | None = None, + start_date: str | None = None, + end_date: str | None = None, + lookback_days: int | None = None, + ) -> Pager[GenAiSpan, GenAiSpanList]: ... + + def get( + self, + conversation_id: str, + item_id: str, + *, + include: builtins.list[ConversationItemInclude] | None = None, + ) -> GenAiSpan: ... + + +@runtime_checkable +class ConversationsResource(Protocol): + """Protocol of :class:`~introspection_sdk.runner_resources.conversations.Conversations`.""" + + @property + def items(self) -> ConversationItemsResource: ... + + def list( + self, + *, + limit: int = 100, + next: str | None = None, + conversation_id: str | None = None, + sort: ConversationSortField | None = None, + direction: Literal["asc", "desc"] | None = None, + model: str | None = None, + agent_name: str | None = None, + status: SpanStatus | None = None, + service_name: str | None = None, + service_names: builtins.list[str] | None = None, + environment: str | None = None, + runtime_id: UUID | None = None, + runtime_group_id: UUID | None = None, + experiment_id: UUID | None = None, + recipe_git_commit_sha: str | None = None, + conversation_ids: builtins.list[str] | None = None, + resolution: ConversationResolution | None = None, + sentiment: ConversationSentiment | None = None, + owner_key: str | None = None, + metadata: dict[str, str] | None = None, + start_date: str | datetime | None = None, + end_date: str | datetime | None = None, + order: Literal["asc", "desc"] | None = None, + start: str | datetime | None = None, + end: str | datetime | None = None, + lookback: str | None = None, + format: ReadFormat = "json", + ) -> Pager[Conversation, Paginated[Conversation]]: ... + + def list_arrow( + self, + *, + limit: int = 100, + next: str | None = None, + conversation_id: str | None = None, + sort: ConversationSortField | None = None, + direction: Literal["asc", "desc"] | None = None, + model: str | None = None, + agent_name: str | None = None, + status: SpanStatus | None = None, + service_name: str | None = None, + service_names: builtins.list[str] | None = None, + environment: str | None = None, + runtime_id: UUID | None = None, + runtime_group_id: UUID | None = None, + experiment_id: UUID | None = None, + recipe_git_commit_sha: str | None = None, + conversation_ids: builtins.list[str] | None = None, + resolution: ConversationResolution | None = None, + sentiment: ConversationSentiment | None = None, + owner_key: str | None = None, + metadata: dict[str, str] | None = None, + start_date: str | datetime | None = None, + end_date: str | datetime | None = None, + order: Literal["asc", "desc"] | None = None, + start: str | datetime | None = None, + end: str | datetime | None = None, + lookback: str | None = None, + ) -> ArrowPageIterator: ... + + def iterate( + self, *, max_items: int | None = None, **kwargs: Any + ) -> Iterator[Conversation]: ... + + def get(self, conversation_id: str) -> Conversation: ... + + def export_json( + self, conversation_id: str, **params: Unpack[ConversationExportParams] + ) -> GenAiSpanList: ... + + def export_stream( + self, + conversation_id: str, + format: ConversationExportFormat, + **params: Unpack[ConversationExportParams], + ) -> Iterator[bytes]: ... + + def export_trajectory( + self, + conversation_id: str, + *, + agent: str | None = None, + service_name: str | None = None, + operation_name: str | None = None, + lookback_days: int | None = None, + start_date: str | datetime | None = None, + end_date: str | datetime | None = None, + ) -> Trajectory: ... + + def export_arrow( + self, + conversation_id: str, + *, + agent: str | None = None, + service_name: str | None = None, + operation_name: str | None = None, + lookback_days: int | None = None, + start_date: str | datetime | None = None, + end_date: str | datetime | None = None, + ) -> Any: ... + + def retrieve( + self, conversation_id: str, item_id: str | None = None + ) -> GenAiSpan | None: ... + + +@runtime_checkable +class EventsResource(Protocol): + """Protocol of :class:`~introspection_sdk.runner_resources.events.Events`.""" + + def list( + self, + event_name: str | IntrospectionEventName, + *, + limit: int = 100, + next: str | None = None, + sort: EventSortField | None = None, + direction: Literal["asc", "desc"] | None = None, + order: Literal["asc", "desc"] | None = None, + start: str | datetime | None = None, + end: str | datetime | None = None, + lookback: str | None = None, + start_date: str | datetime | None = None, + end_date: str | datetime | None = None, + conversation_id: str | None = None, + service_name: str | None = None, + environment: str | None = None, + runtime_group_id: UUID | None = None, + trace_id: str | None = None, + span_id: str | None = None, + owner_key: str | None = None, + event_id: builtins.list[str] | None = None, + conversation_ids: builtins.list[str] | None = None, + lens: str | None = None, + pattern_id: str | UUID | None = None, + include_superseded: bool | None = None, + severities: builtins.list[str] | None = None, + runtime_group_unattributed: bool | None = None, + status: str | None = None, + automation_id: UUID | str | None = None, + task_id: UUID | str | None = None, + format: ReadFormat = "json", + ) -> Pager[Event, Paginated[Event]]: ... + + def get(self, event_id: str) -> Event | UnknownEvent: ... + + def iterate( + self, + event_name: str | IntrospectionEventName, + *, + max_items: int | None = None, + **kwargs: Any, + ) -> Iterator[Event]: ... + + def list_arrow( + self, + event_name: str | IntrospectionEventName, + *, + limit: int = 100, + next: str | None = None, + sort: EventSortField | None = None, + direction: Literal["asc", "desc"] | None = None, + order: Literal["asc", "desc"] | None = None, + start: str | datetime | None = None, + end: str | datetime | None = None, + lookback: str | None = None, + start_date: str | datetime | None = None, + end_date: str | datetime | None = None, + conversation_id: str | None = None, + service_name: str | None = None, + environment: str | None = None, + runtime_group_id: UUID | None = None, + trace_id: str | None = None, + span_id: str | None = None, + owner_key: str | None = None, + event_id: builtins.list[str] | None = None, + conversation_ids: builtins.list[str] | None = None, + lens: str | None = None, + pattern_id: str | UUID | None = None, + include_superseded: bool | None = None, + severities: builtins.list[str] | None = None, + runtime_group_unattributed: bool | None = None, + status: str | None = None, + automation_id: UUID | str | None = None, + task_id: UUID | str | None = None, + ) -> ArrowPageIterator: ... + + +@runtime_checkable +class MetricsResource(Protocol): + """Protocol of :class:`~introspection_sdk.runner_resources.metrics.Metrics`.""" + + def query( + self, request: MetricQueryRequest | dict[str, Any] + ) -> MetricQueryResponse: ... + + +@runtime_checkable +class SharesResource(Protocol): + """Protocol of :class:`~introspection_sdk.runner_resources.shares.Shares`.""" + + def list( + self, + *, + limit: int = 100, + next: str | None = None, + resource_type: ShareResourceType | str | None = None, + resource_id: str | None = None, + granted_member_id: str | None = None, + granted_tag: str | None = None, + created_by_me: bool = False, + granted_to_me: bool = False, + ) -> Pager[ResourceShare, Paginated[ResourceShare]]: ... + + def create( + self, + *, + resource_type: ShareResourceType | str, + resource_id: str, + granted_member_id: str | None = None, + granted_tag: str | None = None, + visible_from: datetime | None = None, + ) -> ResourceShare: ... + + def get(self, share_id: str) -> ResourceShare: ... + + def update( + self, + share_id: str, + *, + visible_from: datetime | None, + ) -> ResourceShare: ... + + def delete(self, share_id: str) -> None: ... + + +@runtime_checkable +class AutomationsResource(Protocol): + """Protocol of :class:`~introspection_sdk.runner_resources.automations.Automations`.""" + + def list( + self, + *, + kind: AutomationKind | str | None = None, + enabled: bool | None = None, + scheduled: bool | None = None, + task_id: UUID | str | None = None, + limit: int | None = None, + next: str | None = None, + ) -> Pager[Automation, Paginated[Automation]]: ... + + def get(self, automation_id: UUID | str) -> Automation: ... + + def create( + self, + *, + name: str, + trigger_type: AutomationTriggerType | str, + description: str | None = None, + cron_schedule: str | None = None, + kind: AutomationKind | str | None = None, + prompt: str | None = None, + runtime_group_id: UUID | None = None, + task_id: UUID | None = None, + next_trigger_at: datetime | None = None, + metadata: MetadataInput | None = None, + enabled: bool | None = None, + ) -> Automation: ... + + def update( + self, + automation_id: UUID | str, + *, + name: str | None = None, + description: str | None = None, + cron_schedule: str | None = None, + prompt: str | None = None, + runtime_group_id: UUID | None = None, + task_id: UUID | None = None, + next_trigger_at: datetime | None = None, + metadata: MetadataInput | None = None, + enabled: bool | None = None, + ) -> Automation: ... + + def delete(self, automation_id: UUID | str) -> None: ... + + def trigger( + self, automation_id: UUID | str + ) -> AutomationTriggerResponse: ... + + +@runtime_checkable +class ConnectionsResource(Protocol): + """Protocol of :class:`~introspection_sdk.runner_resources.connections.AppConnections`.""" + + def list( + self, + *, + member_id: UUID | str | None = None, + app: str | None = None, + limit: int | None = None, + next: str | None = None, + ) -> Pager[Connection, Paginated[Connection]]: ... + + def create(self, *, app: str, runtime: str | UUID) -> ConnectPage: ... + + def get(self, connection_id: UUID | str) -> Connection: ... + + def delete(self, connection_id: UUID | str) -> None: ... + + +@runtime_checkable +class DataPlaneResources(Protocol): + """The data-plane namespaces :class:`~introspection_sdk.IntrospectionClient` + and :class:`~introspection_sdk.runner.Runner` both expose.""" + + @property + def tasks(self) -> TasksResource: ... + + @property + def files(self) -> FilesResource: ... + + @property + def conversations(self) -> ConversationsResource: ... + + @property + def events(self) -> EventsResource: ... + + @property + def metrics(self) -> MetricsResource: ... + + @property + def shares(self) -> SharesResource: ... + + @property + def automations(self) -> AutomationsResource: ... + + @property + def connections(self) -> ConnectionsResource: ... diff --git a/introspection_sdk/_sync/proxy/__init__.py b/introspection_sdk/_sync/proxy/__init__.py new file mode 100644 index 0000000..3a87a69 --- /dev/null +++ b/introspection_sdk/_sync/proxy/__init__.py @@ -0,0 +1,2 @@ +# Generated by scripts/gen_sync.py from introspection_sdk/_async/proxy/__init__.py. Do not edit: +# change the async source and run `uv run python scripts/gen_sync.py`. diff --git a/introspection_sdk/_sync/proxy/_transport.py b/introspection_sdk/_sync/proxy/_transport.py new file mode 100644 index 0000000..4307c4c --- /dev/null +++ b/introspection_sdk/_sync/proxy/_transport.py @@ -0,0 +1,104 @@ +# Generated by scripts/gen_sync.py from introspection_sdk/_async/proxy/_transport.py. Do not edit: +# change the async source and run `uv run python scripts/gen_sync.py`. +"""Re-exported by :mod:`introspection_sdk.proxy._transport`.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from introspection_sdk.proxy._config import ProxyConfig + + +def forward_proxy(http: Any, url: str, ssl_context: Any) -> Any: + """Build a forward proxy whose own TLS trusts the same roots.""" + + if http.URL(url).scheme == "https": + return http.Proxy(url, ssl_context=ssl_context) + return http.Proxy(url) + + +def egress_request( + http: Any, + request: Any, + egress_url: str, + relay_target: str | None = None, +) -> Any: + """Rebuild a request for egress while retaining its upstream authority.""" + + egress = http.URL(egress_url) + target = request.url.copy_with( + scheme=egress.scheme, + host=egress.host, + port=egress.port, + ) + headers = request.headers.copy() + original_authority = request.url.netloc.decode("ascii") + if relay_target: + headers["Host"] = egress.netloc.decode("ascii") + headers["x-introspection-egress-host"] = original_authority + headers["x-introspection-relay-target"] = relay_target + else: + headers["Host"] = original_authority + return http.Request( + request.method, + target, + headers=headers, + stream=request.stream, + extensions=request.extensions, + ) + + +def select_transport( + config: ProxyConfig, + request: Any, + *, + direct: Any, + egress: Any, + forward: Any | None, +) -> tuple[Any, bool]: + """Return the transport and whether the request needs egress rewriting.""" + + host = request.url.host + if config.uses_egress(host): + return egress, True + if forward is not None and not config.bypasses_forward_proxy( + host, request.url.port + ): + return forward, False + return direct, False + + +def handle_request( + http: Any, + config: ProxyConfig, + request: Any, + *, + direct: Any, + egress: Any, + forward: Any | None, + send: Callable[[Any, Any], Any], +) -> Any: + transport, rewrite = select_transport( + config, + request, + direct=direct, + egress=egress, + forward=forward, + ) + if rewrite: + assert config.egress_url is not None + request = egress_request( + http, request, config.egress_url, config.relay_target + ) + return send(transport, request) + + +def unique_transports(*transports: Any | None) -> list[Any]: + return list( + { + id(transport): transport + for transport in transports + if transport is not None + }.values() + ) diff --git a/introspection_sdk/_sync/proxy/httpx.py b/introspection_sdk/_sync/proxy/httpx.py new file mode 100644 index 0000000..33f6d0d --- /dev/null +++ b/introspection_sdk/_sync/proxy/httpx.py @@ -0,0 +1,70 @@ +# Generated by scripts/gen_sync.py from introspection_sdk/_async/proxy/httpx.py. Do not edit: +# change the async source and run `uv run python scripts/gen_sync.py`. +"""Re-exported by :mod:`introspection_sdk.proxy.httpx`.""" + +from __future__ import annotations + +import ssl + +try: + import httpx +except ImportError as error: # pragma: no cover - requires an install variant + raise ImportError( + "Install introspection-sdk[proxy-httpx] to use this adapter" + ) from error + +from introspection_sdk.proxy._config import ProxyConfig +from introspection_sdk.proxy._transport import ( + forward_proxy, + handle_request, + unique_transports, +) + + +class IntrospectionTransport(httpx.BaseTransport): + """Synchronous legacy-``httpx`` endpoint-binding transport.""" + + def __init__( + self, + config: ProxyConfig | None = None, + *, + verify: ssl.SSLContext | bool | str = True, + _direct: httpx.BaseTransport | None = None, + _egress: httpx.BaseTransport | None = None, + _forward: httpx.BaseTransport | None = None, + ) -> None: + self.config = config or ProxyConfig.from_env() + ssl_context = httpx.create_ssl_context(verify=verify) + self._direct = _direct or httpx.HTTPTransport(verify=ssl_context) + self._egress = _egress or httpx.HTTPTransport(verify=ssl_context) + self._forward = _forward + if self._forward is None and self.config.forward_proxy_url: + self._forward = httpx.HTTPTransport( + verify=ssl_context, + proxy=forward_proxy( + httpx, self.config.forward_proxy_url, ssl_context + ), + ) + + @classmethod + def from_env( + cls, *, verify: ssl.SSLContext | bool | str = True + ) -> IntrospectionTransport: + return cls(ProxyConfig.from_env(), verify=verify) + + def handle_request(self, request: httpx.Request) -> httpx.Response: + return handle_request( + httpx, + self.config, + request, + direct=self._direct, + egress=self._egress, + forward=self._forward, + send=lambda transport, value: transport.handle_request(value), + ) + + def close(self) -> None: + for transport in unique_transports( + self._direct, self._egress, self._forward + ): + transport.close() diff --git a/introspection_sdk/_sync/proxy/httpx2.py b/introspection_sdk/_sync/proxy/httpx2.py new file mode 100644 index 0000000..071d728 --- /dev/null +++ b/introspection_sdk/_sync/proxy/httpx2.py @@ -0,0 +1,65 @@ +# Generated by scripts/gen_sync.py from introspection_sdk/_async/proxy/httpx2.py. Do not edit: +# change the async source and run `uv run python scripts/gen_sync.py`. +"""Re-exported by :mod:`introspection_sdk.proxy.httpx2`.""" + +from __future__ import annotations + +import ssl + +import httpx2 as httpx + +from introspection_sdk.proxy._config import ProxyConfig +from introspection_sdk.proxy._transport import ( + forward_proxy, + handle_request, + unique_transports, +) + + +class IntrospectionTransport(httpx.BaseTransport): + """Synchronous ``httpx2`` endpoint-binding transport.""" + + def __init__( + self, + config: ProxyConfig | None = None, + *, + verify: ssl.SSLContext | bool | str = True, + _direct: httpx.BaseTransport | None = None, + _egress: httpx.BaseTransport | None = None, + _forward: httpx.BaseTransport | None = None, + ) -> None: + self.config = config or ProxyConfig.from_env() + ssl_context = httpx.create_ssl_context(verify=verify) + self._direct = _direct or httpx.HTTPTransport(verify=ssl_context) + self._egress = _egress or httpx.HTTPTransport(verify=ssl_context) + self._forward = _forward + if self._forward is None and self.config.forward_proxy_url: + self._forward = httpx.HTTPTransport( + verify=ssl_context, + proxy=forward_proxy( + httpx, self.config.forward_proxy_url, ssl_context + ), + ) + + @classmethod + def from_env( + cls, *, verify: ssl.SSLContext | bool | str = True + ) -> IntrospectionTransport: + return cls(ProxyConfig.from_env(), verify=verify) + + def handle_request(self, request: httpx.Request) -> httpx.Response: + return handle_request( + httpx, + self.config, + request, + direct=self._direct, + egress=self._egress, + forward=self._forward, + send=lambda transport, value: transport.handle_request(value), + ) + + def close(self) -> None: + for transport in unique_transports( + self._direct, self._egress, self._forward + ): + transport.close() diff --git a/introspection_sdk/_sync/resources/__init__.py b/introspection_sdk/_sync/resources/__init__.py new file mode 100644 index 0000000..758c3f4 --- /dev/null +++ b/introspection_sdk/_sync/resources/__init__.py @@ -0,0 +1,2 @@ +# Generated by scripts/gen_sync.py from introspection_sdk/_async/resources/__init__.py. Do not edit: +# change the async source and run `uv run python scripts/gen_sync.py`. diff --git a/introspection_sdk/_sync/resources/connectors.py b/introspection_sdk/_sync/resources/connectors.py new file mode 100644 index 0000000..662a7a6 --- /dev/null +++ b/introspection_sdk/_sync/resources/connectors.py @@ -0,0 +1,567 @@ +# Generated by scripts/gen_sync.py from introspection_sdk/_async/resources/connectors.py. Do not edit: +# change the async source and run `uv run python scripts/gen_sync.py`. +"""Re-exported by :mod:`introspection_sdk.resources.connectors`.""" + +from __future__ import annotations + +import builtins +from typing import Any +from uuid import UUID + +from introspection_sdk._http import _HttpClient +from introspection_sdk.pagination import ( + Pager, + cursor_paginate, +) +from introspection_sdk.schemas.connectors import ( + Connection, + ConnectionAuthorizationPending, + ConnectionBrokerSubjectType, + ConnectionCreateRequest, + ConnectionCreateSubjectType, + ConnectionMissionConstraints, + ConnectionToken, + ConnectionTokenRequest, + ConnectionTokenResult, + Connector, + ConnectorApp, + ConnectorAuthMode, + ConnectorAuthorization, + ConnectorAuthorizeBinding, + ConnectorAuthorizeRequest, + ConnectorCreateRequest, + ConnectorOAuthDiscoveryRequest, + ConnectorOAuthDiscoveryResponse, + ConnectorStatus, + ConnectorUpdateRequest, + PolicyEntity, +) +from introspection_sdk.schemas.pagination import Paginated +from introspection_sdk.schemas.runner import RunnerIdentity + + +def _create_body( + *, + name: str, + provider: str, + auth_mode: ConnectorAuthMode | str, + slug: str | None, + environment: str | None, + agent_member_id: UUID | None, + authorization_endpoint: str | None, + token_endpoint: str | None, + scopes: list[str] | None, + api_hosts: list[str] | None, + client_id: str | None, + client_secret: str | None, + signing_secret: str | None, + metadata: dict[str, Any] | None, + issuer: str | None, + person_server_url: str | None, + application_id: UUID | None, + assertion_audience: str | None, + policy_entities: list[PolicyEntity | dict[str, Any]] | None, + webhook_url: str | None, +) -> dict[str, Any]: + # Loose public inputs (plain str) are coerced by validation: + # str -> ConnectorAuthMode. Secrets ride the body as-is; the API + # stores them encrypted and never returns them. + return ConnectorCreateRequest.model_validate( + { + "name": name, + "provider": provider, + "auth_mode": auth_mode, + "slug": slug, + "environment": environment, + "agent_member_id": agent_member_id, + "authorization_endpoint": authorization_endpoint, + "token_endpoint": token_endpoint, + "scopes": scopes, + "api_hosts": api_hosts, + "client_id": client_id, + "client_secret": client_secret, + "signing_secret": signing_secret, + "metadata": metadata, + "issuer": issuer, + "person_server_url": person_server_url, + "application_id": application_id, + "assertion_audience": assertion_audience, + "policy_entities": policy_entities, + "webhook_url": webhook_url, + } + ).model_dump(mode="json", exclude_none=True) + + +def _update_body( + *, + name: str | None, + agent_member_id: UUID | None, + scopes: list[str] | None, + api_hosts: list[str] | None, + status: ConnectorStatus | str | None, + metadata: dict[str, Any] | None, + webhook_url: str | None, + policy_entities: list[PolicyEntity | dict[str, Any]] | None, + client_secret: str | None, + signing_secret: str | None, +) -> dict[str, Any]: + # `exclude_none` is the "omitted = unchanged" contract: a secret + # left as None never reaches the wire, so it cannot be cleared. + return ConnectorUpdateRequest.model_validate( + { + "name": name, + "agent_member_id": agent_member_id, + "scopes": scopes, + "api_hosts": api_hosts, + "status": status, + "metadata": metadata, + "webhook_url": webhook_url, + "policy_entities": policy_entities, + "client_secret": client_secret, + "signing_secret": signing_secret, + } + ).model_dump(mode="json", exclude_none=True) + + +def _authorize_body( + *, + connector_id: UUID, + runtime: str | UUID | None, + subject: ConnectionBrokerSubjectType | str | None, + return_url: str | None, + expires_in: int | None, + identity: RunnerIdentity | dict[str, Any] | None, + app: str | None, + allow_progressive_scopes: bool, + binding: ConnectorAuthorizeBinding | dict[str, Any] | None, +) -> dict[str, Any]: + return ConnectorAuthorizeRequest.model_validate( + { + "connector_id": connector_id, + "runtime": str(runtime) if runtime is not None else None, + "subject": subject, + "return_url": return_url, + "expires_in": expires_in, + "identity": identity, + "app": app, + "allow_progressive_scopes": allow_progressive_scopes, + "binding": binding, + } + ).model_dump(mode="json", exclude_none=True, exclude_defaults=True) + + +def _connection_create_body( + *, + access_token: str, + subject_type: ConnectionCreateSubjectType | str | None, + scopes_granted: list[str] | None, + refresh_token: str | None, + token_expires_at: Any, +) -> dict[str, Any]: + return ConnectionCreateRequest.model_validate( + { + "access_token": access_token, + "subject_type": subject_type, + "scopes_granted": scopes_granted, + "refresh_token": refresh_token, + "token_expires_at": token_expires_at, + } + ).model_dump(mode="json", exclude_none=True) + + +def _token_body( + *, + connector_id: UUID, + subject: ConnectionBrokerSubjectType | str | None, + action: str | None, + requested_permissions: ConnectionMissionConstraints + | dict[str, Any] + | None, +) -> dict[str, Any]: + return ConnectionTokenRequest.model_validate( + { + "connector_id": connector_id, + "subject": subject, + "action": action, + "requested_permissions": requested_permissions, + } + ).model_dump(mode="json", exclude_none=True, exclude_defaults=True) + + +def _token_result(payload: Any) -> ConnectionTokenResult: + if ( + isinstance(payload, dict) + and payload.get("status") == "authorization_pending" + ): + return ConnectionAuthorizationPending.model_validate(payload) + return ConnectionToken.model_validate(payload) + + +class Connections: + """CP ``/v1/connectors/{connector_id}/connections`` namespace. + + Connections are addressed by connector id and take no ``project`` + parameter. ``revoke`` (not ``delete``) destroys the provider token. + """ + + def __init__(self, http: _HttpClient) -> None: + self._http = http + + def list( + self, + connector_id: UUID, + *, + limit: int | None = None, + next: str | None = None, + ) -> Pager[Connection, Paginated[Connection]]: + """List a connector's connections. Iterate the returned + :class:`Pager` to stream every connection across pages, or call + ``.page()`` for the first page only.""" + + def fetch(cursor: str | None) -> Paginated[Connection]: + params: dict[str, Any] = {"limit": limit, "next": cursor} + payload = self._http.request( + "GET", + f"/v1/connectors/{connector_id}/connections", + params=params, + ) + return Paginated[Connection].model_validate(payload) + + return cursor_paginate(fetch, start=next) + + def create( + self, + connector_id: UUID, + *, + access_token: str, + subject_type: ConnectionCreateSubjectType | str | None = None, + scopes_granted: builtins.list[str] | None = None, + refresh_token: str | None = None, + token_expires_at: Any = None, + ) -> Connection: + """Register a connection with a caller-supplied provider token + (registered mode). Tokens are write-only — stored encrypted, + never returned.""" + payload = self._http.request( + "POST", + f"/v1/connectors/{connector_id}/connections", + json=_connection_create_body( + access_token=access_token, + subject_type=subject_type, + scopes_granted=scopes_granted, + refresh_token=refresh_token, + token_expires_at=token_expires_at, + ), + ) + return Connection.model_validate(payload) + + def get_token( + self, + connector_id: UUID, + *, + subject: ConnectionBrokerSubjectType | str | None = None, + action: str | None = None, + requested_permissions: ConnectionMissionConstraints + | dict[str, Any] + | None = None, + ) -> ConnectionTokenResult: + """Resolve a provider token, or a pending human-approval mission.""" + payload = self._http.request( + "POST", + "/v1/oauth/connections/token", + json=_token_body( + connector_id=connector_id, + subject=subject, + action=action, + requested_permissions=requested_permissions, + ), + ) + return _token_result(payload) + + def get(self, connector_id: UUID, connection_id: UUID) -> Connection: + payload = self._http.request( + "GET", + f"/v1/connectors/{connector_id}/connections/{connection_id}", + ) + return Connection.model_validate(payload) + + def revoke(self, connector_id: UUID, connection_id: UUID) -> None: + """Revoke a connection — destroys the stored provider token.""" + self._http.request( + "DELETE", + f"/v1/connectors/{connector_id}/connections/{connection_id}", + expect="empty", + ) + + +class Connectors: + """CP ``/v1/connectors`` namespace. + + Connector CRUD uses the project carried by the authenticated + credential. Nested ``connections`` and ``authorize`` are addressed + by connector id alone. + """ + + def __init__(self, http: _HttpClient) -> None: + self._http = http + self.connections = Connections(http) + + def list( + self, + *, + limit: int | None = None, + next: str | None = None, + ) -> Pager[Connector, Paginated[Connector]]: + """List connectors. Iterate the returned :class:`Pager` to + stream every connector across pages, or call ``.page()`` for the + first page only.""" + + def fetch(cursor: str | None) -> Paginated[Connector]: + params: dict[str, Any] = { + "limit": limit, + "next": cursor, + } + payload = self._http.request( + "GET", "/v1/connectors", params=params + ) + return Paginated[Connector].model_validate(payload) + + return cursor_paginate(fetch, start=next) + + def create( + self, + *, + name: str, + provider: str, + auth_mode: ConnectorAuthMode | str, + slug: str | None = None, + environment: str | None = None, + agent_member_id: UUID | None = None, + authorization_endpoint: str | None = None, + token_endpoint: str | None = None, + scopes: builtins.list[str] | None = None, + api_hosts: builtins.list[str] | None = None, + client_id: str | None = None, + client_secret: str | None = None, + signing_secret: str | None = None, + metadata: dict[str, Any] | None = None, + issuer: str | None = None, + person_server_url: str | None = None, + application_id: UUID | None = None, + assertion_audience: str | None = None, + policy_entities: builtins.list[PolicyEntity | dict[str, Any]] + | None = None, + webhook_url: str | None = None, + ) -> Connector: + """Create a connector, or replace one with the same ``slug``. + + ``slug`` is unique per project. A repeat create with a live slug + replaces that connector's configuration (name, environment, + endpoints, scopes, api hosts, client id, metadata, ...) and keeps + its ``provider``, ``auth_mode`` and stored secrets, so re-running + a create never duplicates the connector. + + ``client_secret`` / ``signing_secret`` are write-only — never + returned on any read. Pass ``issuer`` to have the server resolve + the OAuth endpoints from ``.well-known`` discovery and, where the + provider supports it, register an OAuth client on its own. A + ``pipedream`` connector requires ``metadata["provider_workspace_id"]`` + — its Pipedream Connect project id (``proj_...``). An ``aauth`` + connector requires ``person_server_url`` and ``application_id``; + ``policy_entities`` are the Cedar entities the egress policy gate + adds to its requests. + """ + payload = self._http.request( + "POST", + "/v1/connectors", + json=_create_body( + name=name, + provider=provider, + auth_mode=auth_mode, + slug=slug, + environment=environment, + agent_member_id=agent_member_id, + authorization_endpoint=authorization_endpoint, + token_endpoint=token_endpoint, + scopes=scopes, + api_hosts=api_hosts, + client_id=client_id, + client_secret=client_secret, + signing_secret=signing_secret, + metadata=metadata, + issuer=issuer, + person_server_url=person_server_url, + application_id=application_id, + assertion_audience=assertion_audience, + policy_entities=policy_entities, + webhook_url=webhook_url, + ), + ) + return Connector.model_validate(payload) + + def list_apps( + self, + connector_id: UUID, + *, + query: str | None = None, + limit: int | None = None, + ) -> builtins.list[ConnectorApp]: + """Search a connector's provider application catalogue.""" + payload = self._http.request( + "GET", + f"/v1/connectors/{connector_id}/apps", + params={"q": query, "limit": limit}, + ) + return [ConnectorApp.model_validate(app) for app in payload["data"]] + + def search_custom_apps( + self, + query: str, + *, + limit: int | None = None, + ) -> builtins.list[ConnectorApp]: + """Search the open MCP registry — the catalogue a custom connector + picks from (``GET /v1/connectors/custom/apps``). + + Takes no connector and no project: it is read before any + connector exists. ``query`` is 2–100 characters; ``limit`` is + 1–50 (server default 20). A listing's ``mcp_url`` is what to pass + to :meth:`discover_oauth` and ``create(issuer=...)``. + """ + payload = self._http.request( + "GET", + "/v1/connectors/custom/apps", + params={"q": query, "limit": limit}, + ) + return [ConnectorApp.model_validate(app) for app in payload["data"]] + + def discover_oauth(self, issuer: str) -> ConnectorOAuthDiscoveryResponse: + """Resolve a provider's OAuth metadata before creating a connector + (``POST /v1/connectors/discover-oauth``). + + ``issuer`` is an authorization-server issuer URL or an MCP server + URL. Discovery may REGISTER an OAuth client with the provider + (dynamic registration) and return its ``client_id`` / + ``client_secret`` — pass those to :meth:`create` so a second + client is not registered. ``create`` with only ``issuer`` also + discovers and registers on its own. Raises + :class:`~introspection_sdk.ValidationError` (400) when discovery + fails. + """ + payload = self._http.request( + "POST", + "/v1/connectors/discover-oauth", + json=ConnectorOAuthDiscoveryRequest(issuer=issuer).model_dump( + mode="json" + ), + ) + return ConnectorOAuthDiscoveryResponse.model_validate(payload) + + def get(self, connector_id: UUID) -> Connector: + payload = self._http.request( + "GET", + f"/v1/connectors/{connector_id}", + ) + return Connector.model_validate(payload) + + def update( + self, + connector_id: UUID, + *, + name: str | None = None, + agent_member_id: UUID | None = None, + scopes: builtins.list[str] | None = None, + api_hosts: builtins.list[str] | None = None, + status: ConnectorStatus | str | None = None, + metadata: dict[str, Any] | None = None, + webhook_url: str | None = None, + policy_entities: builtins.list[PolicyEntity | dict[str, Any]] + | None = None, + client_secret: str | None = None, + signing_secret: str | None = None, + ) -> Connector: + """Update a connector. Only provided fields change; an omitted + ``client_secret`` / ``signing_secret`` stays as it is (write-only + rotation, never a clear). ``policy_entities`` replaces the + connector's Cedar entities.""" + payload = self._http.request( + "PATCH", + f"/v1/connectors/{connector_id}", + json=_update_body( + name=name, + agent_member_id=agent_member_id, + scopes=scopes, + api_hosts=api_hosts, + status=status, + metadata=metadata, + webhook_url=webhook_url, + policy_entities=policy_entities, + client_secret=client_secret, + signing_secret=signing_secret, + ), + ) + return Connector.model_validate(payload) + + def delete(self, connector_id: UUID) -> None: + """Soft-delete a connector; its connections are revoked.""" + self._http.request( + "DELETE", + f"/v1/connectors/{connector_id}", + expect="empty", + ) + + def authorize( + self, + connector_id: UUID, + *, + runtime: str | UUID | None = None, + subject: ConnectionBrokerSubjectType | str | None = None, + return_url: str | None = None, + expires_in: int | None = None, + identity: RunnerIdentity | dict[str, Any] | None = None, + app: str | None = None, + allow_progressive_scopes: bool = False, + binding: ConnectorAuthorizeBinding | dict[str, Any] | None = None, + ) -> ConnectorAuthorization: + """Mint a consent URL for the connector + (``POST /v1/oauth/connections/authorize``). + + Each call writes a fresh single-use ``state``: two calls give + two different URLs, and the response must never be cached. The + URL stays valid for ``expires_in`` seconds (60–86400, default + 600) — raise it when handing the link to someone else to open. A + connector with ``requires_runtime=True`` 422s unless ``runtime`` + (slug or runtime group id) names the agent that replies. + + ``identity`` asserts the end customer the grant is for: its + ``user_id`` resolves a ``customer`` member recorded as the + connection's ``created_by_member_id``, so a partner can associate + the connection with their own caller rather than the agent member + that made the API call. Omit to attribute the grant to the + authenticated principal. Asserting one mints a ``customer`` + member, so it can raise + :class:`~introspection_sdk.ConflictError` (409) when the org has + reached its member limit — a plan conflict, not back-pressure. + + ``binding`` names the MCP endpoint (environment, Recipe MCP + server id, https URL) the grant completes into. It requires + ``runtime``; on a successful grant the control plane writes the + endpoint binding in the same transaction as the connection, so + the runtime is never authorized-but-unbound. + """ + payload = self._http.request( + "POST", + "/v1/oauth/connections/authorize", + json=_authorize_body( + connector_id=connector_id, + runtime=runtime, + subject=subject, + return_url=return_url, + expires_in=expires_in, + identity=identity, + app=app, + allow_progressive_scopes=allow_progressive_scopes, + binding=binding, + ), + ) + return ConnectorAuthorization.model_validate(payload) diff --git a/introspection_sdk/_sync/resources/experiments.py b/introspection_sdk/_sync/resources/experiments.py new file mode 100644 index 0000000..b60c1e9 --- /dev/null +++ b/introspection_sdk/_sync/resources/experiments.py @@ -0,0 +1,251 @@ +# Generated by scripts/gen_sync.py from introspection_sdk/_async/resources/experiments.py. Do not edit: +# change the async source and run `uv run python scripts/gen_sync.py`. +"""Re-exported by :mod:`introspection_sdk.resources.experiments`.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any +from uuid import UUID + +from introspection_sdk._errors import ValidationError +from introspection_sdk._http import _HttpClient +from introspection_sdk.pagination import ( + Pager, + cursor_paginate, +) +from introspection_sdk.runner import Runner +from introspection_sdk.schemas.experiments import Experiment +from introspection_sdk.schemas.pagination import Paginated +from introspection_sdk.schemas.runner import ( + RunCaller, + RunnerIdentity, + RunnerSpec, + RunRequest, +) + + +class Experiments: + """CP ``/v1/experiments`` namespace. + + Also callable: ``client.experiments(id)`` returns an + :class:`ExperimentHandle`. + """ + + def __init__( + self, + http: _HttpClient, + *, + additional_headers: Mapping[str, str] | None = None, + ) -> None: + self._http = http + self._additional_headers = additional_headers + + def __call__(self, experiment_id: UUID) -> ExperimentHandle: + return ExperimentHandle(self, experiment_id=experiment_id) + + # --- reads -------------------------------------------------------- + + def list( + self, + *, + project: str | UUID | None = None, + runtime: str | UUID | None = None, + environment: str | None = None, + status: str | None = None, + limit: int = 100, + next: str | None = None, + ) -> Pager[Experiment, Paginated[Experiment]]: + """List experiments. Iterate the returned :class:`Pager` to stream + every experiment across pages, or call ``.page()`` for the first + page only.""" + + def fetch(cursor: str | None) -> Paginated[Experiment]: + params: dict[str, Any] = { + "project": str(project) if project else None, + "runtime": str(runtime) if runtime is not None else None, + "environment": environment, + "status": status, + "limit": limit, + "next": cursor, + } + payload = self._http.request( + "GET", "/v1/experiments", params=params + ) + return Paginated[Experiment].model_validate(payload) + + return cursor_paginate(fetch, start=next) + + def get( + self, experiment_id: UUID, *, project: str | UUID | None = None + ) -> Experiment: + params: dict[str, Any] = {} + if project: + params["project"] = str(project) + payload = self._http.request( + "GET", + f"/v1/experiments/{experiment_id}", + params=params or None, + ) + return Experiment.model_validate(payload) + + def create( + self, + document: Mapping[str, Any], + *, + project: str | UUID | None = None, + ) -> Experiment: + body = dict(document) + if project is not None: + if "project" in body and "project_id" in body: + raise ValidationError( + "experiment document must not contain both project and project_id", + status_code=422, + code="conflicting_experiment_project", + ) + supplied = body.get("project", body.get("project_id")) + if supplied is not None and str(supplied) != str(project): + raise ValidationError( + "experiment document project conflicts with project", + status_code=422, + code="conflicting_experiment_project", + ) + body.pop("project_id", None) + body["project"] = str(project) + return Experiment.model_validate( + self._http.request("POST", "/v1/experiments", json=body) + ) + + def update( + self, + experiment_id: UUID, + document: Mapping[str, Any], + *, + project: str | UUID | None = None, + ) -> Experiment: + return Experiment.model_validate( + self._http.request( + "PATCH", + f"/v1/experiments/{experiment_id}", + params={ + "project": str(project) if project is not None else None + }, + json=dict(document), + ) + ) + + def delete( + self, + experiment_id: UUID, + *, + project: str | UUID | None = None, + ) -> None: + self._http.request( + "DELETE", + f"/v1/experiments/{experiment_id}", + params={"project": str(project) if project is not None else None}, + expect="empty", + ) + + def _post_run( + self, + experiment_id: UUID, + options: RunRequest, + ) -> RunnerSpec: + body: dict[str, Any] = options.model_dump( + exclude_none=True, mode="json" + ) + payload = self._http.request( + "POST", f"/v1/experiments/{experiment_id}/run", json=body + ) + return RunnerSpec.model_validate(payload) + + def _start(self, experiment_id: UUID) -> Experiment: + payload = self._http.request( + "POST", f"/v1/experiments/{experiment_id}/start" + ) + return Experiment.model_validate(payload) + + def _end(self, experiment_id: UUID) -> Experiment: + payload = self._http.request( + "POST", + f"/v1/experiments/{experiment_id}/end", + ) + return Experiment.model_validate(payload) + + def _cancel(self, experiment_id: UUID) -> Experiment: + payload = self._http.request( + "POST", f"/v1/experiments/{experiment_id}/cancel" + ) + return Experiment.model_validate(payload) + + +class ExperimentHandle: + """Handle for a specific experiment. + + Built by ``client.experiments(id)``. + """ + + def __init__( + self, + experiments: Experiments, + *, + experiment_id: UUID, + ) -> None: + self._experiments = experiments + self._experiment_id = experiment_id + + @property + def experiment_id(self) -> UUID: + return self._experiment_id + + def run( + self, + *, + identity: RunnerIdentity | dict[str, Any] | None = None, + caller: RunCaller | dict[str, Any] | None = None, + agent_name: str | None = None, + ttl_seconds: int | None = 3600, + scope: str | None = None, + ) -> Runner: + ident: RunnerIdentity | None + if identity is None: + ident = None + elif isinstance(identity, RunnerIdentity): + ident = identity + else: + ident = RunnerIdentity.model_validate(identity) + call: RunCaller | None + if caller is None: + call = None + elif isinstance(caller, RunCaller): + call = caller + else: + call = RunCaller.model_validate(caller) + options = RunRequest( + identity=ident, + caller=call, + agent_name=agent_name, + ttl_seconds=ttl_seconds, + scope=scope, + ) + eid = self._experiment_id + + def refresher() -> RunnerSpec: + return self._experiments._post_run(eid, options) + + spec = refresher() + return Runner( + spec, + refresher=refresher, + additional_headers=self._experiments._additional_headers, + ) + + def start(self) -> Experiment: + return self._experiments._start(self._experiment_id) + + def end(self) -> Experiment: + return self._experiments._end(self._experiment_id) + + def cancel(self) -> Experiment: + return self._experiments._cancel(self._experiment_id) diff --git a/introspection_sdk/_sync/resources/members.py b/introspection_sdk/_sync/resources/members.py new file mode 100644 index 0000000..01315aa --- /dev/null +++ b/introspection_sdk/_sync/resources/members.py @@ -0,0 +1,194 @@ +# Generated by scripts/gen_sync.py from introspection_sdk/_async/resources/members.py. Do not edit: +# change the async source and run `uv run python scripts/gen_sync.py`. +"""Re-exported by :mod:`introspection_sdk.resources.members`.""" + +from __future__ import annotations + +import builtins +from typing import Any +from uuid import UUID + +from pydantic import BaseModel + +from introspection_sdk._http import _HttpClient +from introspection_sdk.pagination import ( + Pager, + cursor_paginate, +) +from introspection_sdk.schemas.members import ( + MEMBER_CREATE_ADAPTER, + BusinessMemberCreate, + CustomerMemberCreate, + Member, + MemberType, + MemberUpdateRequest, +) +from introspection_sdk.schemas.pagination import Paginated + + +def _list_params( + *, + project: str | UUID | None, + member_type: MemberType | str | None, + connector_id: UUID | None, + application_idp_id: UUID | None, + tag: str | None, + metadata: dict[str, str] | None, + limit: int | None, + cursor: str | None, +) -> dict[str, Any]: + return { + "project": str(project) if project else None, + "member_type": str(member_type) if member_type else None, + "connector_id": str(connector_id) if connector_id else None, + "application_idp_id": ( + str(application_idp_id) if application_idp_id else None + ), + "tag": tag, + "metadata": ( + [f"{key}:{value}" for key, value in metadata.items()] + if metadata + else None + ), + "limit": limit, + "next": cursor, + } + + +def _create_body( + member: BusinessMemberCreate | CustomerMemberCreate | dict[str, Any], +) -> dict[str, Any]: + # `member_type` has a default, so `exclude_none` always keeps it. + validated = MEMBER_CREATE_ADAPTER.validate_python( + member.model_dump() if isinstance(member, BaseModel) else member + ) + return validated.model_dump(mode="json", exclude_none=True) + + +def _update_body( + *, + name: str | None, + image_url: str | None, + role: str | None, + tags: builtins.list[str] | None, + metadata: dict[str, str] | None, + policy_attributes: dict[str, Any] | None, +) -> dict[str, Any]: + # `exclude_none` drops an omitted field but keeps an explicit [] / {}, + # which is what clears tags / metadata — both replace wholesale. + return MemberUpdateRequest( + name=name, + image_url=image_url, + role=role, + tags=tags, + metadata=metadata, + policy_attributes=policy_attributes, + ).model_dump(mode="json", exclude_none=True) + + +class Members: + """CP ``/v1/members`` namespace.""" + + def __init__(self, http: _HttpClient) -> None: + self._http = http + + def list( + self, + *, + project: str | UUID | None = None, + member_type: MemberType | str | None = None, + connector_id: UUID | None = None, + application_idp_id: UUID | None = None, + tag: str | None = None, + metadata: dict[str, str] | None = None, + limit: int | None = None, + next: str | None = None, + ) -> Pager[Member, Paginated[Member]]: + """List members. Iterate the returned :class:`Pager` to stream every + member across pages, or call ``.page()`` for the first page only. + + Filters narrow and AND together. ``tag`` matches one access-bearing + tag exactly. ``metadata`` matches members whose metadata holds every + pair, each compared exactly against the string value; it is sent as + one repeated ``metadata=key:value`` param per entry (distinct keys, + at most 16). ``project`` is only needed by a credential that is not + already org-scoped (a deployment token).""" + + def fetch(cursor: str | None) -> Paginated[Member]: + params = _list_params( + project=project, + member_type=member_type, + connector_id=connector_id, + application_idp_id=application_idp_id, + tag=tag, + metadata=metadata, + limit=limit, + cursor=cursor, + ) + payload = self._http.request("GET", "/v1/members", params=params) + return Paginated[Member].model_validate(payload) + + return cursor_paginate(fetch, start=next) + + def get( + self, member_id: UUID, *, project: str | UUID | None = None + ) -> Member: + payload = self._http.request( + "GET", + f"/v1/members/{member_id}", + params={"project": str(project) if project else None}, + ) + return Member.model_validate(payload) + + def create( + self, + member: BusinessMemberCreate | CustomerMemberCreate | dict[str, Any], + ) -> Member: + """Create a member: a :class:`BusinessMemberCreate` invites a person + by email (admin or owner only); a :class:`CustomerMemberCreate` + records one of the organization's end users by ``external_user_id`` + (owner only, no ``role``). + + A plain dict is validated through the same union, and one without + ``member_type`` is a business member. ``member_type`` is always + sent. A business ``name`` must be a full name (first and last). + ``tags`` and ``policy_attributes`` decide access, so passing either + needs ``members:manage``. ``metadata`` keys are letters, digits, + ``_`` and ``-``, values are non-empty strings, at most 64 entries + per write; the server answers 422 otherwise.""" + payload = self._http.request( + "POST", "/v1/members", json=_create_body(member) + ) + return Member.model_validate(payload) + + def update( + self, + member_id: UUID, + *, + name: str | None = None, + image_url: str | None = None, + role: str | None = None, + tags: builtins.list[str] | None = None, + metadata: dict[str, str] | None = None, + policy_attributes: dict[str, Any] | None = None, + ) -> Member: + """Update a member (requires ``members:manage``). + + ``tags`` and ``metadata`` each replace wholesale: ``None`` leaves + them untouched, ``[]`` / ``{}`` clears them. To change one metadata + key, read the member, edit its map, and send the whole map back. + ``policy_attributes`` replaces the member's Cedar attributes for the + egress policy gate; ``None`` leaves them untouched.""" + payload = self._http.request( + "PATCH", + f"/v1/members/{member_id}", + json=_update_body( + name=name, + image_url=image_url, + role=role, + tags=tags, + metadata=metadata, + policy_attributes=policy_attributes, + ), + ) + return Member.model_validate(payload) diff --git a/introspection_sdk/_sync/resources/recipes.py b/introspection_sdk/_sync/resources/recipes.py new file mode 100644 index 0000000..6f15c6f --- /dev/null +++ b/introspection_sdk/_sync/resources/recipes.py @@ -0,0 +1,56 @@ +# Generated by scripts/gen_sync.py from introspection_sdk/_async/resources/recipes.py. Do not edit: +# change the async source and run `uv run python scripts/gen_sync.py`. +"""Re-exported by :mod:`introspection_sdk.resources.recipes`.""" + +from __future__ import annotations + +from typing import Any +from uuid import UUID + +from introspection_sdk._http import _HttpClient +from introspection_sdk.pagination import ( + Pager, + cursor_paginate, +) +from introspection_sdk.schemas.pagination import Paginated +from introspection_sdk.schemas.recipes import Recipe + + +class Recipes: + """CP ``/v1/recipes`` namespace.""" + + def __init__(self, http: _HttpClient) -> None: + self._http = http + + # --- reads -------------------------------------------------------- + + def list( + self, + *, + project: str | UUID | None = None, + repository_id: UUID | None = None, + name: str | None = None, + limit: int | None = None, + next: str | None = None, + ) -> Pager[Recipe, Paginated[Recipe]]: + """List recipes. Iterate the returned :class:`Pager` to stream every + recipe across pages, or call ``.page()`` for the first page only.""" + + def fetch(cursor: str | None) -> Paginated[Recipe]: + params: dict[str, Any] = { + "project": str(project) if project else None, + "repository_id": ( + str(repository_id) if repository_id is not None else None + ), + "name": name, + "limit": limit, + "next": cursor, + } + payload = self._http.request("GET", "/v1/recipes", params=params) + return Paginated[Recipe].model_validate(payload) + + return cursor_paginate(fetch, start=next) + + def get(self, recipe_id: UUID) -> Recipe: + payload = self._http.request("GET", f"/v1/recipes/{recipe_id}") + return Recipe.model_validate(payload) diff --git a/introspection_sdk/_sync/resources/repositories.py b/introspection_sdk/_sync/resources/repositories.py new file mode 100644 index 0000000..4181f02 --- /dev/null +++ b/introspection_sdk/_sync/resources/repositories.py @@ -0,0 +1,244 @@ +# Generated by scripts/gen_sync.py from introspection_sdk/_async/resources/repositories.py. Do not edit: +# change the async source and run `uv run python scripts/gen_sync.py`. +"""Re-exported by :mod:`introspection_sdk.resources.repositories`.""" + +from __future__ import annotations + +import builtins +from typing import Any +from urllib.parse import quote +from uuid import UUID + +from pydantic import TypeAdapter + +from introspection_sdk._http import RawResponse, _HttpClient +from introspection_sdk.pagination import ( + Pager, + cursor_paginate, +) +from introspection_sdk.schemas.pagination import Paginated +from introspection_sdk.schemas.repositories import ( + Repository, + RepositoryCommit, + RepositoryCommitDetail, + RepositoryContent, + RepositoryDirectory, + RepositoryEntry, + RepositoryFile, + RepositoryMerge, +) + +_REPOSITORY_LIST = TypeAdapter(list[Repository]) +_CONTENT = TypeAdapter(RepositoryContent) + + +def _contents_path(repository_id: UUID | str, path: str) -> str: + """Percent-encode each segment; ``/`` stays literal. Empty is the root.""" + base = f"/v1/repositories/{repository_id}/contents" + segments = [quote(s, safe="") for s in path.strip("/").split("/") if s] + return f"{base}/{'/'.join(segments)}" if segments else base + + +def _commits_path(repository_id: UUID | str) -> str: + return f"/v1/repositories/{repository_id}/commits" + + +def _commit_path(repository_id: UUID | str, sha: str) -> str: + return f"{_commits_path(repository_id)}/{quote(sha, safe='')}" + + +def _merges_path(repository_id: UUID | str) -> str: + return f"/v1/repositories/{repository_id}/merges" + + +def _merge_body( + base: str, head: str, commit_message: str | None +) -> dict[str, str]: + body = {"base": base, "head": head} + if commit_message is not None: + body["commit_message"] = commit_message + return body + + +def _merge(response: RawResponse) -> RepositoryMerge | None: + """``None`` on 204: ``base`` already contains ``head``.""" + if not response.content: + return None + return RepositoryMerge.model_validate_json(response.content) + + +def _directory(payload: Any, path: str) -> RepositoryDirectory: + content = _CONTENT.validate_python(payload) + if isinstance(content, RepositoryFile): + raise ValueError( + f"{path!r} is a file, not a directory; read it with " + "repositories.contents.get()" + ) + return content + + +def _pager_items(page: RepositoryDirectory) -> list[RepositoryEntry]: + return page.records + + +def _pager_next(page: RepositoryDirectory) -> str | None: + return page.next + + +class RepositoryContents: + """DP ``/v1/repositories/{id}/contents`` namespace. + + Call it to enumerate a directory; use :meth:`get` for one page of a + directory or a file's content. + """ + + def __init__(self, http: _HttpClient) -> None: + self._http = http + + def __call__( + self, + repository_id: UUID | str, + path: str = "", + ref: str | None = None, + limit: int | None = None, + ) -> Pager[RepositoryEntry, RepositoryDirectory]: + """List a directory's entries across pages. + + Iterating raises :class:`ValueError` when ``path`` is a file. + """ + + def fetch(cursor: str | None) -> RepositoryDirectory: + payload = self._http.request( + "GET", + _contents_path(repository_id, path), + params={"ref": ref, "cursor": cursor, "limit": limit}, + ) + return _directory(payload, path) + + return Pager(fetch, items=_pager_items, next_cursor=_pager_next) + + def get( + self, + repository_id: UUID | str, + path: str = "", + ref: str | None = None, + ) -> RepositoryDirectory | RepositoryFile: + """A directory's first page or a file's content, discriminated on + ``type``.""" + payload = self._http.request( + "GET", + _contents_path(repository_id, path), + params={"ref": ref}, + ) + return _CONTENT.validate_python(payload) + + +class RepositoryMerges: + """DP ``/v1/repositories/{id}/merges`` namespace.""" + + def __init__(self, http: _HttpClient) -> None: + self._http = http + + def create( + self, + repository_id: UUID | str, + base: str, + head: str, + commit_message: str | None = None, + ) -> RepositoryMerge | None: + """Merge ``head`` (a branch or a full commit sha) into ``base``. + + Returns the merge commit, or ``None`` when ``base`` already contains + ``head``. A conflict raises :class:`ConflictError` with nothing + changed. A merge still running after 60s raises + :class:`SandboxUnavailableError`; calling again with the same + ``base`` and ``head`` attaches to it. ``commit_message`` defaults + server-side to ``Merge {head} into {base}``. + """ + response = self._http.request( + "POST", + _merges_path(repository_id), + json=_merge_body(base, head, commit_message), + expect="raw", + ) + return _merge(response) + + +class Repositories: + """CP ``/v1/repositories`` namespace, plus DP :attr:`contents`, + commits and :attr:`merges`.""" + + contents: RepositoryContents + merges: RepositoryMerges + + def __init__(self, http: _HttpClient, dp_http: _HttpClient) -> None: + self._http = http + self._dp_http = dp_http + self.contents = RepositoryContents(dp_http) + self.merges = RepositoryMerges(dp_http) + + def list( + self, + *, + project: str | UUID | None = None, + slug: str | None = None, + ) -> builtins.list[Repository]: + """The repositories linked to the project. + + ``slug`` narrows to one: ``owner/repo`` for GitHub, the Runtime slug + for a hosted repository. + """ + payload = self._http.request( + "GET", + "/v1/repositories", + params={"project": project, "slug": slug}, + ) + return _REPOSITORY_LIST.validate_python(payload) + + def get( + self, + repository_id: UUID | str, + *, + project: str | UUID | None = None, + ) -> Repository: + payload = self._http.request( + "GET", + f"/v1/repositories/{repository_id}", + params={"project": project}, + ) + return Repository.model_validate(payload) + + def commits( + self, + repository_id: UUID | str, + sha: str | None = None, + path: str | None = None, + limit: int | None = None, + ) -> Pager[RepositoryCommit, Paginated[RepositoryCommit]]: + """The history from ``sha`` (a branch, tag or commit; the default + branch when omitted) across pages, narrowed to commits touching + ``path`` when set.""" + + def fetch(cursor: str | None) -> Paginated[RepositoryCommit]: + payload = self._dp_http.request( + "GET", + _commits_path(repository_id), + params={ + "sha": sha, + "path": path, + "cursor": cursor, + "limit": limit, + }, + ) + return Paginated[RepositoryCommit].model_validate(payload) + + return cursor_paginate(fetch) + + def commit( + self, repository_id: UUID | str, sha: str + ) -> RepositoryCommitDetail: + """One commit with its changed files and unified diff.""" + payload = self._dp_http.request( + "GET", _commit_path(repository_id, sha) + ) + return RepositoryCommitDetail.model_validate(payload) diff --git a/introspection_sdk/_sync/resources/runtimes.py b/introspection_sdk/_sync/resources/runtimes.py new file mode 100644 index 0000000..13e68fc --- /dev/null +++ b/introspection_sdk/_sync/resources/runtimes.py @@ -0,0 +1,184 @@ +# Generated by scripts/gen_sync.py from introspection_sdk/_async/resources/runtimes.py. Do not edit: +# change the async source and run `uv run python scripts/gen_sync.py`. +"""Re-exported by :mod:`introspection_sdk.resources.runtimes`.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any +from urllib.parse import quote +from uuid import UUID + +from introspection_sdk._errors import NotFoundError +from introspection_sdk._http import _HttpClient +from introspection_sdk.pagination import ( + Pager, + cursor_paginate, +) +from introspection_sdk.resources._runtimes_common import ( + RuntimeHandle, +) +from introspection_sdk.schemas.pagination import Paginated +from introspection_sdk.schemas.runner import ( + RunnerSpec, + RunRequest, +) +from introspection_sdk.schemas.runtimes import Runtime + + +class Runtimes: + """CP ``/v1/runtimes`` namespace. + + Also callable: ``client.runtimes("runtime")`` returns a + :class:`RuntimeHandle` for that runtime. + """ + + def __init__( + self, + http: _HttpClient, + *, + additional_headers: Mapping[str, str] | None = None, + ) -> None: + self._http = http + self._additional_headers = additional_headers + + def __call__( + self, runtime: str | UUID, *, project: str | None = None + ) -> RuntimeHandle: + # The project is scoped by the API key server-side; `project` is an + # explicit per-call override only — there is no client-level default. + return RuntimeHandle( + self, + runtime=runtime, + project=project, + ) + + def handle(self, runtime_id: UUID) -> RuntimeHandle: + """Bind a handle to a concrete ``runtime_id``, skipping resolution. + + ``client.runtimes(selector)`` holds a slug or runtime group id and + resolves it on every use to find what the group currently serves. Pass a + runtime id there and the lookup answers nothing: the ``runtime`` + filter matches slugs and group ids, not the row's own id. Use this + when the id is already known — from ``resolve()``, ``get()``, or a + broker that handed it over. + """ + return RuntimeHandle( + self, + runtime=runtime_id, + project=None, + resolved=True, + ) + + # Runtime lifecycle and version selection are managed by the CLI and + # platform. The SDK intentionally exposes only read, resolve, and run. + + def list( + self, + *, + project: str | None = None, + runtime: str | None = None, + recipe_id: UUID | None = None, + environment: str | None = None, + limit: int = 100, + next: str | None = None, + ) -> Pager[Runtime, Paginated[Runtime]]: + """List runtimes. Iterate the returned :class:`Pager` to stream + every runtime across pages, or call ``.page()`` for the first page + only. + + Pass ``environment`` to restrict to runtimes serving that lane. An + API key already selects its environment, so passing both is a 400.""" + + def fetch(cursor: str | None) -> Paginated[Runtime]: + params: dict[str, Any] = { + "project": project, + "runtime": runtime, + "recipe_id": recipe_id, + "environment": environment, + "limit": limit, + "next": cursor, + } + payload = self._http.request("GET", "/v1/runtimes", params=params) + return Paginated[Runtime].model_validate(payload) + + return cursor_paginate(fetch, start=next) + + def get(self, runtime_id: UUID, *, project: str | None = None) -> Runtime: + payload = self._http.request( + "GET", + f"/v1/runtimes/{runtime_id}", + params={"project": project}, + ) + return Runtime.model_validate(payload) + + def resolve( + self, runtime: str | UUID, *, project: str | None = None + ) -> Runtime: + """Resolve a runtime by slug or runtime group id. + + The standalone form of ``client.runtimes(runtime)`` resolution — + handy for a server broker that resolves a concrete ``runtime_id`` to hand + to a browser client (which talks only to the Data Plane and never + resolves runtimes itself). The project is scoped by the token + server-side; pass ``project`` only to override it. + + The route answers newest-first and has no notion of a single + "active" row for a development credential, so a slug that has + been published more than once resolves to its newest runtime + rather than failing as ambiguous. + + Raises :class:`~introspection_sdk.NotFoundError` if nothing matches + the slug or runtime group id — the same error + :meth:`get` raises for a runtime that is not there, so one + ``except`` covers both ways of asking for one. + """ + page = self.list( + runtime=str(runtime), + limit=1, + project=project, + ).page() + if not page.records: + raise _no_runtime(runtime) + return page.records[0] + + # --- /run -------------------------------------------------------- + + def _post_run( + self, + runtime: UUID | str, + options: RunRequest, + *, + project: str | None = None, + ) -> RunnerSpec: + body: dict[str, Any] = options.model_dump( + exclude_none=True, mode="json" + ) + payload = self._http.request( + "POST", + _run_path(runtime), + params={"project": project}, + json=body, + ) + return RunnerSpec.model_validate(payload) + + +def _no_runtime(runtime: str | UUID) -> NotFoundError: + """The error a resolve-by-selector miss raises. + + A miss here is an empty list page rather than a ``404`` from the route, + so the status has to be supplied. It is still a 404: the caller named a + runtime the project does not serve, which is the same answer + ``GET /v1/runtimes/{id}`` gives, and a caller handling "not there" + should not have to catch a second, unrelated exception type to cover + the selector form. + """ + return NotFoundError( + f"No runtime {str(runtime)!r}", + status_code=404, + code="not_found", + ) + + +def _run_path(runtime: UUID | str) -> str: + return f"/v1/runtimes/{quote(str(runtime), safe='')}/run" diff --git a/introspection_sdk/_sync/resources/tags.py b/introspection_sdk/_sync/resources/tags.py new file mode 100644 index 0000000..072c0d5 --- /dev/null +++ b/introspection_sdk/_sync/resources/tags.py @@ -0,0 +1,105 @@ +# Generated by scripts/gen_sync.py from introspection_sdk/_async/resources/tags.py. Do not edit: +# change the async source and run `uv run python scripts/gen_sync.py`. +"""Re-exported by :mod:`introspection_sdk.resources.tags`.""" + +from __future__ import annotations + +from urllib.parse import quote + +from pydantic import TypeAdapter + +from introspection_sdk._errors import ValidationError +from introspection_sdk._http import _HttpClient +from introspection_sdk.pagination import ( + Pager, + cursor_paginate, +) +from introspection_sdk.schemas.pagination import Paginated +from introspection_sdk.schemas.tags import ( + Tag, + TagCreate, + TagKind, + TagUpdate, +) + +_CREATE: TypeAdapter[TagCreate] = TypeAdapter(TagCreate) + + +def _path(kind: TagKind, name: str) -> str: + # An access tag's name may contain `/`, so it is encoded even though the + # API matches it as a path. + return f"/v1/tags/{kind.value}/{quote(name, safe='')}" + + +def _create_body(tag: TagCreate) -> dict[str, object]: + validated = _CREATE.validate_python(tag) + return validated.model_dump(mode="json", exclude_none=True) + + +def _update_body(kind: TagKind, patch: TagUpdate) -> dict[str, object]: + fields = patch.model_fields_set + if not fields: + raise ValidationError( + "A tag update must name a color or a description", + status_code=422, + code="invalid_tag_update", + ) + if kind is TagKind.ACCESS and "color" in fields: + raise ValidationError( + "An access tag has no color", + status_code=422, + code="invalid_tag_color", + ) + # `exclude_unset` so an omitted description stays omitted: the API reads a + # null as "clear it" and an absent key as "leave it". + return patch.model_dump(mode="json", exclude_unset=True) + + +class Tags: + def __init__(self, http: _HttpClient) -> None: + self._http = http + + def list( + self, + kind: TagKind, + *, + search: str | None = None, + limit: int | None = None, + next: str | None = None, + ) -> Pager[Tag, Paginated[Tag]]: + """List one catalogue. `kind` is required: no listing spans both.""" + + def fetch(cursor: str | None) -> Paginated[Tag]: + payload = self._http.request( + "GET", + "/v1/tags", + params={ + "kind": kind.value, + "search": search, + "limit": limit, + "next": cursor, + }, + ) + return Paginated[Tag].model_validate(payload) + + return cursor_paginate(fetch, start=next) + + def create(self, tag: TagCreate) -> Tag: + """Add a tag. An existing one comes back as it is, a deleted one revives.""" + return Tag.model_validate( + self._http.request("POST", "/v1/tags", json=_create_body(tag)) + ) + + def get(self, kind: TagKind, name: str) -> Tag: + return Tag.model_validate(self._http.request("GET", _path(kind, name))) + + def update(self, kind: TagKind, name: str, patch: TagUpdate) -> Tag: + return Tag.model_validate( + self._http.request( + "PATCH", _path(kind, name), json=_update_body(kind, patch) + ) + ) + + def delete(self, kind: TagKind, name: str) -> None: + """Remove a tag from the catalogue. Shares, member tags and annotations keep theirs.""" + self._http.request("DELETE", _path(kind, name), expect="empty") diff --git a/introspection_sdk/_sync/runner.py b/introspection_sdk/_sync/runner.py new file mode 100644 index 0000000..2d55860 --- /dev/null +++ b/introspection_sdk/_sync/runner.py @@ -0,0 +1,204 @@ +# Generated by scripts/gen_sync.py from introspection_sdk/_async/runner.py. Do not edit: +# change the async source and run `uv run python scripts/gen_sync.py`. +"""Re-exported by :mod:`introspection_sdk.runner`.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from datetime import datetime + +from introspection_sdk._errors import RunnerExpiredError +from introspection_sdk._http import _HttpClient +from introspection_sdk.runner_resources import ( + Automations, + Conversations, + Events, + Files, + Metrics, + RunnerAppConnections, + Shares, + Tasks, +) +from introspection_sdk.schemas.runner import ( + RunnerContext, + RunnerDeployment, + RunnerSpec, +) + + +class Runner: + """Handle for talking to a running runtime/experiment on the DP. + + Constructed indirectly by ``client.runtimes(...).run()`` / + ``client.experiments(...).run()``. Holds a private HTTP client + pointed at ``spec.deployment.endpoint`` with the bearer JWT + picked from ``spec.session_token``. + """ + + def __init__( + self, + spec: RunnerSpec, + *, + refresher: Callable[[], RunnerSpec], + additional_headers: Mapping[str, str] | None = None, + timeout: float = 30.0, + ) -> None: + self._spec = spec + self._refresher = refresher + self._additional_headers = ( + dict(additional_headers) if additional_headers else None + ) + self._timeout = timeout + self._closed = False + self._http = self._build_http(spec) + self._tasks = Tasks(self._http) + self._files = Files(self._http) + self._conversations = Conversations(self._http) + self._events = Events(self._http) + self._metrics = Metrics(self._http) + self._shares = Shares(self._http) + self._automations = Automations(self._http) + self._connections = RunnerAppConnections( + self._http, spec.runtime_context.runtime_group_id + ) + + def _build_http(self, spec: RunnerSpec) -> _HttpClient: + return _HttpClient( + api_url=spec.deployment.endpoint, + token=spec.session_token, + additional_headers=self._additional_headers, + timeout=self._timeout, + ) + + def _check_open(self) -> None: + if self._closed: + raise RunnerExpiredError( + "Runner has been closed; create a new one via " + "client.runtimes(...).run() or client.experiments(...).run().", + status_code=0, + ) + + @property + def tasks(self) -> Tasks: + """DP ``/v1/tasks`` namespace bound to this Runner.""" + self._check_open() + return self._tasks + + @property + def files(self) -> Files: + """DP ``/v1/files`` namespace bound to this Runner.""" + self._check_open() + return self._files + + @property + def conversations(self) -> Conversations: + """Read-only DP ``/v1/conversations`` namespace bound to this Runner.""" + self._check_open() + return self._conversations + + @property + def events(self) -> Events: + """Read-only DP ``/v1/events`` namespace bound to this Runner.""" + self._check_open() + return self._events + + @property + def metrics(self) -> Metrics: + """Read-only DP ``/v1/metrics`` namespace bound to this Runner.""" + self._check_open() + return self._metrics + + @property + def shares(self) -> Shares: + """DP ``/v1/shares`` read-sharing namespace bound to this Runner.""" + self._check_open() + return self._shares + + @property + def automations(self) -> Automations: + """DP ``/v1/automations`` namespace bound to this Runner.""" + self._check_open() + return self._automations + + @property + def connections(self) -> RunnerAppConnections: + """DP ``/v1/connections`` namespace bound to this Runner. ``create`` + defaults ``runtime`` to the runner's runtime group.""" + self._check_open() + return self._connections + + @property + def context(self) -> RunnerContext: + """Resolved runtime/arm/recipe/identity/caller context.""" + return self._spec.runtime_context + + @property + def deployment(self) -> RunnerDeployment: + """DP deployment descriptor (endpoint, slug, region).""" + return self._spec.deployment + + @property + def dp_endpoint(self) -> str: + """Convenience accessor for ``spec.deployment.endpoint``.""" + return self._spec.deployment.endpoint + + @property + def expires_at(self) -> datetime: + return self._spec.expires_at + + @property + def session_id(self) -> str: + return self._spec.session_id + + @property + def spec(self) -> RunnerSpec: + return self._spec + + def refresh(self) -> None: + """Manually re-mint the RunnerSpec via CP ``/run``. + + The DP-side agent-session materializer rotates the access + token transparently, so callers typically don't need this. + It stays as an escape hatch when the caller wants to force + a fresh spec (new identity, new TTL, etc.). Swaps in the + new spec and rebuilds the underlying HTTP client; the + previously rented client is closed best-effort. + """ + self._check_open() + new_spec = self._refresher() + old_http = self._http + self._spec = new_spec + self._http = self._build_http(new_spec) + self._tasks = Tasks(self._http) + self._files = Files(self._http) + self._conversations = Conversations(self._http) + self._events = Events(self._http) + self._metrics = Metrics(self._http) + self._shares = Shares(self._http) + self._automations = Automations(self._http) + self._connections = RunnerAppConnections( + self._http, new_spec.runtime_context.runtime_group_id + ) + try: + old_http.close() + except Exception: # noqa: BLE001 — best-effort cleanup + pass + + def close(self) -> None: + """Mark the Runner closed and tear down its HTTP client. + + Server-side revoke is a follow-up; for now ``close()`` just + flips a local flag so further ``runner.tasks`` / + ``runner.files`` calls fail loudly. + """ + self._closed = True + try: + self._http.close() + except Exception: # noqa: BLE001 — best-effort cleanup + pass + + def __enter__(self) -> Runner: + return self + + def __exit__(self, *_exc_info: object) -> None: + self.close() diff --git a/introspection_sdk/_sync/runner_resources/__init__.py b/introspection_sdk/_sync/runner_resources/__init__.py new file mode 100644 index 0000000..0be054c --- /dev/null +++ b/introspection_sdk/_sync/runner_resources/__init__.py @@ -0,0 +1,2 @@ +# Generated by scripts/gen_sync.py from introspection_sdk/_async/runner_resources/__init__.py. Do not edit: +# change the async source and run `uv run python scripts/gen_sync.py`. diff --git a/introspection_sdk/_sync/runner_resources/_reads.py b/introspection_sdk/_sync/runner_resources/_reads.py new file mode 100644 index 0000000..88d2ab1 --- /dev/null +++ b/introspection_sdk/_sync/runner_resources/_reads.py @@ -0,0 +1,202 @@ +# Generated by scripts/gen_sync.py from introspection_sdk/_async/runner_resources/_reads.py. Do not edit: +# change the async source and run `uv run python scripts/gen_sync.py`. +"""Re-exported by :mod:`introspection_sdk.runner_resources._reads`.""" + +from __future__ import annotations + +import re +from collections.abc import Callable, Iterator +from datetime import UTC, datetime, timedelta +from types import ModuleType +from typing import TYPE_CHECKING, Any, Literal, TypeVar + +from pydantic import BaseModel + +from introspection_sdk._http import RawResponse +from introspection_sdk.schemas.pagination import Paginated + +if TYPE_CHECKING: # pragma: no cover - typing only + import pyarrow as pa + +T = TypeVar("T", bound=BaseModel) + +#: Response representation for a list read: the default JSON envelope or the +#: columnar Arrow IPC stream. +ReadFormat = Literal["json", "arrow"] + +#: Media type of the Arrow IPC *streaming* format (schema message + record +#: batches + EOS) — matches the DP ``serialization.ARROW_STREAM_MEDIA_TYPE``. +ARROW_STREAM_MEDIA_TYPE = "application/vnd.apache.arrow.stream" + +#: Base media type of a trajectory-v1 conversation export. Callers append +#: the ``version`` parameter; a server that does not implement the requested +#: version answers ``406`` rather than silently serving a different shape. +TRAJECTORY_MEDIA_TYPE = "application/vnd.letta.trajectory+json" + +#: Accept header selecting the pinned trajectory-v1 export representation. +TRAJECTORY_ACCEPT_HEADERS = {"Accept": f"{TRAJECTORY_MEDIA_TYPE};version=1"} + +#: Accept header injected on the Arrow path. +ARROW_ACCEPT_HEADERS: dict[str, str] = {"Accept": ARROW_STREAM_MEDIA_TYPE} + +LOOKBACK_UNITS: dict[str, int] = { + "s": 1, + "m": 60, + "h": 3600, + "d": 86400, + "w": 604800, +} +LOOKBACK_RE = re.compile(r"^\s*(\d+)\s*([smhdw])\s*$") + + +def parse_lookback(lookback: str) -> timedelta: + """Parse a relative window like ``"24h"`` / ``"7d"`` into a timedelta. + + Accepts a positive integer followed by one of ``s`` (seconds), ``m`` + (minutes), ``h`` (hours), ``d`` (days), or ``w`` (weeks). Raises + ``ValueError`` on anything else. + """ + match = LOOKBACK_RE.match(lookback) + if match is None: + raise ValueError( + f"invalid lookback {lookback!r}; expected e.g. '24h', '7d', '30m'" + ) + amount = int(match.group(1)) + if amount <= 0: + raise ValueError(f"lookback must be positive, got {lookback!r}") + return timedelta(seconds=amount * LOOKBACK_UNITS[match.group(2)]) + + +def resolve_window( + *, + start: str | datetime | None = None, + end: str | datetime | None = None, + lookback: str | None = None, + start_date: str | datetime | None = None, + end_date: str | datetime | None = None, +) -> tuple[str | datetime | None, str | datetime | None]: + """Fold the ergonomic window aliases into ``(start_date, end_date)``. + + ``start`` / ``end`` are aliases for the explicit ``start_date`` / + ``end_date``; ``lookback`` computes ``start_date = now - lookback``. + ``lookback`` is mutually exclusive with ``start`` / ``end`` / + ``start_date`` / ``end_date`` — passing both raises ``ValueError`` + before any request goes out. + """ + resolved_start = start if start is not None else start_date + resolved_end = end if end is not None else end_date + if lookback is not None: + if resolved_start is not None or resolved_end is not None: + raise ValueError( + "lookback is mutually exclusive with start/end " + "(start_date/end_date)" + ) + return datetime.now(UTC) - parse_lookback(lookback), None + return resolved_start, resolved_end + + +def import_pyarrow() -> ModuleType: + """Import ``pyarrow`` lazily, steering the caller at the ``[arrow]`` + extra when it is not installed.""" + try: + import pyarrow as pa + except ModuleNotFoundError as exc: # pragma: no cover - import guard + raise ImportError( + "format='arrow' requires the 'pyarrow' package. Install it with " + "`pip install pyarrow` (or `pip install introspection-sdk[arrow]`)." + ) from exc + return pa + + +def next_cursor_header(raw: RawResponse) -> str | None: + """Read the opaque next-page token off an Arrow list response.""" + headers = raw.headers + return headers.get("X-Next-Cursor") or headers.get("x-next-cursor") or None + + +def decode_arrow_table(raw: RawResponse) -> pa.Table | None: + """Decode an Arrow IPC stream body into one ``pyarrow.Table``. + + Returns ``None`` for an empty body (an empty page carries no schema + message at all).""" + pa = import_pyarrow() + if not raw.content: + return None + with pa.ipc.open_stream(pa.BufferReader(raw.content)) as reader: + return reader.read_all() + + +def decode_arrow_page( + raw: RawResponse, + validate: Callable[[dict[str, Any]], T | None], +) -> Paginated[T]: + """Decode an Arrow IPC stream list response into ``Paginated[T]``. + + ``validate`` maps one decoded row dict onto a record model (e.g. a + model's ``model_validate`` or a ``TypeAdapter.validate_python``); it + may return ``None`` to skip a row (unknown-family tolerance). Row + values come from the columnar body — nested struct columns (the typed + event ``payload``) decode to nested dicts via ``to_pylist()``, so the + same validator serves JSON and Arrow. ``next`` / ``count`` / + ``total_count`` are read back from the ``X-Next-Cursor`` / + ``X-Result-Count`` / ``X-Total-Count`` response headers so the resulting + envelope is indistinguishable from the JSON path and pages identically. + """ + table = decode_arrow_table(raw) + rows: list[dict[str, Any]] = table.to_pylist() if table is not None else [] + records = [ + record + for record in (validate(row) for row in rows) + if record is not None + ] + + headers = raw.headers + count_header = headers.get("X-Result-Count") or headers.get( + "x-result-count" + ) + total_header = headers.get("X-Total-Count") or headers.get("x-total-count") + page: Paginated[T] = Paginated( + records=records, + count=int(count_header) if count_header is not None else len(records), + total_count=int(total_header) if total_header is not None else None, + next=next_cursor_header(raw), + ) + return page + + +class ArrowPageIterator: + """Lazily iterate a list read as one ``pyarrow.Table`` per page. + + Yields each server page as a Table (constant memory — one page held + at a time), driving the same opaque ``X-Next-Cursor`` token the JSON + pager uses. Empty pages (no body) yield nothing. :meth:`read_all` + concatenates every page into a single Table. + """ + + def __init__( + self, + fetch: Callable[[str | None], RawResponse], + *, + start: str | None = None, + ) -> None: + self._fetch = fetch + self._start = start + + def __iter__(self) -> Iterator[pa.Table]: + cursor = self._start + while True: + raw = self._fetch(cursor) + table = decode_arrow_table(raw) + if table is not None: + yield table + cursor = next_cursor_header(raw) + if cursor is None: + return + + def read_all(self) -> pa.Table: + """Fetch every page and concatenate into one ``pyarrow.Table``.""" + pa = import_pyarrow() + tables = [table for table in self] + if not tables: + return pa.table({}) + return pa.concat_tables(tables) diff --git a/introspection_sdk/_sync/runner_resources/automations.py b/introspection_sdk/_sync/runner_resources/automations.py new file mode 100644 index 0000000..10487d3 --- /dev/null +++ b/introspection_sdk/_sync/runner_resources/automations.py @@ -0,0 +1,260 @@ +# Generated by scripts/gen_sync.py from introspection_sdk/_async/runner_resources/automations.py. Do not edit: +# change the async source and run `uv run python scripts/gen_sync.py`. +"""Re-exported by :mod:`introspection_sdk.runner_resources.automations`.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any +from urllib.parse import quote +from uuid import UUID + +from introspection_sdk._http import _HttpClient +from introspection_sdk.pagination import ( + Pager, + cursor_paginate, +) +from introspection_sdk.schemas.automations import ( + Automation, + AutomationCreateRequest, + AutomationKind, + AutomationMetadata, + AutomationTriggerResponse, + AutomationTriggerType, + AutomationUpdateRequest, +) +from introspection_sdk.schemas.pagination import Paginated + +MetadataInput = AutomationMetadata | dict[str, Any] + + +def _path(automation_id: UUID | str) -> str: + return f"/v1/automations/{quote(str(automation_id), safe='')}" + + +def _list_params( + *, + kind: AutomationKind | str | None, + enabled: bool | None, + scheduled: bool | None, + task_id: UUID | str | None, + limit: int | None, + cursor: str | None, +) -> dict[str, Any]: + return { + "limit": limit, + "next": cursor, + "kind": str(kind) if kind else None, + "enabled": enabled, + "scheduled": scheduled, + "task_id": str(task_id) if task_id else None, + } + + +def _uuid(value: UUID | str | None) -> UUID | None: + return value if value is None or isinstance(value, UUID) else UUID(value) + + +def _metadata(metadata: MetadataInput | None) -> dict[str, Any] | None: + if isinstance(metadata, AutomationMetadata): + return metadata.model_dump(mode="json", exclude_none=True) + return metadata + + +def _create_body( + *, + name: str, + trigger_type: AutomationTriggerType | str, + description: str | None, + cron_schedule: str | None, + kind: AutomationKind | str | None, + prompt: str | None, + runtime_group_id: UUID | str | None, + task_id: UUID | str | None, + next_trigger_at: datetime | None, + metadata: MetadataInput | None, + enabled: bool | None, +) -> dict[str, Any]: + return AutomationCreateRequest( + name=name, + trigger_type=trigger_type, + description=description, + cron_schedule=cron_schedule, + kind=kind, + prompt=prompt, + runtime_group_id=_uuid(runtime_group_id), + task_id=_uuid(task_id), + next_trigger_at=next_trigger_at, + metadata=_metadata(metadata), + enabled=enabled, + ).model_dump(mode="json", exclude_none=True) + + +def _update_body( + *, + name: str | None, + description: str | None, + cron_schedule: str | None, + prompt: str | None, + runtime_group_id: UUID | str | None, + task_id: UUID | str | None, + next_trigger_at: datetime | None, + metadata: MetadataInput | None, + enabled: bool | None, +) -> dict[str, Any]: + # `exclude_none` is the whole PATCH contract: an unset field is absent, + # and the server leaves an absent field as it is. + return AutomationUpdateRequest( + name=name, + description=description, + cron_schedule=cron_schedule, + prompt=prompt, + runtime_group_id=_uuid(runtime_group_id), + task_id=_uuid(task_id), + next_trigger_at=next_trigger_at, + metadata=_metadata(metadata), + enabled=enabled, + ).model_dump(mode="json", exclude_none=True) + + +class Automations: + """DP ``/v1/automations`` namespace.""" + + def __init__(self, http: _HttpClient) -> None: + self._http = http + + def list( + self, + *, + kind: AutomationKind | str | None = None, + enabled: bool | None = None, + scheduled: bool | None = None, + task_id: UUID | str | None = None, + limit: int | None = None, + next: str | None = None, + ) -> Pager[Automation, Paginated[Automation]]: + """List the project's automations. Iterate the returned + :class:`Pager` to stream every automation across pages, or call + ``.page()`` for the first page only. + + Filters AND together. ``scheduled=True`` keeps only automations with + a next slot, ``False`` only those without. ``task_id`` keeps those + that post into one task; the server accepts it only once + introspection-cloud#3137 ships. ``limit`` is the page size (1-1000, + server default 100).""" + + def fetch(cursor: str | None) -> Paginated[Automation]: + params = _list_params( + kind=kind, + enabled=enabled, + scheduled=scheduled, + task_id=task_id, + limit=limit, + cursor=cursor, + ) + payload = self._http.request( + "GET", "/v1/automations", params=params + ) + return Paginated[Automation].model_validate(payload) + + return cursor_paginate(fetch, start=next) + + def get(self, automation_id: UUID | str) -> Automation: + """Read one automation, soft-deleted ones included.""" + return Automation.model_validate( + self._http.request("GET", _path(automation_id)) + ) + + def create( + self, + *, + name: str, + trigger_type: AutomationTriggerType | str, + description: str | None = None, + cron_schedule: str | None = None, + kind: AutomationKind | str | None = None, + prompt: str | None = None, + runtime_group_id: UUID | str | None = None, + task_id: UUID | str | None = None, + next_trigger_at: datetime | None = None, + metadata: MetadataInput | None = None, + enabled: bool | None = None, + ) -> Automation: + """Create an automation. + + Omit ``kind`` for a prompt automation, which then needs ``prompt`` + and a ``runtime_group_id``. ``task_id`` posts each firing into that + existing task instead of creating one. A one-off reminder is + ``trigger_type="manual"`` with a future, timezone-aware + ``next_trigger_at``; a ``cron`` automation needs ``cron_schedule`` + (or ``metadata.cron_schedules``) and must not send a slot. + ``metadata`` takes an :class:`AutomationMetadata` or a plain dict. + """ + payload = self._http.request( + "POST", + "/v1/automations", + json=_create_body( + name=name, + trigger_type=trigger_type, + description=description, + cron_schedule=cron_schedule, + kind=kind, + prompt=prompt, + runtime_group_id=runtime_group_id, + task_id=task_id, + next_trigger_at=next_trigger_at, + metadata=metadata, + enabled=enabled, + ), + ) + return Automation.model_validate(payload) + + def update( + self, + automation_id: UUID | str, + *, + name: str | None = None, + description: str | None = None, + cron_schedule: str | None = None, + prompt: str | None = None, + runtime_group_id: UUID | str | None = None, + task_id: UUID | str | None = None, + next_trigger_at: datetime | None = None, + metadata: MetadataInput | None = None, + enabled: bool | None = None, + ) -> Automation: + """Update an automation. Only the fields you pass are sent; ``None`` + leaves a field as it is, so nothing can be cleared. ``kind`` and + ``trigger_type`` are immutable and ``metadata`` replaces wholesale. + ``enabled=False`` pauses and keeps the slot.""" + payload = self._http.request( + "PATCH", + _path(automation_id), + json=_update_body( + name=name, + description=description, + cron_schedule=cron_schedule, + prompt=prompt, + runtime_group_id=runtime_group_id, + task_id=task_id, + next_trigger_at=next_trigger_at, + metadata=metadata, + enabled=enabled, + ), + ) + return Automation.model_validate(payload) + + def delete(self, automation_id: UUID | str) -> None: + """Soft-delete an automation. A project default + (``observation_synthesis``, ``project_check_in``) cannot be deleted + and answers 409; disable it instead.""" + self._http.request("DELETE", _path(automation_id), expect="empty") + + def trigger(self, automation_id: UUID | str) -> AutomationTriggerResponse: + """Run an automation now (``POST /v1/automations/{id}/trigger``). + + Returns the task it created or posted into. A ``skipped`` status + carries its ``reason``. Neither reads nor clears a scheduled slot. + """ + payload = self._http.request("POST", f"{_path(automation_id)}/trigger") + return AutomationTriggerResponse.model_validate(payload) diff --git a/introspection_sdk/_sync/runner_resources/connections.py b/introspection_sdk/_sync/runner_resources/connections.py new file mode 100644 index 0000000..433783e --- /dev/null +++ b/introspection_sdk/_sync/runner_resources/connections.py @@ -0,0 +1,142 @@ +# Generated by scripts/gen_sync.py from introspection_sdk/_async/runner_resources/connections.py. Do not edit: +# change the async source and run `uv run python scripts/gen_sync.py`. +"""Re-exported by :mod:`introspection_sdk.runner_resources.connections`.""" + +from __future__ import annotations + +from typing import Any +from urllib.parse import quote +from uuid import UUID + +from introspection_sdk._http import _HttpClient +from introspection_sdk.pagination import ( + Pager, + cursor_paginate, +) +from introspection_sdk.schemas.connections import ( + Connection, + ConnectionCreateRequest, + ConnectPage, +) +from introspection_sdk.schemas.pagination import Paginated + +CONNECTIONS_PATH = "/v1/connections" + + +def _path(connection_id: UUID | str) -> str: + return f"{CONNECTIONS_PATH}/{quote(str(connection_id), safe='')}" + + +def _list_params( + *, + member_id: UUID | str | None, + app: str | None, + limit: int | None, + cursor: str | None, +) -> dict[str, Any]: + return { + "member_id": str(member_id) if member_id else None, + "app": app, + "limit": limit, + "next": cursor, + } + + +def _create_body(app: str, runtime: str | UUID) -> dict[str, Any]: + return ConnectionCreateRequest(app=app, runtime=str(runtime)).model_dump( + mode="json" + ) + + +def _runner_runtime( + runtime: str | UUID | None, runtime_group_id: UUID | None +) -> str | UUID: + if runtime is not None: + return runtime + if runtime_group_id is None: + raise ValueError( + "This runner's runtime context has no runtime_group_id, so " + "runner.connections.create() cannot say which runtime's sessions " + "use the connection. Pass runtime= explicitly, or open the " + "runner from a runtime with client.runtimes(...).run()." + ) + return runtime_group_id + + +class AppConnections: + """DP ``/v1/connections`` namespace.""" + + def __init__(self, http: _HttpClient) -> None: + self._http = http + + def list( + self, + *, + member_id: UUID | str | None = None, + app: str | None = None, + limit: int | None = None, + next: str | None = None, + ) -> Pager[Connection, Paginated[Connection]]: + """List connections. Iterate the returned :class:`Pager` to stream + every connection across pages, or call ``.page()`` for the first + page only. + + ``member_id`` narrows to one member's connections; a caller who is + not a project administrator always gets only their own. ``app`` + narrows to one provider application slug.""" + + def fetch(cursor: str | None) -> Paginated[Connection]: + params = _list_params( + member_id=member_id, app=app, limit=limit, cursor=cursor + ) + payload = self._http.request( + "GET", CONNECTIONS_PATH, params=params + ) + return Paginated[Connection].model_validate(payload) + + return cursor_paginate(fetch, start=next) + + def create(self, *, app: str, runtime: str | UUID) -> ConnectPage: + """A connect page for one app, for the caller themself. + + ``app`` is the provider application slug, such as ``gmail`` + (lowercase letters, digits, ``_`` and ``-``). ``runtime`` is the + runtime slug or runtime group id whose sessions use the connection. + Hand the member :attr:`ConnectPage.authorize_url`; it ends on a page + saying the app is connected. + """ + payload = self._http.request( + "POST", CONNECTIONS_PATH, json=_create_body(app, runtime) + ) + return ConnectPage.model_validate(payload) + + def get(self, connection_id: UUID | str) -> Connection: + """Read one connection.""" + return Connection.model_validate( + self._http.request("GET", _path(connection_id)) + ) + + def delete(self, connection_id: UUID | str) -> None: + """Remove one connection.""" + self._http.request("DELETE", _path(connection_id), expect="empty") + + +class RunnerAppConnections(AppConnections): + """``runner.connections``: :class:`AppConnections` whose ``create`` + defaults ``runtime`` to the runner's runtime group.""" + + def __init__( + self, http: _HttpClient, runtime_group_id: UUID | None + ) -> None: + super().__init__(http) + self._runtime_group_id = runtime_group_id + + def create( + self, *, app: str, runtime: str | UUID | None = None + ) -> ConnectPage: + """A connect page for one app, used by this runner's runtime group + unless ``runtime`` names another. Raises :class:`ValueError` when + neither is known.""" + return super().create( + app=app, runtime=_runner_runtime(runtime, self._runtime_group_id) + ) diff --git a/introspection_sdk/_sync/runner_resources/conversations.py b/introspection_sdk/_sync/runner_resources/conversations.py new file mode 100644 index 0000000..7fc8453 --- /dev/null +++ b/introspection_sdk/_sync/runner_resources/conversations.py @@ -0,0 +1,700 @@ +# Generated by scripts/gen_sync.py from introspection_sdk/_async/runner_resources/conversations.py. Do not edit: +# change the async source and run `uv run python scripts/gen_sync.py`. +"""Re-exported by :mod:`introspection_sdk.runner_resources.conversations`.""" + +from __future__ import annotations + +import builtins +from collections.abc import Iterator +from datetime import datetime +from typing import Any, Literal, Unpack +from urllib.parse import quote +from uuid import UUID + +from pydantic import TypeAdapter + +from introspection_sdk._http import RawResponse, _HttpClient +from introspection_sdk.pagination import ( + Pager, + cursor_paginate, +) +from introspection_sdk.runner_resources._conversations_common import ( + ConversationExportParams, +) +from introspection_sdk.runner_resources._reads import ( + ARROW_ACCEPT_HEADERS, + TRAJECTORY_ACCEPT_HEADERS, + ArrowPageIterator, + ReadFormat, + decode_arrow_page, + decode_arrow_table, + resolve_window, +) +from introspection_sdk.schemas.conversations import ( + Conversation, + ConversationItemInclude, + ConversationSortField, + SpanStatus, +) +from introspection_sdk.schemas.genai_span import GenAiSpan, GenAiSpanList +from introspection_sdk.schemas.pagination import Paginated +from introspection_sdk.schemas.trajectory import Trajectory, TrajectoryRecord + +#: Validates a decoded export body into the trajectory-v1 record array. +TRAJECTORY_ADAPTER: TypeAdapter[list[TrajectoryRecord]] = TypeAdapter( + list[TrajectoryRecord] +) + +ConversationExportFormat = Literal["json", "trajectory", "arrow"] +"""Wire representation accepted by the complete conversation export route.""" + + +JSON_EXPORT_HEADERS = {"Accept": "application/json"} + + +#: How many items ``retrieve()`` scans, newest-first, looking for the latest +#: LLM turn. The scan used to be unbounded: it drove the auto-paging +#: ``Pager`` and, on a conversation whose recent items are all tool or +#: retrieval spans, walked every turn ever recorded -- one request per page, +#: for a lookup the caller expects to cost one or two. The turn it wants is +#: at the head of a descending list; if it is not in the first page, the +#: caller wants ``items.list()`` and their own predicate, not this heuristic. +#: Deliberately below the list route's default page size, so the bound is the +#: request the SDK asks for and not just a local slice of a larger page. +LATEST_TURN_SCAN_LIMIT = 50 + + +def build_export_params( + *, + agent: str | None = None, + service_name: str | None = None, + operation_name: str | None = None, + lookback_days: int | None = None, + start_date: str | datetime | None = None, + end_date: str | datetime | None = None, +) -> dict[str, Any]: + """Query params for the conversation export route. + + The export is assembled server-side over the whole conversation, so + there is no cursor or page bound here: every param filters what gets + assembled. + + ``start_date`` / ``end_date`` bound which records are assembled. Unlike + the list routes, this route takes them under their wire names with no + ergonomic ``start`` / ``end`` / ``lookback`` aliases, because the route's + own relative window is the separate ``lookback_days`` integer. + """ + params: dict[str, Any] = {} + if agent is not None: + params["agent"] = agent + if service_name is not None: + params["service_name"] = service_name + if operation_name is not None: + params["operation_name"] = operation_name + if lookback_days is not None: + params["lookback_days"] = lookback_days + if start_date is not None: + params["start_date"] = start_date + if end_date is not None: + params["end_date"] = end_date + return params + + +def _export_path(conversation_id: str) -> str: + return f"/v1/conversations/{quote(conversation_id, safe='')}/export" + + +def _export_headers(format: ConversationExportFormat) -> dict[str, str]: + if format == "trajectory": + return TRAJECTORY_ACCEPT_HEADERS + if format == "arrow": + return ARROW_ACCEPT_HEADERS + return JSON_EXPORT_HEADERS + + +ConversationResolution = Literal[ + "resolved", "blocked", "unresolved", "pending" +] +"""``resolution`` filter on the conversations list.""" + +ConversationSentiment = Literal["positive", "negative", "mixed", "neutral"] +"""``sentiment`` filter on the conversations list.""" + + +def build_conversation_params( + *, + limit: int = 100, + cursor: str | None = None, + conversation_id: str | None = None, + sort: ConversationSortField | None = None, + direction: Literal["asc", "desc"] | None = None, + model: str | None = None, + agent_name: str | None = None, + status: SpanStatus | None = None, + service_name: str | None = None, + service_names: builtins.list[str] | None = None, + environment: str | None = None, + runtime_id: UUID | None = None, + runtime_group_id: UUID | None = None, + experiment_id: UUID | None = None, + recipe_git_commit_sha: str | None = None, + conversation_ids: builtins.list[str] | None = None, + resolution: ConversationResolution | None = None, + sentiment: ConversationSentiment | None = None, + owner_key: str | None = None, + metadata: dict[str, str] | None = None, + start_date: str | datetime | None = None, + end_date: str | datetime | None = None, +) -> dict[str, Any]: + """Fold the shared conversations list/arrow kwargs into query params.""" + return { + "limit": limit, + "next": cursor, + "conversation_id": conversation_id, + "sort": sort, + "direction": direction, + "model": model, + "agent_name": agent_name, + "status": status, + "service_name": service_name, + "service_names": service_names, + "environment": environment, + "runtime_id": runtime_id, + "runtime_group_id": runtime_group_id, + "experiment_id": experiment_id, + "recipe_git_commit_sha": recipe_git_commit_sha, + "conversation_ids": conversation_ids, + "resolution": resolution, + "sentiment": sentiment, + "owner_key": owner_key, + "metadata": ( + [f"{key}:{value}" for key, value in metadata.items()] + if metadata + else None + ), + "start_date": start_date, + "end_date": end_date, + } + + +def _normalize_part(part: Any) -> Any: + """Map a legacy ``tool_call_response`` ``result`` key to ``response``. + + Older DP deployments emitted ``tool_call_response`` parts with a legacy + ``result`` key instead of the semconv ``response`` key. Map it across so + replayed history is always semconv-shaped. Non-tool parts and + already-semconv parts pass through untouched. + """ + if not isinstance(part, dict) or part.get("type") != "tool_call_response": + return part + if "response" in part or "result" not in part: + return part + rest = {k: v for k, v in part.items() if k != "result"} + rest["response"] = part["result"] + return rest + + +def _normalize_messages(messages: Any) -> Any: + if not isinstance(messages, list): + return messages + out: list[Any] = [] + for msg in messages: + if isinstance(msg, dict) and isinstance(msg.get("parts"), list): + msg = {**msg, "parts": [_normalize_part(p) for p in msg["parts"]]} + out.append(msg) + return out + + +def _normalize_item_payload(raw: Any) -> Any: + """Apply the legacy ``result`` -> ``response`` mapping to a span payload. + + The mapping itself is unchanged and still needed — it is a compatibility + shim for older DP deployments, not an artifact of the old envelope. What + changed is where the messages live: they used to be flat top-level fields, + and now they sit at ``attributes.gen_ai.input.messages`` / + ``attributes.gen_ai.output.messages``, so the walk follows the tree. + """ + if not isinstance(raw, dict): + return raw + attributes = raw.get("attributes") + if not isinstance(attributes, dict): + return raw + gen_ai = attributes.get("gen_ai") + if not isinstance(gen_ai, dict): + return raw + + patched_gen_ai = dict(gen_ai) + for key in ("input", "output"): + node = patched_gen_ai.get(key) + if isinstance(node, dict) and "messages" in node: + patched_gen_ai[key] = { + **node, + "messages": _normalize_messages(node["messages"]), + } + return { + **raw, + "attributes": {**attributes, "gen_ai": patched_gen_ai}, + } + + +def _conversation_items_next(page: GenAiSpanList) -> str | None: + if page.has_more and page.next is None: + raise ValueError("conversation items page has_more without next") + return page.next + + +class ConversationItems: + """Items of a conversation (``/v1/conversations/{id}/items``). Read-only. + + The returned :class:`~introspection_sdk.pagination.Pager` passes each + page's opaque ``next`` token back unchanged. + """ + + def __init__(self, http: _HttpClient) -> None: + self._http = http + + def list( + self, + conversation_id: str, + *, + limit: int = 100, + next: str | None = None, + include: builtins.list[ConversationItemInclude] | None = None, + agent: str | None = None, + service_name: str | None = None, + operation_name: str | None = None, + start_date: str | None = None, + end_date: str | None = None, + lookback_days: int | None = None, + ) -> Pager[GenAiSpan, GenAiSpanList]: + """List conversation items using an opaque ``next`` cursor. Iterate + the returned :class:`Pager` to stream every item + across pages, or call ``.page()`` for the first page only. + + Items are always returned newest-first: the route hardcodes a + descending sort and rejects a cursor that disagrees, so there is no + ordering parameter. + + Items carry the turn-local delta in ``input_messages`` — only the + messages new to that turn. Use :meth:`get` for the full input + history of a span. + """ + + def fetch(cursor: str | None) -> GenAiSpanList: + params: dict[str, Any] = { + "limit": limit, + "next": cursor, + "include": include, + "agent": agent, + "service_name": service_name, + "operation_name": operation_name, + "start_date": start_date, + "end_date": end_date, + "lookback_days": lookback_days, + } + payload = self._http.request( + "GET", + f"/v1/conversations/{conversation_id}/items", + params=params, + ) + if isinstance(payload, dict) and isinstance( + payload.get("data"), list + ): + payload = { + **payload, + "data": [ + _normalize_item_payload(d) for d in payload["data"] + ], + } + return GenAiSpanList.model_validate(payload) + + return Pager( + fetch, + items=lambda page: page.data, + next_cursor=_conversation_items_next, + start=next, + ) + + def get( + self, + conversation_id: str, + item_id: str, + *, + include: builtins.list[ConversationItemInclude] | None = None, + ) -> GenAiSpan: + """Fetch a single conversation item. Unlike the list route, the + detail's ``input_messages`` is the FULL input history for that + span.""" + params: dict[str, Any] = {"include": include} + payload = self._http.request( + "GET", + f"/v1/conversations/{conversation_id}/items/{item_id}", + params=params, + ) + return GenAiSpan.model_validate(_normalize_item_payload(payload)) + + +class Conversations: + """Read-only Conversations API (``/v1/conversations``). + + Both :meth:`list` and :meth:`items.list ` + return an auto-paging :class:`~introspection_sdk.pagination.Pager` and + pass each page's opaque ``next`` token back unchanged. + """ + + def __init__(self, http: _HttpClient) -> None: + self._http = http + self.items = ConversationItems(http) + + def list( + self, + *, + limit: int = 100, + next: str | None = None, + conversation_id: str | None = None, + sort: ConversationSortField | None = None, + direction: Literal["asc", "desc"] | None = None, + model: str | None = None, + agent_name: str | None = None, + status: SpanStatus | None = None, + service_name: str | None = None, + service_names: builtins.list[str] | None = None, + environment: str | None = None, + runtime_id: UUID | None = None, + runtime_group_id: UUID | None = None, + experiment_id: UUID | None = None, + recipe_git_commit_sha: str | None = None, + conversation_ids: builtins.list[str] | None = None, + resolution: ConversationResolution | None = None, + sentiment: ConversationSentiment | None = None, + owner_key: str | None = None, + metadata: dict[str, str] | None = None, + start_date: str | datetime | None = None, + end_date: str | datetime | None = None, + order: Literal["asc", "desc"] | None = None, + start: str | datetime | None = None, + end: str | datetime | None = None, + lookback: str | None = None, + format: ReadFormat = "json", + ) -> Pager[Conversation, Paginated[Conversation]]: + """List conversation summaries (cursor envelope). Iterate the + returned :class:`Pager` to stream every summary across pages, or + call ``.page()`` for the first page only. + + ``order`` is an alias for ``direction``; ``start`` / ``end`` for + ``start_date`` / ``end_date``; ``lookback`` (e.g. ``"24h"``) sets + ``start_date = now - lookback`` and is mutually exclusive with + ``start`` / ``end``. ``format="arrow"`` negotiates the columnar Arrow + stream, decoded back into the same envelope.""" + resolved_start, resolved_end = resolve_window( + start=start, + end=end, + lookback=lookback, + start_date=start_date, + end_date=end_date, + ) + + def fetch( + cursor: str | None, + ) -> Paginated[Conversation]: + params = build_conversation_params( + limit=limit, + cursor=cursor, + conversation_id=conversation_id, + sort=sort, + direction=direction or order, + model=model, + agent_name=agent_name, + status=status, + service_name=service_name, + service_names=service_names, + environment=environment, + runtime_id=runtime_id, + runtime_group_id=runtime_group_id, + experiment_id=experiment_id, + recipe_git_commit_sha=recipe_git_commit_sha, + conversation_ids=conversation_ids, + resolution=resolution, + sentiment=sentiment, + owner_key=owner_key, + metadata=metadata, + start_date=resolved_start, + end_date=resolved_end, + ) + if format == "arrow": + raw = self._http.request( + "GET", + "/v1/conversations", + params=params, + headers=ARROW_ACCEPT_HEADERS, + expect="raw", + ) + assert isinstance(raw, RawResponse) + return decode_arrow_page(raw, Conversation.model_validate) + payload = self._http.request( + "GET", "/v1/conversations", params=params + ) + return Paginated[Conversation].model_validate(payload) + + return cursor_paginate(fetch, start=next) + + def list_arrow( + self, + *, + limit: int = 100, + next: str | None = None, + conversation_id: str | None = None, + sort: ConversationSortField | None = None, + direction: Literal["asc", "desc"] | None = None, + model: str | None = None, + agent_name: str | None = None, + status: SpanStatus | None = None, + service_name: str | None = None, + service_names: builtins.list[str] | None = None, + environment: str | None = None, + runtime_id: UUID | None = None, + runtime_group_id: UUID | None = None, + experiment_id: UUID | None = None, + recipe_git_commit_sha: str | None = None, + conversation_ids: builtins.list[str] | None = None, + resolution: ConversationResolution | None = None, + sentiment: ConversationSentiment | None = None, + owner_key: str | None = None, + metadata: dict[str, str] | None = None, + start_date: str | datetime | None = None, + end_date: str | datetime | None = None, + order: Literal["asc", "desc"] | None = None, + start: str | datetime | None = None, + end: str | datetime | None = None, + lookback: str | None = None, + ) -> ArrowPageIterator: + """Columnar accessor: iterate one ``pyarrow.Table`` per server page + (constant memory), or call ``.read_all()`` to concatenate every + page into one Table. Same filters as :meth:`list`; requires the + ``[arrow]`` extra.""" + resolved_start, resolved_end = resolve_window( + start=start, + end=end, + lookback=lookback, + start_date=start_date, + end_date=end_date, + ) + + def fetch(cursor: str | None) -> RawResponse: + params = build_conversation_params( + limit=limit, + cursor=cursor, + conversation_id=conversation_id, + sort=sort, + direction=direction or order, + model=model, + agent_name=agent_name, + status=status, + service_name=service_name, + service_names=service_names, + environment=environment, + runtime_id=runtime_id, + runtime_group_id=runtime_group_id, + experiment_id=experiment_id, + recipe_git_commit_sha=recipe_git_commit_sha, + conversation_ids=conversation_ids, + resolution=resolution, + sentiment=sentiment, + owner_key=owner_key, + metadata=metadata, + start_date=resolved_start, + end_date=resolved_end, + ) + raw = self._http.request( + "GET", + "/v1/conversations", + params=params, + headers=ARROW_ACCEPT_HEADERS, + expect="raw", + ) + assert isinstance(raw, RawResponse) + return raw + + return ArrowPageIterator(fetch, start=next) + + def iterate( + self, + *, + max_items: int | None = None, + **kwargs: Any, + ) -> Iterator[Conversation]: + """Cursor generator: page through :meth:`list` to exhaustion, yielding + every summary. ``max_items`` bounds the total yielded (``None`` = no + bound). All other keyword args are forwarded to :meth:`list`.""" + if max_items is not None and max_items <= 0: + return + yielded = 0 + for record in self.list(**kwargs): + yield record + yielded += 1 + if max_items is not None and yielded >= max_items: + return + + def get(self, conversation_id: str) -> Conversation: + """Fetch one conversation summary with its complete agent index.""" + payload = self._http.request( + "GET", f"/v1/conversations/{conversation_id}" + ) + return Conversation.model_validate(payload) + + def export_json( + self, + conversation_id: str, + **params: Unpack[ConversationExportParams], + ) -> GenAiSpanList: + """Export one complete conversation as the standard GenAI-span list.""" + payload = self._http.request( + "GET", + _export_path(conversation_id), + params=build_export_params(**params), + headers=JSON_EXPORT_HEADERS, + ) + return GenAiSpanList.model_validate(payload) + + def export_stream( + self, + conversation_id: str, + format: ConversationExportFormat, + **params: Unpack[ConversationExportParams], + ) -> Iterator[bytes]: + """Stream raw complete-export bytes without buffering them in the SDK.""" + return self._http.stream_bytes( + _export_path(conversation_id), + params=build_export_params(**params), + headers=_export_headers(format), + ) + + def export_trajectory( + self, + conversation_id: str, + *, + agent: str | None = None, + service_name: str | None = None, + operation_name: str | None = None, + lookback_days: int | None = None, + start_date: str | datetime | None = None, + end_date: str | datetime | None = None, + ) -> Trajectory: + """Export one complete conversation as trajectory-v1. + + This is not :meth:`ConversationItems.list` in another coat. The + export is assembled server-side over the whole conversation, so it + carries no cursor and no page bound; the keyword args filter what + gets assembled. + + The trajectory is a projection derived on read from the stored + GenAI messages, so a conversation that cannot be represented as + trajectory-v1 raises rather than returning a partial export, and a + conversation with no exportable records raises ``NotFoundError``. + """ + payload = self._http.request( + "GET", + _export_path(conversation_id), + params=build_export_params( + agent=agent, + service_name=service_name, + operation_name=operation_name, + lookback_days=lookback_days, + start_date=start_date, + end_date=end_date, + ), + headers=TRAJECTORY_ACCEPT_HEADERS, + ) + return TRAJECTORY_ADAPTER.validate_python(payload) + + def export_arrow( + self, + conversation_id: str, + *, + agent: str | None = None, + service_name: str | None = None, + operation_name: str | None = None, + lookback_days: int | None = None, + start_date: str | datetime | None = None, + end_date: str | datetime | None = None, + ) -> Any: + """Export one complete conversation as a single ``pyarrow.Table``. + + Unlike :meth:`list_arrow`, this is one table for the whole conversation + rather than an iterator of pages: the export route assembles the + conversation server-side and streams it in one response. Returns + ``None`` for an empty body. + + Requires the optional ``pyarrow`` dependency. + """ + raw = self._http.request( + "GET", + _export_path(conversation_id), + params=build_export_params( + agent=agent, + service_name=service_name, + operation_name=operation_name, + lookback_days=lookback_days, + start_date=start_date, + end_date=end_date, + ), + headers=ARROW_ACCEPT_HEADERS, + expect="raw", + ) + assert isinstance(raw, RawResponse) + return decode_arrow_table(raw) + + def retrieve( + self, conversation_id: str, item_id: str | None = None + ) -> GenAiSpan | None: + """Load the state of a conversation as of one item. + + Returns the span itself. There is no separate response type any more: + the items read already carries the full input history, system + instructions, and tool definitions, so composing a second object from + it would just be copying fields into different names. + + When ``item_id`` is omitted the latest LLM turn is used — the first + item in descending order whose ``gen_ai.operation.name`` is + ``"chat"``, falling back to the first item that produced any output. + Returns ``None`` when the conversation has no items. + + For the full per-turn transcript instead, iterate + ``items.list(conversation_id)``, which is newest-first. + """ + target_id = item_id or self._find_latest_turn_id(conversation_id) + if target_id is None: + return None + return self.items.get(conversation_id, target_id) + + def _find_latest_turn_id(self, conversation_id: str) -> str | None: + """Scan items in descending order for the most recent LLM turn. + + The old heuristic also matched ``node_type == "assistant"``, but + ``node_type`` was a precomputed UI tree hint with no semantic-convention + equivalent and is gone from the wire. ``gen_ai.operation.name`` is the + attribute that actually carried the meaning. + + Bounded by :data:`LATEST_TURN_SCAN_LIMIT`: see its note for why an + unbounded scan was the wrong shape for this lookup. + """ + fallback: GenAiSpan | None = None + scanned = 0 + # The route is descending-only, so the first match is the latest. + for item in self.items.list( + conversation_id, limit=LATEST_TURN_SCAN_LIMIT + ): + gen_ai = item.attributes.gen_ai + operation = ( + gen_ai.operation.name if gen_ai and gen_ai.operation else None + ) + if operation == "chat": + return item.span_id + if fallback is None and item.output_messages: + fallback = item + # Counted after the body, so the loop stops on the last item of + # the page rather than asking the pager for the next one. + scanned += 1 + if scanned >= LATEST_TURN_SCAN_LIMIT: + break + return fallback.span_id if fallback else None diff --git a/introspection_sdk/_sync/runner_resources/events.py b/introspection_sdk/_sync/runner_resources/events.py new file mode 100644 index 0000000..11e4afe --- /dev/null +++ b/introspection_sdk/_sync/runner_resources/events.py @@ -0,0 +1,405 @@ +# Generated by scripts/gen_sync.py from introspection_sdk/_async/runner_resources/events.py. Do not edit: +# change the async source and run `uv run python scripts/gen_sync.py`. +"""Re-exported by :mod:`introspection_sdk.runner_resources.events`.""" + +from __future__ import annotations + +import builtins +import json +from collections.abc import Iterator +from datetime import datetime +from typing import Any, Literal +from uuid import UUID + +from pydantic import TypeAdapter + +from introspection_sdk._http import RawResponse, _HttpClient +from introspection_sdk.pagination import ( + Pager, + cursor_paginate, +) +from introspection_sdk.runner_resources._events_common import ( + UNKNOWN_EVENT_SKIPS, +) +from introspection_sdk.runner_resources._reads import ( + ARROW_ACCEPT_HEADERS, + ArrowPageIterator, + ReadFormat, + decode_arrow_page, + resolve_window, +) +from introspection_sdk.schemas.events import ( + KNOWN_EVENT_NAMES, + Event, + EventSortField, + IntrospectionEventName, + UnknownEvent, +) +from introspection_sdk.schemas.pagination import Paginated + +#: One adapter validates a decoded row into the right union member off the +#: top-level ``event_name`` discriminator. +EVENT_ADAPTER: TypeAdapter[Event] = TypeAdapter(Event) + +EVENT_PAYLOAD_JSON_FIELDS = frozenset({"metadata", "params", "properties"}) + + +def normalize_event_arrow_row(row: dict[str, Any]) -> dict[str, Any]: + """Restore dict fields that the server JSON-encodes inside Arrow structs.""" + payload = row.get("payload") + if not isinstance(payload, dict): + return row + for field in EVENT_PAYLOAD_JSON_FIELDS: + value = payload.get(field) + if not isinstance(value, str): + continue + try: + payload[field] = json.loads(value) + except json.JSONDecodeError: + # Keep the original value so the typed model reports the same + # malformed-field error instead of hiding server drift. + pass + return row + + +def validate_event_row(row: dict[str, Any]) -> Event | None: + """Validate one wire row into the discriminated :data:`Event` union. + + Rows whose ``event_name`` falls outside the known family set are + skipped (``None``) rather than raised — the closed set may grow + server-side before this SDK learns the new member. + """ + row = normalize_event_arrow_row(row) + if row.get("event_name") not in KNOWN_EVENT_NAMES: + UNKNOWN_EVENT_SKIPS.increment(row.get("event_name")) + return None + return EVENT_ADAPTER.validate_python(row) + + +def validate_single_event(payload: Any) -> Event | UnknownEvent: + """Validate one wire row read by id. + + Unlike :func:`validate_event_row`, an unknown family is never skipped: + the caller named an id, so returning nothing would be indistinguishable + from a missing event. The envelope is validated and the payload left + untyped instead. + """ + row = normalize_event_arrow_row(payload) + if row.get("event_name") not in KNOWN_EVENT_NAMES: + UNKNOWN_EVENT_SKIPS.increment(row.get("event_name")) + return UnknownEvent.model_validate(row) + return EVENT_ADAPTER.validate_python(row) + + +def validate_event_page(payload: Any) -> Paginated[Event]: + """Validate a JSON cursor envelope, skipping unknown-family rows.""" + envelope = Paginated[Any].model_validate(payload) + records = [ + record + for record in (validate_event_row(row) for row in envelope.records) + if record is not None + ] + return Paginated[Event]( + records=records, + count=envelope.count, + total_count=envelope.total_count, + next=envelope.next, + ) + + +def build_event_params( + event_name: str | IntrospectionEventName, + *, + limit: int = 100, + cursor: str | None = None, + sort: EventSortField | None = None, + direction: Literal["asc", "desc"] | None = None, + start_date: str | datetime | None = None, + end_date: str | datetime | None = None, + conversation_id: str | None = None, + service_name: str | None = None, + environment: str | None = None, + runtime_group_id: UUID | None = None, + trace_id: str | None = None, + span_id: str | None = None, + owner_key: str | None = None, + event_id: builtins.list[str] | None = None, + conversation_ids: builtins.list[str] | None = None, + lens: str | None = None, + pattern_id: str | UUID | None = None, + include_superseded: bool | None = None, + severities: builtins.list[str] | None = None, + runtime_group_unattributed: bool | None = None, + status: str | None = None, + automation_id: UUID | str | None = None, + task_id: UUID | str | None = None, +) -> dict[str, Any]: + """Fold the shared list/arrow kwargs into the wire query params. + + ``event_name`` is required, exactly one family — the last per-request + source of heterogeneity. Family-scoped filters (observation: + ``conversation_ids`` / ``lens`` / ``pattern_id`` / ``include_superseded`` + / ``severities`` / ``runtime_group_unattributed``; pattern: ``lens`` / + ``status``; automation triggered/skipped: ``automation_id`` / + ``task_id``) are passed through; the server validates them against the + requested family's allow-map. + """ + if not event_name: + raise ValueError( + "event_name is required — pass exactly one event family, e.g. " + "'introspection.observation' (see IntrospectionEventName)" + ) + return { + "event_name": str(event_name), + "limit": limit, + "next": cursor, + "sort": sort, + "direction": direction, + "start_date": start_date, + "end_date": end_date, + "conversation_id": conversation_id, + "service_name": service_name, + "environment": environment, + "runtime_group_id": runtime_group_id, + "trace_id": trace_id, + "span_id": span_id, + "owner_key": owner_key, + "event_id": event_id, + "conversation_ids": conversation_ids, + "lens": lens, + "pattern_id": pattern_id, + "include_superseded": include_superseded, + "severities": severities, + "runtime_group_unattributed": runtime_group_unattributed, + "status": status, + "automation_id": automation_id, + "task_id": task_id, + } + + +class Events: + """Read-only Events API (``GET /v1/events``). + + :meth:`list` returns an auto-paging + :class:`~introspection_sdk.pagination.Pager` over the standard cursor + envelope's opaque ``next`` token. :meth:`iterate` is a bounded + convenience generator over the same stream. :meth:`list_arrow` is the + columnar accessor — raw ``pyarrow.Table`` pages instead of models. + """ + + def __init__(self, http: _HttpClient) -> None: + self._http = http + + def list( + self, + event_name: str | IntrospectionEventName, + *, + limit: int = 100, + next: str | None = None, + sort: EventSortField | None = None, + direction: Literal["asc", "desc"] | None = None, + order: Literal["asc", "desc"] | None = None, + start: str | datetime | None = None, + end: str | datetime | None = None, + lookback: str | None = None, + start_date: str | datetime | None = None, + end_date: str | datetime | None = None, + conversation_id: str | None = None, + service_name: str | None = None, + environment: str | None = None, + runtime_group_id: UUID | None = None, + trace_id: str | None = None, + span_id: str | None = None, + owner_key: str | None = None, + event_id: builtins.list[str] | None = None, + conversation_ids: builtins.list[str] | None = None, + lens: str | None = None, + pattern_id: str | UUID | None = None, + include_superseded: bool | None = None, + severities: builtins.list[str] | None = None, + runtime_group_unattributed: bool | None = None, + status: str | None = None, + automation_id: UUID | str | None = None, + task_id: UUID | str | None = None, + format: ReadFormat = "json", + ) -> Pager[Event, Paginated[Event]]: + """List events of one family (cursor envelope). Iterate the returned + :class:`Pager` to stream every event across pages, or call + ``.page()`` for the first page only. + + ``event_name`` is **required** and names exactly one family — every + row is that family's typed union member. Family-scoped filters + beyond the envelope set are server-validated against the requested + family. ``order`` is an alias for ``direction``; ``start`` / + ``end`` for ``start_date`` / ``end_date``; ``lookback`` (e.g. + ``"24h"``) sets ``start_date = now - lookback`` and is mutually + exclusive with ``start`` / ``end``. ``format="arrow"`` negotiates + the columnar Arrow stream, decoded back into the same envelope. + """ + resolved_start, resolved_end = resolve_window( + start=start, + end=end, + lookback=lookback, + start_date=start_date, + end_date=end_date, + ) + build_event_params(event_name) # fail fast before any request + + def fetch(cursor: str | None) -> Paginated[Event]: + params = build_event_params( + event_name, + limit=limit, + cursor=cursor, + sort=sort, + direction=direction or order, + start_date=resolved_start, + end_date=resolved_end, + conversation_id=conversation_id, + service_name=service_name, + environment=environment, + runtime_group_id=runtime_group_id, + trace_id=trace_id, + span_id=span_id, + owner_key=owner_key, + event_id=event_id, + conversation_ids=conversation_ids, + lens=lens, + pattern_id=pattern_id, + include_superseded=include_superseded, + severities=severities, + runtime_group_unattributed=runtime_group_unattributed, + status=status, + automation_id=automation_id, + task_id=task_id, + ) + if format == "arrow": + raw = self._http.request( + "GET", + "/v1/events", + params=params, + headers=ARROW_ACCEPT_HEADERS, + expect="raw", + ) + assert isinstance(raw, RawResponse) + return decode_arrow_page(raw, validate_event_row) + payload = self._http.request("GET", "/v1/events", params=params) + return validate_event_page(payload) + + return cursor_paginate(fetch, start=next) + + def get(self, event_id: str) -> Event | UnknownEvent: + """Read one event by id, across every family. + + No ``event_name`` is supplied, so the family is not known in + advance and the result widens to :class:`UnknownEvent` for a + family added server-side after this SDK version. Raises + ``NotFoundError`` when the id does not resolve in tenant scope. + """ + payload = self._http.request("GET", f"/v1/events/{event_id}") + return validate_single_event(payload) + + def iterate( + self, + event_name: str | IntrospectionEventName, + *, + max_items: int | None = None, + **kwargs: Any, + ) -> Iterator[Event]: + """Cursor generator: page through :meth:`list` to exhaustion, yielding + every event. ``max_items`` bounds the total yielded (``None`` = no + bound). All other keyword args are forwarded to :meth:`list`. + """ + if max_items is not None and max_items <= 0: + return + yielded = 0 + for record in self.list(event_name, **kwargs): + yield record + yielded += 1 + if max_items is not None and yielded >= max_items: + return + + def list_arrow( + self, + event_name: str | IntrospectionEventName, + *, + limit: int = 100, + next: str | None = None, + sort: EventSortField | None = None, + direction: Literal["asc", "desc"] | None = None, + order: Literal["asc", "desc"] | None = None, + start: str | datetime | None = None, + end: str | datetime | None = None, + lookback: str | None = None, + start_date: str | datetime | None = None, + end_date: str | datetime | None = None, + conversation_id: str | None = None, + service_name: str | None = None, + environment: str | None = None, + runtime_group_id: UUID | None = None, + trace_id: str | None = None, + span_id: str | None = None, + owner_key: str | None = None, + event_id: builtins.list[str] | None = None, + conversation_ids: builtins.list[str] | None = None, + lens: str | None = None, + pattern_id: str | UUID | None = None, + include_superseded: bool | None = None, + severities: builtins.list[str] | None = None, + runtime_group_unattributed: bool | None = None, + status: str | None = None, + automation_id: UUID | str | None = None, + task_id: UUID | str | None = None, + ) -> ArrowPageIterator: + """Columnar accessor: iterate one ``pyarrow.Table`` per server page + (constant memory — envelope columns + the family's typed ``payload`` + struct column), or call ``.read_all()`` to concatenate every page + into one Table. Same filters as :meth:`list`; requires the + ``[arrow]`` extra. + """ + resolved_start, resolved_end = resolve_window( + start=start, + end=end, + lookback=lookback, + start_date=start_date, + end_date=end_date, + ) + build_event_params(event_name) # fail fast before any request + + def fetch(cursor: str | None) -> RawResponse: + params = build_event_params( + event_name, + limit=limit, + cursor=cursor, + sort=sort, + direction=direction or order, + start_date=resolved_start, + end_date=resolved_end, + conversation_id=conversation_id, + service_name=service_name, + environment=environment, + runtime_group_id=runtime_group_id, + trace_id=trace_id, + span_id=span_id, + owner_key=owner_key, + event_id=event_id, + conversation_ids=conversation_ids, + lens=lens, + pattern_id=pattern_id, + include_superseded=include_superseded, + severities=severities, + runtime_group_unattributed=runtime_group_unattributed, + status=status, + automation_id=automation_id, + task_id=task_id, + ) + raw = self._http.request( + "GET", + "/v1/events", + params=params, + headers=ARROW_ACCEPT_HEADERS, + expect="raw", + ) + assert isinstance(raw, RawResponse) + return raw + + return ArrowPageIterator(fetch, start=next) diff --git a/introspection_sdk/_sync/runner_resources/files.py b/introspection_sdk/_sync/runner_resources/files.py new file mode 100644 index 0000000..24ec2fb --- /dev/null +++ b/introspection_sdk/_sync/runner_resources/files.py @@ -0,0 +1,304 @@ +# Generated by scripts/gen_sync.py from introspection_sdk/_async/runner_resources/files.py. Do not edit: +# change the async source and run `uv run python scripts/gen_sync.py`. +"""Re-exported by :mod:`introspection_sdk.runner_resources.files`.""" + +from __future__ import annotations + +import builtins +import contextlib +import json +import mimetypes +from collections.abc import Iterator +from pathlib import Path +from typing import IO, Any + +from introspection_sdk._http import _HttpClient +from introspection_sdk.pagination import ( + Pager, + cursor_paginate, +) +from introspection_sdk.schemas.files import ( + File, + FileCreateTextRequest, + FileType, + FileUpdateRequest, +) +from introspection_sdk.schemas.pagination import Paginated + +FileLike = Path | IO[bytes] | bytes + + +@contextlib.contextmanager +def _materialise_upload( + file: FileLike, + name: str | None, + content_type: str | None, +) -> Iterator[tuple[str, IO[bytes] | bytes, str]]: + """Yield the multipart tuple for ``file``, closing anything it opened. + + A context manager because the ``Path`` branch opens a handle that used to + be handed to httpx and never closed -- one leaked descriptor per upload, + and one that stays open if the request raises. + """ + if isinstance(file, Path): + guessed_name = name or file.name + guessed_ct = ( + content_type + or mimetypes.guess_type(guessed_name)[0] + or "application/octet-stream" + ) + with file.open("rb") as handle: + yield guessed_name, handle, guessed_ct + return + if isinstance(file, bytes | bytearray): + if not name: + raise ValueError("`name` is required when uploading raw bytes") + ct = content_type or ( + mimetypes.guess_type(name)[0] or "application/octet-stream" + ) + yield name, bytes(file), ct + return + # file-like object + if not name: + raise ValueError( + "`name` is required when uploading a file-like object" + ) + ct = content_type or ( + mimetypes.guess_type(name)[0] or "application/octet-stream" + ) + yield name, file, ct + + +def _upload_form( + name: str, + file_type: FileType | str, + metadata: dict[str, Any] | None, + tags: list[str] | None, +) -> dict[str, Any]: + """Build the multipart form fields for ``POST /v1/files``. + + ``tags`` goes out as one ``tags`` field per tag; ``metadata`` as a JSON + object string. Either is left off the form entirely when ``None``. + """ + data: dict[str, Any] = { + "name": name, + "file_type": ( + file_type.value if isinstance(file_type, FileType) else file_type + ), + } + if metadata is not None: + data["metadata"] = json.dumps(metadata) + if tags is not None: + data["tags"] = tags + return data + + +class FileVersions: + def __init__(self, http: _HttpClient) -> None: + self._http = http + + def list( + self, + file_id: str, + *, + limit: int = 100, + next: str | None = None, + include_total: bool = False, + ) -> Pager[File, Paginated[File]]: + """List versions of a file. Iterate the returned :class:`Pager` to + stream every version across pages, or call ``.page()`` for the + first page only.""" + + def fetch(cursor: str | None) -> Paginated[File]: + params: dict[str, Any] = { + "limit": limit, + "next": cursor, + "include_total": include_total, + } + payload = self._http.request( + "GET", f"/v1/files/{file_id}/versions", params=params + ) + return Paginated[File].model_validate(payload) + + return cursor_paginate(fetch, start=next) + + def get(self, file_id: str, version_id: str) -> File: + payload = self._http.request( + "GET", f"/v1/files/{file_id}/versions/{version_id}" + ) + return File.model_validate(payload) + + def create( + self, + file_id: str, + *, + file: FileLike, + name: str | None = None, + file_type: FileType | str = FileType.OTHER, + content_type: str | None = None, + ) -> File: + with _materialise_upload(file, name, content_type) as ( + n, + body, + ct, + ): + files = {"file": (n, body, ct)} + data = { + "name": n, + "file_type": ( + file_type.value + if isinstance(file_type, FileType) + else file_type + ), + } + payload = self._http.request( + "POST", + f"/v1/files/{file_id}/versions", + files=files, + data=data, + ) + return File.model_validate(payload) + + +class Files: + def __init__(self, http: _HttpClient) -> None: + self._http = http + self.versions = FileVersions(http) + + def list( + self, + *, + limit: int = 100, + next: str | None = None, + include_total: bool = False, + name: str | None = None, + file_type: FileType | str | None = None, + storage_path: str | None = None, + tag: str | None = None, + metadata: dict[str, str] | None = None, + ) -> Pager[File, Paginated[File]]: + """List files. Iterate the returned :class:`Pager` to stream every + file across pages, or call ``.page()`` for the first page only. + + ``metadata`` narrows to files whose metadata holds every pair, each + matched exactly against the string value; it is sent as one repeated + ``metadata=key:value`` param per entry (at most 16). Keys are letters, + digits, ``_`` and ``-``. A server that predates the filter ignores it + and returns the unfiltered list.""" + + def fetch(cursor: str | None) -> Paginated[File]: + params: dict[str, Any] = { + "limit": limit, + "next": cursor, + "include_total": include_total, + "name": name, + "file_type": ( + file_type.value + if isinstance(file_type, FileType) + else file_type + ), + "storage_path": storage_path, + "tag": tag, + "metadata": ( + [f"{key}:{value}" for key, value in metadata.items()] + if metadata + else None + ), + } + payload = self._http.request("GET", "/v1/files", params=params) + return Paginated[File].model_validate(payload) + + return cursor_paginate(fetch, start=next) + + def upload( + self, + *, + file: FileLike, + name: str | None = None, + file_type: FileType | str = FileType.OTHER, + content_type: str | None = None, + metadata: dict[str, Any] | None = None, + tags: builtins.list[str] | None = None, + ) -> File: + """Upload a binary file via multipart. + + ``tags`` and ``metadata`` are stamped when the request creates the + file. A request that adds a new version to an existing file keeps + that file's tags; change them with :meth:`update`. + + Example: + >>> runner.files.upload( + ... file=Path("input.jsonl"), + ... file_type="upload", + ... ) + """ + with _materialise_upload(file, name, content_type) as ( + n, + body, + ct, + ): + files = {"file": (n, body, ct)} + data = _upload_form(n, file_type, metadata, tags) + payload = self._http.request( + "POST", "/v1/files", files=files, data=data + ) + return File.model_validate(payload) + + def create_text( + self, + *, + name: str, + content: str, + mime_type: str = "text/markdown", + metadata: dict[str, Any] | None = None, + tags: builtins.list[str] | None = None, + ) -> File: + """Create a text/markdown file via JSON body. + + ``tags`` and ``metadata`` are stamped when the request creates the + file. A request that adds a new version to an existing file keeps + that file's tags; change them with :meth:`update`.""" + body = FileCreateTextRequest( + name=name, + content=content, + mime_type=mime_type, + metadata=metadata, + tags=tags, + ) + payload = self._http.request( + "POST", "/v1/files", json=body.model_dump(exclude_none=True) + ) + return File.model_validate(payload) + + def get(self, file_id: str) -> File: + payload = self._http.request("GET", f"/v1/files/{file_id}") + return File.model_validate(payload) + + def update( + self, + file_id: str, + *, + name: str | None = None, + metadata: dict[str, Any] | None = None, + tags: builtins.list[str] | None = None, + ) -> File: + # `exclude_none` drops an omitted tag list but keeps an explicit [], + # which is what clears the tags — they replace wholesale. + body = FileUpdateRequest( + name=name, metadata=metadata, tags=tags + ).model_dump(exclude_none=True) + payload = self._http.request( + "PATCH", f"/v1/files/{file_id}", json=body + ) + return File.model_validate(payload) + + def delete(self, file_id: str) -> None: + self._http.request("DELETE", f"/v1/files/{file_id}", expect="empty") + + def download(self, file_id: str) -> bytes: + return self._http.request( + "GET", f"/v1/files/{file_id}/content", expect="bytes" + ) + + def download_stream(self, file_id: str) -> Iterator[bytes]: + return self._http.stream_bytes(f"/v1/files/{file_id}/content") diff --git a/introspection_sdk/_sync/runner_resources/metrics.py b/introspection_sdk/_sync/runner_resources/metrics.py new file mode 100644 index 0000000..e842a82 --- /dev/null +++ b/introspection_sdk/_sync/runner_resources/metrics.py @@ -0,0 +1,38 @@ +# Generated by scripts/gen_sync.py from introspection_sdk/_async/runner_resources/metrics.py. Do not edit: +# change the async source and run `uv run python scripts/gen_sync.py`. +"""Re-exported by :mod:`introspection_sdk.runner_resources.metrics`.""" + +from __future__ import annotations + +from typing import Any + +from introspection_sdk._http import _HttpClient +from introspection_sdk.schemas.metrics import ( + MetricQueryRequest, + MetricQueryResponse, +) + + +class Metrics: + """Read-only Metrics API (``POST /v1/metrics``).""" + + def __init__(self, http: _HttpClient) -> None: + self._http = http + + def query( + self, request: MetricQueryRequest | dict[str, Any] + ) -> MetricQueryResponse: + """Execute one bounded metrics query. + + ``request`` is a :class:`MetricQueryRequest` (or an equivalent dict). + The whole grammar is validated by the DP; passing a typed + ``MetricQueryRequest`` also validates the closed contract locally + before the request is sent. + """ + body = ( + request.model_dump(mode="json", exclude_none=True) + if isinstance(request, MetricQueryRequest) + else request + ) + payload = self._http.request("POST", "/v1/metrics", json=body) + return MetricQueryResponse.model_validate(payload) diff --git a/introspection_sdk/_sync/runner_resources/shares.py b/introspection_sdk/_sync/runner_resources/shares.py new file mode 100644 index 0000000..ebbe02e --- /dev/null +++ b/introspection_sdk/_sync/runner_resources/shares.py @@ -0,0 +1,175 @@ +# Generated by scripts/gen_sync.py from introspection_sdk/_async/runner_resources/shares.py. Do not edit: +# change the async source and run `uv run python scripts/gen_sync.py`. +"""Re-exported by :mod:`introspection_sdk.runner_resources.shares`.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from introspection_sdk._http import _HttpClient +from introspection_sdk.pagination import ( + Pager, + cursor_paginate, +) +from introspection_sdk.schemas.pagination import Paginated +from introspection_sdk.schemas.shares import ( + ResourceShare, + ShareCreateRequest, + ShareResourceType, + ShareUpdateRequest, +) + + +def _list_params( + *, + limit: int, + cursor: str | None, + resource_type: ShareResourceType | str | None, + resource_id: str | None, + granted_member_id: str | None, + granted_tag: str | None, + created_by_me: bool, + granted_to_me: bool, +) -> dict[str, Any]: + return { + "limit": limit, + "next": cursor, + "resource_type": ( + resource_type.value + if isinstance(resource_type, ShareResourceType) + else resource_type + ), + "resource_id": resource_id, + "granted_member_id": granted_member_id, + "granted_tag": granted_tag, + "created_by_me": created_by_me, + "granted_to_me": granted_to_me, + } + + +def _create_body( + *, + resource_type: ShareResourceType | str, + resource_id: str, + granted_member_id: str | None, + granted_tag: str | None, + visible_from: datetime | None, +) -> dict[str, Any]: + # Loose public inputs (plain str / enum) are coerced by validation: + # str -> ShareResourceType, str -> UUID for granted_member_id. + return ShareCreateRequest.model_validate( + { + "resource_type": resource_type, + "resource_id": resource_id, + "granted_member_id": granted_member_id, + "granted_tag": granted_tag, + "visible_from": visible_from, + } + ).model_dump(mode="json", exclude_none=True) + + +def _update_body(visible_from: datetime | None) -> dict[str, Any]: + return ShareUpdateRequest(visible_from=visible_from).model_dump( + mode="json" + ) + + +class Shares: + """Synchronous `/v1/shares` resource.""" + + def __init__(self, http: _HttpClient) -> None: + self._http = http + + def list( + self, + *, + limit: int = 100, + next: str | None = None, + resource_type: ShareResourceType | str | None = None, + resource_id: str | None = None, + granted_member_id: str | None = None, + granted_tag: str | None = None, + created_by_me: bool = False, + granted_to_me: bool = False, + ) -> Pager[ResourceShare, Paginated[ResourceShare]]: + """List grants the caller created or that name them. + + ``granted_to_me`` matches grants naming the caller's member, a tag the + caller's token carries, or both.""" + + def fetch(cursor: str | None) -> Paginated[ResourceShare]: + payload = self._http.request( + "GET", + "/v1/shares", + params=_list_params( + limit=limit, + cursor=cursor, + resource_type=resource_type, + resource_id=resource_id, + granted_member_id=granted_member_id, + granted_tag=granted_tag, + created_by_me=created_by_me, + granted_to_me=granted_to_me, + ), + ) + return Paginated[ResourceShare].model_validate(payload) + + return cursor_paginate(fetch, start=next) + + def create( + self, + *, + resource_type: ShareResourceType | str, + resource_id: str, + granted_member_id: str | None = None, + granted_tag: str | None = None, + visible_from: datetime | None = None, + ) -> ResourceShare: + """Create a grant. The caller must own the target resource. + + ``granted_member_id`` and ``granted_tag`` are ANDed; omit both for a + project-wide grant. A tag share also requires the caller to hold the + tag (or be an admin), and a duplicate live tag share raises + ``ConflictError``. A share admits; the grantee's token scopes decide + whether they may read, write or delete. ``visible_from`` + (conversations only, timezone-aware, not in the future) hides earlier + spans from callers the share alone admits.""" + payload = self._http.request( + "POST", + "/v1/shares", + json=_create_body( + resource_type=resource_type, + resource_id=resource_id, + granted_member_id=granted_member_id, + granted_tag=granted_tag, + visible_from=visible_from, + ), + ) + return ResourceShare.model_validate(payload) + + def get(self, share_id: str) -> ResourceShare: + payload = self._http.request("GET", f"/v1/shares/{share_id}") + return ResourceShare.model_validate(payload) + + def update( + self, + share_id: str, + *, + visible_from: datetime | None, + ) -> ResourceShare: + """Set or clear a conversation grant's ``visible_from``. + + ``visible_from=None`` clears the cutoff. The grantee cannot change; + revoke and create another to admit someone else. Only the grantor or + an admin may update a grant; anyone else gets ``NotFoundError``, and a + non-conversation share is a ``422``.""" + payload = self._http.request( + "PATCH", + f"/v1/shares/{share_id}", + json=_update_body(visible_from), + ) + return ResourceShare.model_validate(payload) + + def delete(self, share_id: str) -> None: + self._http.request("DELETE", f"/v1/shares/{share_id}", expect="empty") diff --git a/introspection_sdk/_sync/runner_resources/tasks.py b/introspection_sdk/_sync/runner_resources/tasks.py new file mode 100644 index 0000000..333cc38 --- /dev/null +++ b/introspection_sdk/_sync/runner_resources/tasks.py @@ -0,0 +1,397 @@ +# Generated by scripts/gen_sync.py from introspection_sdk/_async/runner_resources/tasks.py. Do not edit: +# change the async source and run `uv run python scripts/gen_sync.py`. +"""Re-exported by :mod:`introspection_sdk.runner_resources.tasks`.""" + +from __future__ import annotations + +import builtins +from collections.abc import Iterator +from typing import Any + +from introspection_sdk._errors import RunFailedError, StreamIncompleteError +from introspection_sdk._http import _HttpClient +from introspection_sdk.pagination import ( + Pager, + cursor_paginate, +) +from introspection_sdk.resumable import ( + stream_resumable, +) +from introspection_sdk.schemas.agui import ( + AGUIEvent, + AssistantMessage, + CustomEvent, + MessagesSnapshotEvent, + ResumeEntry, + RunErrorEvent, + TextMessageChunkEvent, + TextMessageContentEvent, +) +from introspection_sdk.schemas.pagination import Paginated +from introspection_sdk.schemas.tasks import ( + Task, + TaskCancelRequest, + TaskCancelResponse, + TaskCreateResponse, + TaskFileRef, + TaskPrompt, + TaskRepoRequest, + TaskRun, + TaskRunKind, + TaskRunResponse, +) + + +def _resume_body( + resume: list[ResumeEntry | dict[str, Any]], +) -> dict[str, Any]: + return { + "resume": [ + entry.model_dump(exclude_none=True, by_alias=True) + if isinstance(entry, ResumeEntry) + else entry + for entry in resume + ] + } + + +def _file_refs( + files: list[TaskFileRef | dict[str, Any]], +) -> list[dict[str, Any]]: + return [ + ref.model_dump(exclude_none=True) + if isinstance(ref, TaskFileRef) + else ref + for ref in files + ] + + +def _repo_refs( + repositories: list[TaskRepoRequest | dict[str, Any]], +) -> list[dict[str, Any]]: + # `exclude_none` matters here: `ref` and `depth` default to the + # repository's registered branch and a shallow clone server-side, and a + # null would be the caller asking for something instead of nothing. + return [ + repo.model_dump(exclude_none=True) + if isinstance(repo, TaskRepoRequest) + else repo + for repo in repositories + ] + + +def _snapshot_text(event: MessagesSnapshotEvent) -> list[str]: + """A snapshot carries the whole run so far, so it replaces what was read.""" + return [ + m.content + for m in event.messages + if isinstance(m, AssistantMessage) and isinstance(m.content, str) + ] + + +class RunHandle: + """Returned by ``Tasks.start(...)`` and ``TaskRuns.create(...)``. + + Mirrors the Cursor SDK shape: ``handle.stream()`` to iterate validated + AG-UI events, ``handle.text()`` to collect text deltas into a string, + ``handle.cancel()`` for the default abort behavior, with typed options for + explicit abort or drain cancellation. + """ + + def __init__( + self, + task: Task | None, + run: TaskRun, + runs: TaskRuns, + ) -> None: + self.task = task + self.run = run + self._runs = runs + + def stream( + self, + *, + max_reconnects: int = 5, + backoff: float = 0.5, + timeout: float = 300.0, + ) -> Iterator[AGUIEvent]: + """Stream this run's AG-UI events, reconnecting on a dropped + connection under the recovery contract in + :mod:`introspection_sdk.resumable`.""" + return self._runs.stream( + str(self.run.task_id), + self.run.id, + max_reconnects=max_reconnects, + backoff=backoff, + timeout=timeout, + ) + + def cancel( + self, + options: TaskCancelRequest | dict[str, Any] | None = None, + ) -> TaskCancelResponse: + return self._runs.cancel( + str(self.run.task_id), self.run.id, options=options + ) + + def text(self) -> str: + """Collect this run's assistant text from :meth:`stream`. + + Never returns partial output: raises ``RunFailedError`` on + ``RUN_ERROR`` and ``StreamIncompleteError`` wherever output may be + missing (a ``410``, a ``resume_gap``, or a run that settled without + the stream confirming it). A ``MESSAGES_SNAPSHOT`` replaces the text + collected so far with the snapshot's. The transcript is not read to + fill a gap, so streaming needs no ``conversations:read`` scope; read + ``runner.conversations`` yourself when you need the output after + such an error. + """ + out: list[str] = [] + for ev in self.stream(): + if isinstance(ev, RunErrorEvent): + raise RunFailedError(ev.message, code=ev.code) + if isinstance(ev, CustomEvent) and ev.name == "resume_gap": + raise StreamIncompleteError( + "The replay buffer lost output; read the conversation transcript" + ) + if isinstance(ev, MessagesSnapshotEvent): + out = _snapshot_text(ev) + elif isinstance( + ev, TextMessageContentEvent | TextMessageChunkEvent + ): + out.append(str(ev.delta or "")) + return "".join(out) + + +class TaskRuns: + def __init__(self, http: _HttpClient) -> None: + self._http = http + + def create( + self, + task_id: str, + *, + prompt: TaskPrompt | dict[str, Any] | None = None, + kind: TaskRunKind | str | None = None, + metadata: dict[str, Any] | None = None, + files: list[TaskFileRef | dict[str, Any]] | None = None, + ) -> RunHandle: + body: dict[str, Any] = {} + if prompt is not None: + body["prompt"] = ( + prompt.model_dump(exclude_none=True) + if isinstance(prompt, TaskPrompt) + else prompt + ) + if kind is not None: + body["kind"] = ( + kind.value if isinstance(kind, TaskRunKind) else kind + ) + if metadata is not None: + body["metadata"] = metadata + if files: + body["files"] = _file_refs(files) + payload = self._http.request( + "POST", f"/v1/tasks/{task_id}/runs", json=body + ) + res = TaskRunResponse.model_validate(payload) + return RunHandle(None, res.run, self) + + def resume( + self, + task_id: str, + *, + resume: list[ResumeEntry | dict[str, Any]], + ) -> RunHandle: + payload = self._http.request( + "POST", f"/v1/tasks/{task_id}/runs", json=_resume_body(resume) + ) + res = TaskRunResponse.model_validate(payload) + return RunHandle(None, res.run, self) + + def get(self, task_id: str, run_id: str) -> TaskRun: + payload = self._http.request( + "GET", f"/v1/tasks/{task_id}/runs/{run_id}" + ) + return TaskRun.model_validate(payload) + + def cancel( + self, + task_id: str, + run_id: str, + options: TaskCancelRequest | dict[str, Any] | None = None, + ) -> TaskCancelResponse: + if options is not None: + request = ( + options + if isinstance(options, TaskCancelRequest) + else TaskCancelRequest.model_validate(options) + ) + return self._cancel_with(task_id, run_id, request) + payload = self._http.request( + "POST", f"/v1/tasks/{task_id}/runs/{run_id}/cancel" + ) + return TaskCancelResponse.model_validate(payload) + + def _cancel_with( + self, task_id: str, run_id: str, request: TaskCancelRequest + ) -> TaskCancelResponse: + payload = self._http.request( + "POST", + f"/v1/tasks/{task_id}/runs/{run_id}/cancel", + json=request.model_dump(exclude_none=True, mode="json"), + ) + return TaskCancelResponse.model_validate(payload) + + def stream( + self, + task_id: str, + run_id: str, + *, + max_reconnects: int = 5, + backoff: float = 0.5, + timeout: float = 300.0, + ) -> Iterator[AGUIEvent]: + """Stream a run's AG-UI events, reconnecting on a dropped connection + under the recovery contract in :mod:`introspection_sdk.resumable`. + The keyword args bound recovery attempts.""" + return stream_resumable( + self._http, + task_id, + run_id, + max_reconnects=max_reconnects, + backoff=backoff, + timeout=timeout, + ) + + +class Tasks: + def __init__(self, http: _HttpClient) -> None: + self._http = http + self.runs = TaskRuns(http) + + def list( + self, + *, + limit: int = 100, + next: str | None = None, + include_total: bool = False, + statuses: builtins.list[str] | None = None, + require_automation_id: bool | None = None, + tag: str | None = None, + ) -> Pager[Task, Paginated[Task]]: + """List tasks. Iterate the returned :class:`Pager` to stream every + task across pages, or call ``.page()`` for the first page only.""" + + def fetch(cursor: str | None) -> Paginated[Task]: + params: dict[str, Any] = { + "limit": limit, + "next": cursor, + "include_total": include_total, + "tag": tag, + } + if statuses: + params["statuses"] = statuses + if require_automation_id is not None: + params["require_automation_id"] = require_automation_id + payload = self._http.request("GET", "/v1/tasks", params=params) + return Paginated[Task].model_validate(payload) + + return cursor_paginate(fetch, start=next) + + def create( + self, + *, + title: str | None = None, + prompt: str | None = None, + agent_name: str | None = None, + repositories: builtins.list[TaskRepoRequest | dict[str, Any]] + | None = None, + metadata: dict[str, Any] | None = None, + conversation_metadata: dict[str, str] | None = None, + idle_timeout_seconds: int | None = None, + fork_share_id: str | None = None, + files: builtins.list[TaskFileRef | dict[str, Any]] | None = None, + tags: builtins.list[str] | None = None, + ) -> TaskCreateResponse: + body: dict[str, Any] = { + "title": title, + "prompt": prompt, + "agent_name": agent_name, + "metadata": metadata, + "conversation_metadata": conversation_metadata, + "idle_timeout_seconds": idle_timeout_seconds, + "fork_share_id": fork_share_id, + "files": _file_refs(files) if files else None, + "repositories": _repo_refs(repositories) if repositories else None, + "tags": tags, + } + body = {k: v for k, v in body.items() if v is not None} + payload = self._http.request("POST", "/v1/tasks", json=body) + return TaskCreateResponse.model_validate(payload) + + def get(self, task_id: str) -> Task: + payload = self._http.request("GET", f"/v1/tasks/{task_id}") + return Task.model_validate(payload) + + def update( + self, + task_id: str, + *, + title: str | None = None, + is_archived: bool | None = None, + metadata: dict[str, Any] | None = None, + tags: builtins.list[str] | None = None, + ) -> Task: + body: dict[str, Any] = {} + if title is not None: + body["title"] = title + if is_archived is not None: + body["is_archived"] = is_archived + if metadata is not None: + body["metadata"] = metadata + if tags is not None: + # Replaces wholesale, so an explicit [] must reach the wire to + # clear — `if tags:` would silently drop it. + body["tags"] = tags + payload = self._http.request( + "PATCH", f"/v1/tasks/{task_id}", json=body + ) + return Task.model_validate(payload) + + def delete(self, task_id: str) -> None: + """Soft-delete a task. Requires ``tasks:delete`` scope. + + Note: dashboard-minted API keys (Tasks switch) do NOT grant + ``tasks:delete`` per the PR #678 scope model. Calls will return + ``IntrospectionAPIError(status_code=403)`` unless the caller + holds a wildcard or explicitly-elevated key. + """ + self._http.request("DELETE", f"/v1/tasks/{task_id}", expect="empty") + + def archive(self, task_id: str) -> None: + self._http.request( + "POST", f"/v1/tasks/{task_id}/archive", expect="empty" + ) + + def unarchive(self, task_id: str) -> None: + self._http.request( + "POST", f"/v1/tasks/{task_id}/unarchive", expect="empty" + ) + + def start(self, *, prompt: str, **kwargs: Any) -> RunHandle: + """Cursor-style sugar: create a task + return a handle on its initial run. + + Takes everything :meth:`create` takes, with ``prompt`` required — + ``start`` exists to stream a turn, and a task with no prompt has no + turn to stream. Forwarding ``**kwargs`` rather than restating the + parameters is deliberate: a hand-copied subset silently drops every + field added to ``create`` afterwards, which is how ``fork_share_id`` + and ``tags`` became unreachable from here. + + Example: + >>> run = runner.tasks.start(prompt="Summarize this repo") + >>> print(run.text()) + """ + res = self.create(prompt=prompt, **kwargs) + return RunHandle(res.task, res.run, self.runs) diff --git a/introspection_sdk/_sync/streaming.py b/introspection_sdk/_sync/streaming.py new file mode 100644 index 0000000..c34c185 --- /dev/null +++ b/introspection_sdk/_sync/streaming.py @@ -0,0 +1,41 @@ +# Generated by scripts/gen_sync.py from introspection_sdk/_async/streaming.py. Do not edit: +# change the async source and run `uv run python scripts/gen_sync.py`. +"""Re-exported by :mod:`introspection_sdk.streaming`.""" + +from __future__ import annotations + +import json +from collections.abc import Iterable, Iterator + +from introspection_sdk._streaming_common import ( + _SseAccumulator, + _SseEvent, +) +from introspection_sdk.schemas.agui import AGUIEvent, validate_ag_ui_event + + +def _parse_sse( + lines: Iterable[str], +) -> Iterator[_SseEvent]: + acc = _SseAccumulator() + for line in lines: + event = acc.feed(line) + if event is not None: + yield event + tail = acc.flush() + if tail is not None: + yield tail + + +def parse_ag_ui_events( + lines: Iterable[str], +) -> Iterator[AGUIEvent]: + """Yield AG-UI events from ``event: ag_ui`` SSE frames. + + Non-AG-UI transport frames, such as heartbeats, are ignored. + """ + for event in _parse_sse(lines): + if event.event != "ag_ui": + continue + payload = json.loads(event.data) + yield validate_ag_ui_event(payload) diff --git a/pyproject.toml b/pyproject.toml index fc2d957..61f8774 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,6 +96,11 @@ max-statements = 53 # target 50 max-returns = 10 # target 6 max-positional-args = 5 # target 5 (PLR0917, preview) +[tool.ruff.lint.per-file-ignores] +# Generated by scripts/gen_sync.py: the `for x in it: yield x` and +# `[x for x in it]` an `async for` leaves behind stay as written. +"introspection_sdk/_sync/**" = ["UP028", "C416"] + [tool.ruff.format] quote-style = "double" indent-style = "space" diff --git a/scripts/gen_sync.py b/scripts/gen_sync.py new file mode 100644 index 0000000..ce8de9c --- /dev/null +++ b/scripts/gen_sync.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Generate ``introspection_sdk/_sync/`` from ``introspection_sdk/_async/``. + +Run ``uv run python scripts/gen_sync.py`` after editing ``_async/`` and +commit both trees. "Sync code generation" in AGENTS.md has the rules. +""" + +from __future__ import annotations + +import re +import shutil +import subprocess +import sys +from pathlib import Path + +import unasync + +ROOT = Path(__file__).resolve().parent.parent +SOURCE = ROOT / "introspection_sdk" / "_async" +TARGET = ROOT / "introspection_sdk" / "_sync" + +HEADER = ( + "# Generated by scripts/gen_sync.py from {source}. Do not edit:\n" + "# change the async source and run `uv run python scripts/gen_sync.py`.\n" +) + +# Name tokens unasync renames, on top of its own defaults (__aenter__, +# __aexit__, __aiter__, __anext__, asynccontextmanager, AsyncIterable, +# AsyncIterator, AsyncGenerator, StopAsyncIteration) and the Async* / +# async_* / *_async rules in _Rule. A string literal is renamed only when +# its whole content is one of these names. +TOKENS = { + # The source and generated packages. + "_async": "_sync", + # httpx / httpx2. + "AsyncClient": "Client", + "AsyncBaseTransport": "BaseTransport", + "AsyncHTTPTransport": "HTTPTransport", + "AsyncByteStream": "SyncByteStream", + "aclose": "close", + "aiter_bytes": "iter_bytes", + "aiter_lines": "iter_lines", + "aiter_raw": "iter_raw", + "aread": "read", + "handle_async_request": "handle_request", +} + +# Regular expressions applied in order to each generated file, for what +# unasync cannot see as one token. +SUBSTITUTIONS: list[tuple[str, str]] = [ + # A block fenced by these comments exists only in the async twin. + ( + r"^[ \t]*# unasync: async-only\b.*\n(?s:.*?)^[ \t]*# unasync: end\n\n?", + "", + ), + (r"\bAwaitable\[((?:[^\[\]]|\[[^\[\]]*\])*)\]", r"\1"), + # A cross-reference in prose, like :class:`AsyncPager`. + (r"\bAsync([A-Z]\w*)", r"\1"), + (r"\bAsynchronous\b", "Synchronous"), + (r"\basynchronous\b", "synchronous"), +] + + +class _Rule(unasync.Rule): + def _unasync_name(self, name: str) -> str: + if name in self.token_replacements: + return self.token_replacements[name] + # AsyncTasks -> Tasks, _AsyncHttpClient -> _HttpClient. + for prefix in ("Async", "_Async"): + rest = name[len(prefix) :] + if name.startswith(prefix) and rest[:1].isupper(): + return prefix[: -len("Async")] + rest + # async_cursor_paginate -> cursor_paginate, + # parse_ag_ui_events_async -> parse_ag_ui_events. + if name.startswith("async_"): + return name[len("async_") :] + if name.endswith("_async"): + return name[: -len("_async")] + return name + + +def main() -> None: + shutil.rmtree(TARGET, ignore_errors=True) + sources = sorted(SOURCE.rglob("*.py")) + unasync.unasync_files( + [str(p) for p in sources], [_Rule("/_async/", "/_sync/", TOKENS)] + ) + for src in sources: + target = TARGET / src.relative_to(SOURCE) + text = target.read_text() + for pattern, replacement in SUBSTITUTIONS: + text = re.sub(pattern, replacement, text, flags=re.MULTILINE) + source = src.relative_to(ROOT).as_posix() + target.write_text(HEADER.format(source=source) + text) + # Sort the imports, drop the ones only the async code used, format. + ruff = [sys.executable, "-m", "ruff"] + for args in (["check", "--fix", "--select", "I,F401"], ["format"]): + subprocess.run([*ruff, *args, "--quiet", TARGET], check=True) + + +if __name__ == "__main__": + main() From c5aebfed7343a84ea0a09155885d5b2c0b8de05e Mon Sep 17 00:00:00 2001 From: Marcus Schiesser Date: Wed, 21 Oct 2026 20:10:24 +0700 Subject: [PATCH 5/5] ci: check the generated sync code and re-exported API in CI Run scripts/gen_sync.py in the Lint & Type Check job and as a pre-commit hook and fail on any diff or untracked file under introspection_sdk/_sync/, load scripts/griffe_reexports.py in the API compatibility check so re-exported classes are compared member by member, pin unasync in the dev extra (deptry DEP004 ignore scoped to it), lower the jscpd threshold to 16.3 for the new layout, and document the sync code generation in AGENTS.md. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/api-compat.yml | 10 +++-- .github/workflows/ci.yml | 12 ++++++ .jscpd.json | 9 +++- .pre-commit-config.yaml | 10 +++++ AGENTS.md | 70 +++++++++++++++++++++++++++++++- pyproject.toml | 17 +++++++- scripts/griffe_reexports.py | 58 ++++++++++++++++++++++++++ uv.lock | 39 ++++++++++++++++-- 8 files changed, 213 insertions(+), 12 deletions(-) create mode 100644 scripts/griffe_reexports.py diff --git a/.github/workflows/api-compat.yml b/.github/workflows/api-compat.yml index 845d3fa..7f0f497 100644 --- a/.github/workflows/api-compat.yml +++ b/.github/workflows/api-compat.yml @@ -1,7 +1,8 @@ # Fails a PR that breaks the public API against the latest v* release -# reachable from HEAD (griffe's own CLI). To break it on purpose, see "Public -# API compatibility" in AGENTS.md: a `!` in the PR title plus the -# `api-break` label. +# reachable from HEAD (griffe's own CLI, plus scripts/griffe_reexports.py so a +# class moved behind a re-export is still compared member by member). To break +# it on purpose, see "Public API compatibility" in AGENTS.md: a `!` in the PR +# title plus the `api-break` label. # # Its own workflow, not a job in ci.yml, because the override lives in PR # metadata: it has to run again when the title is edited or a label is added @@ -63,7 +64,8 @@ jobs: run: | against=$(git describe --tags --abbrev=0 --match 'v*' HEAD) if uv run griffe check introspection_sdk --search . \ - --against "$against" --format github; then + --against "$against" -e scripts/griffe_reexports.py \ + --format github; then exit 0 fi pr=$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca64ddd..17dd873 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,18 @@ jobs: - name: Run linting run: uv run ruff check . + # introspection_sdk/_sync/ is generated from introspection_sdk/_async/ + # by scripts/gen_sync.py; see "Sync code generation" in AGENTS.md. + - name: Check the generated sync code is up to date + run: | + uv run python scripts/gen_sync.py + git diff --exit-code -- introspection_sdk/_sync + untracked=$(git ls-files --others --exclude-standard introspection_sdk/_sync) + if [ -n "$untracked" ]; then + echo "Generated but not committed: $untracked" + exit 1 + fi + - name: Run type checking run: uv run ty check diff --git a/.jscpd.json b/.jscpd.json index b94883b..d46f055 100644 --- a/.jscpd.json +++ b/.jscpd.json @@ -2,13 +2,18 @@ "path": ["introspection_sdk"], "pattern": "**/*.py", "format": ["python"], - "ignore": ["**/.venv/**", "**/__pycache__/**", "**/*_pb2.py"], + "ignore": [ + "**/.venv/**", + "**/__pycache__/**", + "**/*_pb2.py", + "introspection_sdk/_sync/**" + ], "gitignore": true, "minTokens": 50, "minLines": 5, "mode": "weak", "kind": ["exact", "renamed"], "ignoreIdentifiers": true, - "threshold": 29.3, + "threshold": 16.3, "reporters": ["console"] } diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index aaeccb4..8e06534 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,6 +40,16 @@ repos: pass_filenames: false require_serial: true + - id: gen-sync + name: generated sync code is up to date + entry: >- + sh -c 'uv run python scripts/gen_sync.py && + git diff --exit-code -- introspection_sdk/_sync && + test -z "$(git ls-files --others --exclude-standard introspection_sdk/_sync)"' + language: system + files: ^(introspection_sdk/_(a)?sync/|scripts/gen_sync\.py$) + pass_filenames: false + # Enforces the coverage ratchet defined in [tool.coverage.report]. # Runs on the manual stage by default (`prek run --hook-stage manual coverage`) # so it doesn't block fast iteration; CI runs the same check on every PR. diff --git a/AGENTS.md b/AGENTS.md index 609d997..6e94fe2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,6 +122,60 @@ uv run ty check These are enforced by `.pre-commit-config.yaml`. Don't disable hooks with `--no-verify` — fix the underlying issue. +## Sync code generation + +Every class or function with an `Async*` twin is written once, as async +code under `introspection_sdk/_async/`. `scripts/gen_sync.py` deletes +`introspection_sdk/_sync/` and generates the sync twins into it with +[unasync](https://github.com/python-trio/unasync), pinned in the `dev` +extra, the way httpx/httpcore and urllib3 do. Each generated file opens with +a "Generated by scripts/gen_sync.py ... Do not edit" header. + +```shell +uv run python scripts/gen_sync.py # regenerate _sync/ after editing _async/ +``` + +- Edit `_async/`, never `_sync/`, then regenerate and commit both. The + `Lint & Type Check` job and the `gen-sync` pre-commit hook run the + script and fail on any diff or untracked file under `_sync/`. +- The public modules (`runner_resources/tasks.py`, `client.py`, ...) + re-export both twins, so no import path changes. Helper functions and + constants stay in the `_async/` module, and unasync copies them into the + sync twin. Only what both twins must share as one object lives in a + sibling `__common.py`: a class both use (`AuthSession`, + `RawResponse`, `RuntimeHandle`, ...) or process-wide state + (`UNKNOWN_EVENT_SKIPS`), plus the helpers those classes call. It cannot + live in the public module, which imports the twins. +- unasync drops `async` and `await` and renames name tokens: `Async*` and + `_Async*` lose the prefix, `async_*` and `*_async` lose the affix, and + `TOKENS` maps the rest (`AsyncClient` to `Client`, `aclose` to `close`, + ...). Name a new async helper so these rules cover it (`_sleep_async`, + not `_asleep`). What is not one token, such as `Awaitable[...]` or a + cross-reference in a docstring, goes through the regular expressions in + `SUBSTITUTIONS`; when that needs a new rule, prefer a hand-written pair + of helpers (`_sleep` / `_sleep_async` in `_backoff.py`) or prose that + reads right in both twins. ruff then sorts the imports, drops the ones + only the async code used, and formats `_sync/`. The `for x in y: yield x` + an `async for` leaves stays as written: `pyproject.toml` turns off + `UP028` and `C416` for `_sync/`. +- A block only the async twin has, such as `__aenter__` on the async + client, is fenced by `# unasync: async-only` and `# unasync: end` and left + out of the sync file. The same fence keeps an async docstring that says + something the sync one does not: fence the async docstring and write the + sync twin's docstring right after `# unasync: end`. +- Code that genuinely differs stays hand-written; don't bend it to fit the + generator. Today that is `Pager` / `AsyncPager` (`__getattr__` against + `__await__`), `stream_resumable` / `stream_resumable_async` (a test patches + `introspection_sdk.resumable._retry_delay`, which a generated module would + not see), `EmailCodeAuth` / `AsyncEmailCodeAuth` (the sync twin takes + thread locks), `Annotations` / `AsyncAnnotations` (`list` resolves email + filters before paging in sync and on the first fetch in async) and + `RuntimeHandle` / `AsyncRuntimeHandle` (`runtime_id` is a property against + a coroutine). +- A new twin goes in `_async/`. Regenerate, then re-export both names from + the public module. If the sync name is not the async name minus `Async`, + add the pair to `TOKENS`. + ## Dependency check ```shell @@ -165,7 +219,9 @@ copies, so `exact` stays listed). The threshold is a ratchet: don't raise it to land a change, factor the clone out instead. When a refactor lowers duplication, read the `Total` row's duplicated-lines percentage and lower `threshold` to just above it in the same PR. A generated file goes in the -config's `ignore` list. +config's `ignore` list: `introspection_sdk/_sync/` is there because it +repeats `introspection_sdk/_async/` by construction (see "Sync code +generation"), and counting it would hide every new copy. ## When in doubt @@ -215,7 +271,8 @@ latest `v*` release tag (resolved with `git describe --tags --abbrev=0 --match 'v*' HEAD`, never hard-coded). Run the same check locally: ```shell -uv run griffe check introspection_sdk --search . --against v0.29.0 +uv run griffe check introspection_sdk --search . --against v0.29.0 \ + -e scripts/griffe_reexports.py ``` Pass the tag CI uses, `git describe --tags --abbrev=0 --match 'v*' HEAD`. @@ -229,6 +286,15 @@ name without a leading underscore. Imported names are public only through are internal and free to change. To keep a new helper out of the contract, give it a leading underscore or leave it out of `__all__`. +`-e scripts/griffe_reexports.py` loads a griffe extension that replaces +each public alias whose target lives in `introspection_sdk` with the target +object, on the old release and the new code alike. Without it griffe +compares a public class against a re-export of it only shallowly: once it +has seen the old object's path it skips the members of the alias's target, +so a class moved behind a re-export (into `_async/` or `_sync/`, say) would +pass with a parameter removed. griffe has no option for this; keep the +extension in the command until it does. + Reported: a public object removed or renamed, a parameter removed, moved, made required or changed kind, a parameter default changed, a return or attribute type changed, a base class removed, and a changed attribute diff --git a/pyproject.toml b/pyproject.toml index 61f8774..fb23f81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,16 @@ otel = [ "opentelemetry-sdk >= 1.39.0, < 1.43.0", "opentelemetry-exporter-otlp-proto-http>=1.39.0,<1.43.0", ] -dev = ["griffe>=2.3.2", "mypy>=2.4.0", "pre-commit>=3.5.0", "ruff==0.14.14", "ty>=0.0.7"] +# unasync is pinned: scripts/gen_sync.py generates introspection_sdk/_sync/ +# and CI fails on any diff, so a new release must not change its output. +dev = [ + "griffe>=2.3.2", + "mypy>=2.4.0", + "pre-commit>=3.5.0", + "ruff==0.14.14", + "ty>=0.0.7", + "unasync==0.6.0", +] test = [ "pytest>=9.0.0", "pytest-asyncio>=1.3.0", @@ -164,3 +173,9 @@ extend_exclude = ["examples"] known_first_party = ["introspection_sdk"] # The `dev` and `test` extras are tooling, not runtime dependencies. optional_dependencies_dev_groups = ["dev", "test"] + +[tool.deptry.per_rule_ignores] +# scripts/gen_sync.py (a code generator) and scripts/griffe_reexports.py (a +# griffe extension for the API check) run in development and CI only, never +# shipped, so the dev dependencies they import are in the right place. +DEP004 = ["griffe", "unasync"] diff --git a/scripts/griffe_reexports.py b/scripts/griffe_reexports.py new file mode 100644 index 0000000..24af7bd --- /dev/null +++ b/scripts/griffe_reexports.py @@ -0,0 +1,58 @@ +"""Griffe extension: check a re-exported public object as the object itself. + +``griffe check`` compares a public name that changed from a class to a +re-export of it (``introspection_sdk.client.IntrospectionClient`` moving into +``_sync/`` behind an import, say) without comparing any of its members: +``_type_based_yield`` records the old object's path in ``seen_paths`` before +it resolves the alias, then recurses into the target with that same old +path and returns at once. Removing a parameter from such a class passes the +check. griffe has no option for this, so this extension swaps every public +alias whose target lives in the same package for the target object before +the check runs. Both sides then hold the real objects, and the breakage +rules apply to them as they do to a class that never moved. + +Loaded by ``griffe check -e scripts/griffe_reexports.py``, on the old +release and the new code alike. +""" + +from __future__ import annotations + +from typing import Any + +from griffe import ( + Alias, + AliasResolutionError, + Class, + CyclicAliasError, + Extension, + GriffeLoader, + Module, +) + + +class InlineReexports(Extension): + """Replace each public in-package alias with the object it points to.""" + + def on_package( + self, *, pkg: Module, loader: GriffeLoader, **kwargs: Any + ) -> None: + _inline(pkg, pkg) + + +def _inline(obj: Module | Class, pkg: Module) -> None: + for name, member in list(obj.members.items()): + if isinstance(member, Alias): + if not member.is_public: + continue + try: + target = member.final_target + except (AliasResolutionError, CyclicAliasError): + continue + if target.package is not pkg: + continue + # The target's own module is often private (`_sync/`); it is + # public here because this module exports it. + target.public = True + obj.members[name] = target + elif isinstance(member, (Module, Class)): + _inline(member, pkg) diff --git a/uv.lock b/uv.lock index 18b1b41..ed575fb 100644 --- a/uv.lock +++ b/uv.lock @@ -178,11 +178,11 @@ wheels = [ [[package]] name = "certifi" -version = "2026.10.19" +version = "2026.7.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" } +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/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" }, + { 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]] @@ -844,6 +844,7 @@ dev = [ { name = "pre-commit" }, { name = "ruff" }, { name = "ty" }, + { name = "unasync" }, ] otel = [ { name = "opentelemetry-exporter-otlp-proto-http" }, @@ -893,6 +894,7 @@ requires-dist = [ { name = "ruff", marker = "extra == 'dev'", specifier = "==0.14.14" }, { name = "trustme", marker = "extra == 'test'", specifier = ">=1.2.0" }, { name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.7" }, + { name = "unasync", marker = "extra == 'dev'", specifier = "==0.6.0" }, ] provides-extras = ["arrow", "proxy-httpx", "otel", "dev", "test"] @@ -1845,6 +1847,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" }, ] +[[package]] +name = "setuptools" +version = "84.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, +] + +[[package]] +name = "tokenize-rt" +version = "6.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/ed/8f07e893132d5051d86a553e749d5c89b2a4776eb3a579b72ed61f8559ca/tokenize_rt-6.2.0.tar.gz", hash = "sha256:8439c042b330c553fdbe1758e4a05c0ed460dbbbb24a606f11f0dee75da4cad6", size = 5476, upload-time = "2025-05-23T23:48:00.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/f0/3fe8c6e69135a845f4106f2ff8b6805638d4e85c264e70114e8126689587/tokenize_rt-6.2.0-py2.py3-none-any.whl", hash = "sha256:a152bf4f249c847a66497a4a95f63376ed68ac6abf092a2f7cfb29d044ecff44", size = 6004, upload-time = "2025-05-23T23:47:58.812Z" }, +] + [[package]] name = "tomli" version = "2.4.1" @@ -1966,6 +1986,19 @@ wheels = [ { 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]] +name = "unasync" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, + { name = "tokenize-rt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/4e/735dbc0885ca197bcd80a2479ca24035627e2e768c784261fc7f1b8d7600/unasync-0.6.0.tar.gz", hash = "sha256:a9d01ace3e1068b20550ab15b7f9723b15b8bcde728bc1770bcb578374c7ee58", size = 18755, upload-time = "2024-05-03T11:14:58.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/b5/d2842541718ffa12060854735587543120a31ebc339435e0bd0faf368541/unasync-0.6.0-py3-none-any.whl", hash = "sha256:9cf7aaaea9737e417d8949bf9be55dc25fdb4ef1f4edc21b58f76ff0d2b9d73f", size = 9959, upload-time = "2024-05-03T11:14:56.17Z" }, +] + [[package]] name = "urllib3" version = "2.8.0"