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/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 new file mode 100644 index 0000000..3732f73 --- /dev/null +++ b/introspection_sdk/_async/_http.py @@ -0,0 +1,267 @@ +"""Re-exported by :mod:`introspection_sdk._http`.""" + +from __future__ import annotations + +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, + _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, + ) + +#: 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 _AsyncHttpClient: + # unasync: async-only (the sync twin's docstring follows) + """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. + """ + + # unasync: end + """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.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: + """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, + ) + + 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() + # 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 = await self._request_headers(headers) + try: + res = await 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 await 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 + await _sleep_async(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() + + async def stream_bytes( + self, + path: str, + *, + params: Mapping[str, Any] | None = None, + headers: Mapping[str, str] | None = None, + ) -> AsyncIterator[bytes]: + req_headers = await self._request_headers(headers) + try: + async with self._client.stream( + "GET", + path, + params=_clean_params(params), + headers=req_headers, + ) as res: + if res.status_code >= 400: + await res.aread() + raise error_from_response(res) + async for chunk in res.aiter_bytes(): + yield chunk + except httpx.HTTPError as exc: + raise NetworkError(str(exc)) from exc + + async def stream_sse_lines( + self, + path: str, + *, + params: Mapping[str, Any] | None = None, + headers: Mapping[str, str] | None = None, + ) -> AsyncIterator[str]: + self._check_open() + req_headers = await self._request_headers( + {"Accept": "text/event-stream", **(headers or {})} + ) + try: + async with self._client.stream( + "GET", path, params=_clean_params(params), headers=req_headers + ) as res: + if res.status_code >= 400: + await res.aread() + raise error_from_response(res) + async for line in res.aiter_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/_async/auth.py b/introspection_sdk/_async/auth.py new file mode 100644 index 0000000..37ae3ff --- /dev/null +++ b/introspection_sdk/_async/auth.py @@ -0,0 +1,333 @@ +"""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, + } + + +async def _cp_post_async( + base_api_url: str, + path: str, + *, + data: dict[str, str] | None = None, + json: dict[str, str] | None = None, + transport: httpx.AsyncBaseTransport | None = None, +) -> httpx.Response: + try: + async with httpx.AsyncClient( + base_url=base_api_url, timeout=30.0, transport=transport + ) as client: + res = await 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 _post_token_form_async( + base_api_url: str, + form: dict[str, str], + *, + transport: httpx.AsyncBaseTransport | None = None, +) -> OAuthToken: + res = await _cp_post_async( + base_api_url, _TOKEN_PATH, data=form, transport=transport + ) + return OAuthToken.model_validate(res.json()) + + +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: + """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 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: + """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 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: + """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 await _post_token_form_async( + _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, + } + + +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: + """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``. + """ + await _cp_post_async( + _resolve_base_api_url(base_api_url), + _EMAIL_CODE_PATH, + json={"client_id": client_id, "email": email, "project": 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: + """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 await _post_token_form_async( + _resolve_base_api_url(base_api_url), + _email_code_form(email, code, client_id, project), + 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: + """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 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_revoke_session( + session_id: str, + org_id: str, + client_id: str, + *, + base_api_url: str | None = None, + transport: httpx.AsyncBaseTransport | None = None, +) -> None: + """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={ + "client_id": client_id, + "session_id": session_id, + "org_id": org_id, + }, + transport=transport, + ) + + +class AsyncCredentialProvider(Protocol): + """Supplies ``Authorization`` to a client and renews it after a ``401``. + + :class:`EmailCodeAuth` implements it; pass one as + ``IntrospectionClient(credentials=...)``. + """ + + async def authorization(self) -> str | None: + """The header value to send, or ``None`` to send none.""" + ... + + 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`.""" + ... diff --git a/introspection_sdk/_async/client.py b/introspection_sdk/_async/client.py new file mode 100644 index 0000000..6fce233 --- /dev/null +++ b/introspection_sdk/_async/client.py @@ -0,0 +1,231 @@ +"""Re-exported by :mod:`introspection_sdk.client`.""" + +from __future__ import annotations + +import os + +import httpx2 as httpx + +from introspection_sdk._http import _AsyncHttpClient +from introspection_sdk.auth import ( + AsyncCredentialProvider, + async_service_account_token, +) +from introspection_sdk.dev_target import client_headers +from introspection_sdk.resources import ( + AsyncAnnotations, + AsyncAutomations, + AsyncConnectors, + AsyncExperiments, + AsyncMembers, + AsyncRecipes, + AsyncRepositories, + AsyncRuntimes, + AsyncTags, +) +from introspection_sdk.runner_resources import ( + AsyncAppConnections, + AsyncConversations, + AsyncEvents, + AsyncFiles, + AsyncMetrics, + AsyncShares, + AsyncTasks, +) + + +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 + 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: AsyncRuntimes + experiments: AsyncExperiments + recipes: AsyncRecipes + repositories: AsyncRepositories + connectors: AsyncConnectors + members: AsyncMembers + annotations: AsyncAnnotations + tags: AsyncTags + automations: AsyncAutomations + events: AsyncEvents + tasks: AsyncTasks + files: AsyncFiles + conversations: AsyncConversations + metrics: AsyncMetrics + shares: AsyncShares + connections: AsyncAppConnections + + 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: AsyncCredentialProvider | None = None, + transport: httpx.AsyncBaseTransport | 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 = _AsyncHttpClient( + 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 = _AsyncHttpClient( + api_url=dp_url or self._base_api_url, + token=self._token, + additional_headers=self._additional_headers, + transport=transport, + credentials=credentials, + ) + self.runtimes = AsyncRuntimes( + self._http, + additional_headers=self._additional_headers, + ) + self.experiments = AsyncExperiments( + self._http, + additional_headers=self._additional_headers, + ) + self.recipes = AsyncRecipes(self._http) + self.repositories = AsyncRepositories(self._http, self._dp_http) + self.connectors = AsyncConnectors(self._http) + self.members = AsyncMembers(self._http) + self.annotations = AsyncAnnotations(self._http, self._dp_http) + self.tags = AsyncTags(self._dp_http) + self.automations = AsyncAutomations(self._dp_http) + self.events = AsyncEvents(self._dp_http) + self.tasks = AsyncTasks(self._dp_http) + self.files = AsyncFiles(self._dp_http) + self.conversations = AsyncConversations(self._dp_http) + self.metrics = AsyncMetrics(self._dp_http) + self.shares = AsyncShares(self._dp_http) + self.connections = AsyncAppConnections(self._dp_http) + + @classmethod + async 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.AsyncBaseTransport | None = None, + ) -> AsyncIntrospectionClient: + # unasync: async-only (the sync twin's docstring follows) + """Async twin of + :meth:`IntrospectionClient.from_service_account`. + + Mints the ``client_credentials`` token without blocking the event + loop (see + :func:`~introspection_sdk.auth.async_service_account_token`) and + returns a ready :class:`AsyncIntrospectionClient`:: + + client = await AsyncIntrospectionClient.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 = 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, + 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, + ) + + async def shutdown(self) -> None: + """Graceful shutdown — closes the underlying HTTP client.""" + try: + await self._http.aclose() + await self._dp_http.aclose() + 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 new file mode 100644 index 0000000..79ae774 --- /dev/null +++ b/introspection_sdk/_async/protocols.py @@ -0,0 +1,626 @@ +"""Re-exported by :mod:`introspection_sdk.protocols`.""" + +from __future__ import annotations + +import builtins +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 +from introspection_sdk.runner_resources._reads import ( + AsyncArrowPageIterator, + 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 AsyncRunHandle +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 AsyncTaskRunsResource(Protocol): + """Protocol of :class:`~introspection_sdk.runner_resources.tasks.AsyncTaskRuns`.""" + + async 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, + ) -> AsyncRunHandle: ... + + async def resume( + self, task_id: str, *, resume: list[ResumeEntry | dict[str, Any]] + ) -> AsyncRunHandle: ... + + async def get(self, task_id: str, run_id: str) -> TaskRun: ... + + async 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, + ) -> AsyncIterator[AGUIEvent]: ... + + +@runtime_checkable +class AsyncTasksResource(Protocol): + """Protocol of :class:`~introspection_sdk.runner_resources.tasks.AsyncTasks`.""" + + @property + def runs(self) -> AsyncTaskRunsResource: ... + + 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, + ) -> AsyncPager[Task, Paginated[Task]]: ... + + async 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: ... + + async def get(self, task_id: str) -> Task: ... + + async 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: ... + + async def delete(self, task_id: str) -> None: ... + + async def archive(self, task_id: str) -> None: ... + + async def unarchive(self, task_id: str) -> None: ... + + async def start(self, *, prompt: str, **kwargs: Any) -> AsyncRunHandle: ... + + +@runtime_checkable +class AsyncFileVersionsResource(Protocol): + """Protocol of :class:`~introspection_sdk.runner_resources.files.AsyncFileVersions`.""" + + def list( + self, + file_id: str, + *, + limit: int = 100, + next: str | None = None, + include_total: bool = False, + ) -> AsyncPager[File, Paginated[File]]: ... + + async def get(self, file_id: str, version_id: str) -> File: ... + + async 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 AsyncFilesResource(Protocol): + """Protocol of :class:`~introspection_sdk.runner_resources.files.AsyncFiles`.""" + + @property + def versions(self) -> AsyncFileVersionsResource: ... + + 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, + ) -> AsyncPager[File, Paginated[File]]: ... + + async 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: ... + + async 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: ... + + async def get(self, file_id: str) -> File: ... + + async def update( + self, + file_id: str, + *, + name: str | None = None, + metadata: dict[str, Any] | None = None, + tags: builtins.list[str] | None = None, + ) -> File: ... + + async def delete(self, file_id: str) -> None: ... + + async def download(self, file_id: str) -> bytes: ... + + def download_stream(self, file_id: str) -> AsyncIterator[bytes]: ... + + +@runtime_checkable +class AsyncConversationItemsResource(Protocol): + """Protocol of :class:`~introspection_sdk.runner_resources.conversations.AsyncConversationItems`.""" + + 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]: ... + + async def get( + self, + conversation_id: str, + item_id: str, + *, + include: builtins.list[ConversationItemInclude] | None = None, + ) -> GenAiSpan: ... + + +@runtime_checkable +class AsyncConversationsResource(Protocol): + """Protocol of :class:`~introspection_sdk.runner_resources.conversations.AsyncConversations`.""" + + @property + def items(self) -> AsyncConversationItemsResource: ... + + 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", + ) -> AsyncPager[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, + ) -> AsyncArrowPageIterator: ... + + def iterate( + self, *, max_items: int | None = None, **kwargs: Any + ) -> AsyncIterator[Conversation]: ... + + async def get(self, conversation_id: str) -> Conversation: ... + + async def export_json( + self, conversation_id: str, **params: Unpack[ConversationExportParams] + ) -> GenAiSpanList: ... + + def export_stream( + self, + conversation_id: str, + format: ConversationExportFormat, + **params: Unpack[ConversationExportParams], + ) -> AsyncIterator[bytes]: ... + + async 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: ... + + async 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: ... + + async def retrieve( + self, conversation_id: str, item_id: str | None = None + ) -> GenAiSpan | None: ... + + +@runtime_checkable +class AsyncEventsResource(Protocol): + """Protocol of :class:`~introspection_sdk.runner_resources.events.AsyncEvents`.""" + + 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]]: ... + + async def get(self, event_id: str) -> Event | UnknownEvent: ... + + def iterate( + self, + event_name: str | IntrospectionEventName, + *, + max_items: int | None = None, + **kwargs: Any, + ) -> AsyncIterator[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, + ) -> AsyncArrowPageIterator: ... + + +@runtime_checkable +class AsyncMetricsResource(Protocol): + """Protocol of :class:`~introspection_sdk.runner_resources.metrics.AsyncMetrics`.""" + + async def query( + self, request: MetricQueryRequest | dict[str, Any] + ) -> MetricQueryResponse: ... + + +@runtime_checkable +class AsyncSharesResource(Protocol): + """Protocol of :class:`~introspection_sdk.runner_resources.shares.AsyncShares`.""" + + 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, + ) -> AsyncPager[ResourceShare, Paginated[ResourceShare]]: ... + + async 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: ... + + async def get(self, share_id: str) -> ResourceShare: ... + + async def update( + self, + share_id: str, + *, + visible_from: datetime | None, + ) -> ResourceShare: ... + + async def delete(self, share_id: str) -> None: ... + + +@runtime_checkable +class AsyncAutomationsResource(Protocol): + """Protocol of :class:`~introspection_sdk.runner_resources.automations.AsyncAutomations`.""" + + 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]]: ... + + async def get(self, automation_id: UUID | str) -> Automation: ... + + 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 | None = None, + task_id: UUID | None = None, + next_trigger_at: datetime | None = None, + metadata: MetadataInput | None = None, + enabled: bool | None = None, + ) -> Automation: ... + + 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 | None = None, + task_id: UUID | None = None, + next_trigger_at: datetime | None = None, + metadata: MetadataInput | None = None, + enabled: bool | None = None, + ) -> Automation: ... + + async def delete(self, automation_id: UUID | str) -> None: ... + + async def trigger( + self, automation_id: UUID | str + ) -> AutomationTriggerResponse: ... + + +@runtime_checkable +class AsyncConnectionsResource(Protocol): + """Protocol of :class:`~introspection_sdk.runner_resources.connections.AsyncAppConnections`.""" + + 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 def create( + self, *, app: str, runtime: str | UUID + ) -> ConnectPage: ... + + async def get(self, connection_id: UUID | str) -> Connection: ... + + async def delete(self, connection_id: UUID | str) -> None: ... + + +@runtime_checkable +class AsyncDataPlaneResources(Protocol): + """The data-plane namespaces :class:`~introspection_sdk.AsyncIntrospectionClient` + and :class:`~introspection_sdk.runner.AsyncRunner` both expose.""" + + @property + def tasks(self) -> AsyncTasksResource: ... + + @property + def files(self) -> AsyncFilesResource: ... + + @property + def conversations(self) -> AsyncConversationsResource: ... + + @property + def events(self) -> AsyncEventsResource: ... + + @property + def metrics(self) -> AsyncMetricsResource: ... + + @property + def shares(self) -> AsyncSharesResource: ... + + @property + def automations(self) -> AsyncAutomationsResource: ... + + @property + def connections(self) -> AsyncConnectionsResource: ... 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 new file mode 100644 index 0000000..8ff8032 --- /dev/null +++ b/introspection_sdk/_async/proxy/_transport.py @@ -0,0 +1,102 @@ +"""Re-exported by :mod:`introspection_sdk.proxy._transport`.""" + +from __future__ import annotations + +from collections.abc import Awaitable, 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 + + +async def handle_async_request( + http: Any, + config: ProxyConfig, + request: Any, + *, + direct: Any, + egress: Any, + forward: Any | None, + send: Callable[[Any, Any], Awaitable[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 await 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/_async/proxy/httpx.py b/introspection_sdk/_async/proxy/httpx.py new file mode 100644 index 0000000..f8865db --- /dev/null +++ b/introspection_sdk/_async/proxy/httpx.py @@ -0,0 +1,72 @@ +"""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_async_request, + unique_transports, +) + + +class AsyncIntrospectionTransport(httpx.AsyncBaseTransport): + """Asynchronous legacy-``httpx`` endpoint-binding transport.""" + + def __init__( + self, + config: ProxyConfig | None = None, + *, + verify: ssl.SSLContext | bool | str = True, + _direct: httpx.AsyncBaseTransport | None = None, + _egress: httpx.AsyncBaseTransport | None = None, + _forward: httpx.AsyncBaseTransport | None = None, + ) -> None: + self.config = config or ProxyConfig.from_env() + ssl_context = httpx.create_ssl_context(verify=verify) + self._direct = _direct or httpx.AsyncHTTPTransport(verify=ssl_context) + self._egress = _egress or httpx.AsyncHTTPTransport(verify=ssl_context) + self._forward = _forward + if self._forward is None and self.config.forward_proxy_url: + self._forward = httpx.AsyncHTTPTransport( + 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 + ) -> AsyncIntrospectionTransport: + return cls(ProxyConfig.from_env(), verify=verify) + + async def handle_async_request( + self, request: httpx.Request + ) -> httpx.Response: + return await handle_async_request( + httpx, + self.config, + request, + direct=self._direct, + egress=self._egress, + forward=self._forward, + send=lambda transport, value: transport.handle_async_request( + value + ), + ) + + async def aclose(self) -> None: + for transport in unique_transports( + self._direct, self._egress, self._forward + ): + await transport.aclose() diff --git a/introspection_sdk/_async/proxy/httpx2.py b/introspection_sdk/_async/proxy/httpx2.py new file mode 100644 index 0000000..a30e1de --- /dev/null +++ b/introspection_sdk/_async/proxy/httpx2.py @@ -0,0 +1,67 @@ +"""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_async_request, + unique_transports, +) + + +class AsyncIntrospectionTransport(httpx.AsyncBaseTransport): + """Asynchronous ``httpx2`` endpoint-binding transport.""" + + def __init__( + self, + config: ProxyConfig | None = None, + *, + verify: ssl.SSLContext | bool | str = True, + _direct: httpx.AsyncBaseTransport | None = None, + _egress: httpx.AsyncBaseTransport | None = None, + _forward: httpx.AsyncBaseTransport | None = None, + ) -> None: + self.config = config or ProxyConfig.from_env() + ssl_context = httpx.create_ssl_context(verify=verify) + self._direct = _direct or httpx.AsyncHTTPTransport(verify=ssl_context) + self._egress = _egress or httpx.AsyncHTTPTransport(verify=ssl_context) + self._forward = _forward + if self._forward is None and self.config.forward_proxy_url: + self._forward = httpx.AsyncHTTPTransport( + 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 + ) -> AsyncIntrospectionTransport: + return cls(ProxyConfig.from_env(), verify=verify) + + async def handle_async_request( + self, request: httpx.Request + ) -> httpx.Response: + return await handle_async_request( + httpx, + self.config, + request, + direct=self._direct, + egress=self._egress, + forward=self._forward, + send=lambda transport, value: transport.handle_async_request( + value + ), + ) + + async def aclose(self) -> None: + for transport in unique_transports( + self._direct, self._egress, self._forward + ): + await transport.aclose() 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 new file mode 100644 index 0000000..e65ea60 --- /dev/null +++ b/introspection_sdk/_async/resources/connectors.py @@ -0,0 +1,582 @@ +"""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 _AsyncHttpClient +from introspection_sdk.pagination import ( + AsyncPager, + async_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 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: _AsyncHttpClient) -> None: + self._http = http + + def list( + self, + connector_id: UUID, + *, + 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} + payload = await self._http.request( + "GET", + f"/v1/connectors/{connector_id}/connections", + params=params, + ) + return Paginated[Connection].model_validate(payload) + + return async_cursor_paginate(fetch, start=next) + + async 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 = await 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) + + async 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 = await 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) + + async def get(self, connector_id: UUID, connection_id: UUID) -> Connection: + payload = await self._http.request( + "GET", + f"/v1/connectors/{connector_id}/connections/{connection_id}", + ) + return Connection.model_validate(payload) + + async def revoke(self, connector_id: UUID, connection_id: UUID) -> None: + """Revoke a connection — destroys the stored provider token.""" + await self._http.request( + "DELETE", + f"/v1/connectors/{connector_id}/connections/{connection_id}", + expect="empty", + ) + + +class AsyncConnectors: + """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 + self.connections = AsyncConnections(http) + + def list( + self, + *, + 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] = { + "limit": limit, + "next": cursor, + } + payload = await self._http.request( + "GET", "/v1/connectors", params=params + ) + return Paginated[Connector].model_validate(payload) + + return async_cursor_paginate(fetch, start=next) + + async 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 = await 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) + + async 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 = await 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"]] + + async 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 = await self._http.request( + "GET", + "/v1/connectors/custom/apps", + params={"q": query, "limit": limit}, + ) + return [ConnectorApp.model_validate(app) for app in payload["data"]] + + async 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 = await self._http.request( + "POST", + "/v1/connectors/discover-oauth", + json=ConnectorOAuthDiscoveryRequest(issuer=issuer).model_dump( + mode="json" + ), + ) + return ConnectorOAuthDiscoveryResponse.model_validate(payload) + + async def get(self, connector_id: UUID) -> Connector: + payload = await self._http.request( + "GET", + f"/v1/connectors/{connector_id}", + ) + return Connector.model_validate(payload) + + async 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 = await 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) + + async def delete(self, connector_id: UUID) -> None: + """Soft-delete a connector; its connections are revoked.""" + await self._http.request( + "DELETE", + f"/v1/connectors/{connector_id}", + expect="empty", + ) + + async 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 = await 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/_async/resources/experiments.py b/introspection_sdk/_async/resources/experiments.py new file mode 100644 index 0000000..d79bdde --- /dev/null +++ b/introspection_sdk/_async/resources/experiments.py @@ -0,0 +1,269 @@ +"""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 _AsyncHttpClient +from introspection_sdk.pagination import ( + AsyncPager, + async_cursor_paginate, +) +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 ( + RunCaller, + RunnerIdentity, + RunnerSpec, + RunRequest, +) + + +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:`AsyncExperimentHandle`. + """ + + # unasync: end + """CP ``/v1/experiments`` namespace. + + Also callable: ``client.experiments(id)`` returns an + :class:`ExperimentHandle`. + """ + + def __init__( + self, + http: _AsyncHttpClient, + *, + additional_headers: Mapping[str, str] | None = None, + ) -> None: + self._http = http + self._additional_headers = additional_headers + + def __call__(self, experiment_id: UUID) -> AsyncExperimentHandle: + return AsyncExperimentHandle(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, + ) -> 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] = { + "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 = await self._http.request( + "GET", "/v1/experiments", params=params + ) + return Paginated[Experiment].model_validate(payload) + + return async_cursor_paginate(fetch, start=next) + + async def get( + self, experiment_id: UUID, *, project: str | UUID | None = None + ) -> Experiment: + params: dict[str, Any] = {} + if project: + params["project"] = str(project) + payload = await self._http.request( + "GET", + f"/v1/experiments/{experiment_id}", + params=params or None, + ) + return Experiment.model_validate(payload) + + async 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( + await self._http.request("POST", "/v1/experiments", json=body) + ) + + async def update( + self, + experiment_id: UUID, + document: Mapping[str, Any], + *, + project: str | UUID | None = None, + ) -> Experiment: + return Experiment.model_validate( + await self._http.request( + "PATCH", + f"/v1/experiments/{experiment_id}", + params={ + "project": str(project) if project is not None else None + }, + json=dict(document), + ) + ) + + async def delete( + self, + experiment_id: UUID, + *, + project: str | UUID | None = None, + ) -> None: + await self._http.request( + "DELETE", + f"/v1/experiments/{experiment_id}", + params={"project": str(project) if project is not None else None}, + expect="empty", + ) + + async def _post_run( + self, + experiment_id: UUID, + options: RunRequest, + ) -> RunnerSpec: + body: dict[str, Any] = options.model_dump( + exclude_none=True, mode="json" + ) + payload = await self._http.request( + "POST", f"/v1/experiments/{experiment_id}/run", json=body + ) + return RunnerSpec.model_validate(payload) + + async def _start(self, experiment_id: UUID) -> Experiment: + payload = await self._http.request( + "POST", f"/v1/experiments/{experiment_id}/start" + ) + return Experiment.model_validate(payload) + + async def _end(self, experiment_id: UUID) -> Experiment: + payload = await self._http.request( + "POST", + f"/v1/experiments/{experiment_id}/end", + ) + return Experiment.model_validate(payload) + + async def _cancel(self, experiment_id: UUID) -> Experiment: + payload = await self._http.request( + "POST", f"/v1/experiments/{experiment_id}/cancel" + ) + return Experiment.model_validate(payload) + + +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, + *, + experiment_id: UUID, + ) -> None: + self._experiments = experiments + self._experiment_id = experiment_id + + @property + def experiment_id(self) -> UUID: + return self._experiment_id + + 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, + ) + eid = self._experiment_id + + async def refresher() -> RunnerSpec: + return await self._experiments._post_run(eid, options) + + spec = await refresher() + return AsyncRunner( + spec, + refresher=refresher, + additional_headers=self._experiments._additional_headers, + ) + + async def start(self) -> Experiment: + return await self._experiments._start(self._experiment_id) + + async def end(self) -> Experiment: + return await self._experiments._end(self._experiment_id) + + async def cancel(self) -> Experiment: + return await self._experiments._cancel(self._experiment_id) diff --git a/introspection_sdk/_async/resources/members.py b/introspection_sdk/_async/resources/members.py new file mode 100644 index 0000000..b199a5f --- /dev/null +++ b/introspection_sdk/_async/resources/members.py @@ -0,0 +1,206 @@ +"""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 _AsyncHttpClient +from introspection_sdk.pagination import ( + AsyncPager, + async_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 AsyncMembers: + """CP ``/v1/members`` namespace.""" + + 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]]: + # 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. + + 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: + """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 = 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: + """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 = await 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/_async/resources/recipes.py b/introspection_sdk/_async/resources/recipes.py new file mode 100644 index 0000000..ef347e8 --- /dev/null +++ b/introspection_sdk/_async/resources/recipes.py @@ -0,0 +1,61 @@ +"""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 +from introspection_sdk.pagination import ( + AsyncPager, + async_cursor_paginate, +) +from introspection_sdk.schemas.pagination import Paginated +from introspection_sdk.schemas.recipes import Recipe + + +class AsyncRecipes: + """CP ``/v1/recipes`` namespace.""" + + def __init__(self, http: _AsyncHttpClient) -> 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, + ) -> 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] = { + "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 = await self._http.request( + "GET", "/v1/recipes", params=params + ) + return Paginated[Recipe].model_validate(payload) + + return async_cursor_paginate(fetch, start=next) + + async def get(self, recipe_id: UUID) -> Recipe: + payload = await self._http.request("GET", f"/v1/recipes/{recipe_id}") + return Recipe.model_validate(payload) diff --git a/introspection_sdk/_async/resources/repositories.py b/introspection_sdk/_async/resources/repositories.py new file mode 100644 index 0000000..4a2c62d --- /dev/null +++ b/introspection_sdk/_async/resources/repositories.py @@ -0,0 +1,244 @@ +"""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, _AsyncHttpClient +from introspection_sdk.pagination import ( + AsyncPager, + async_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 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: _AsyncHttpClient) -> None: + self._http = http + + def __call__( + self, + repository_id: UUID | str, + path: str = "", + ref: str | None = None, + limit: int | None = None, + ) -> AsyncPager[RepositoryEntry, RepositoryDirectory]: + """List a directory's entries across pages. + + Iterating raises :class:`ValueError` when ``path`` is a file. + """ + + async def fetch(cursor: str | None) -> RepositoryDirectory: + payload = await self._http.request( + "GET", + _contents_path(repository_id, path), + params={"ref": ref, "cursor": cursor, "limit": limit}, + ) + return _directory(payload, path) + + return AsyncPager(fetch, items=_pager_items, next_cursor=_pager_next) + + async 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 = await self._http.request( + "GET", + _contents_path(repository_id, path), + params={"ref": ref}, + ) + return _CONTENT.validate_python(payload) + + +class AsyncRepositoryMerges: + """DP ``/v1/repositories/{id}/merges`` namespace.""" + + def __init__(self, http: _AsyncHttpClient) -> None: + self._http = http + + async 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 = await self._http.request( + "POST", + _merges_path(repository_id), + json=_merge_body(base, head, commit_message), + expect="raw", + ) + return _merge(response) + + +class AsyncRepositories: + """CP ``/v1/repositories`` namespace, plus DP :attr:`contents`, + commits and :attr:`merges`.""" + + contents: AsyncRepositoryContents + merges: AsyncRepositoryMerges + + def __init__( + self, http: _AsyncHttpClient, dp_http: _AsyncHttpClient + ) -> None: + self._http = http + self._dp_http = dp_http + self.contents = AsyncRepositoryContents(dp_http) + self.merges = AsyncRepositoryMerges(dp_http) + + async 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 = await self._http.request( + "GET", + "/v1/repositories", + params={"project": project, "slug": slug}, + ) + return _REPOSITORY_LIST.validate_python(payload) + + async def get( + self, + repository_id: UUID | str, + *, + project: str | UUID | None = None, + ) -> Repository: + payload = await 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, + ) -> AsyncPager[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.""" + + async def fetch(cursor: str | None) -> Paginated[RepositoryCommit]: + payload = await 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 async_cursor_paginate(fetch) + + async def commit( + self, repository_id: UUID | str, sha: str + ) -> RepositoryCommitDetail: + """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) diff --git a/introspection_sdk/_async/resources/runtimes.py b/introspection_sdk/_async/resources/runtimes.py new file mode 100644 index 0000000..13415c7 --- /dev/null +++ b/introspection_sdk/_async/resources/runtimes.py @@ -0,0 +1,212 @@ +"""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 _AsyncHttpClient +from introspection_sdk.pagination import ( + AsyncPager, + async_cursor_paginate, +) +from introspection_sdk.resources._runtimes_common import ( + AsyncRuntimeHandle, +) +from introspection_sdk.schemas.pagination import Paginated +from introspection_sdk.schemas.runner import ( + RunnerSpec, + RunRequest, +) +from introspection_sdk.schemas.runtimes import Runtime + + +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, + *, + 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 + ) -> AsyncRuntimeHandle: + # 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 AsyncRuntimeHandle( + self, + runtime=runtime, + project=project, + ) + + def handle(self, runtime_id: UUID) -> AsyncRuntimeHandle: + """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 AsyncRuntimeHandle( + 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, + ) -> 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.""" + + async 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 = await self._http.request( + "GET", "/v1/runtimes", params=params + ) + return Paginated[Runtime].model_validate(payload) + + return async_cursor_paginate(fetch, start=next) + + async def get( + self, runtime_id: UUID, *, project: str | None = None + ) -> Runtime: + payload = await self._http.request( + "GET", + f"/v1/runtimes/{runtime_id}", + params={"project": project}, + ) + return Runtime.model_validate(payload) + + 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 + standalone form of ``client.runtimes(runtime)`` resolution, handy + for a server broker that resolves a concrete ``runtime_id`` to hand to a + 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, + project=project, + ).page() + if not page.records: + raise _no_runtime(runtime) + return page.records[0] + + # --- /run -------------------------------------------------------- + + async 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 = await 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/_async/resources/tags.py b/introspection_sdk/_async/resources/tags.py new file mode 100644 index 0000000..b9c25ea --- /dev/null +++ b/introspection_sdk/_async/resources/tags.py @@ -0,0 +1,107 @@ +"""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 _AsyncHttpClient +from introspection_sdk.pagination import ( + AsyncPager, + async_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 AsyncTags: + def __init__(self, http: _AsyncHttpClient) -> None: + self._http = http + + def list( + self, + kind: TagKind, + *, + search: str | None = None, + limit: int | None = None, + next: str | None = None, + ) -> AsyncPager[Tag, Paginated[Tag]]: + """List one catalogue. `kind` is required: no listing spans both.""" + + async def fetch(cursor: str | None) -> Paginated[Tag]: + payload = await self._http.request( + "GET", + "/v1/tags", + params={ + "kind": kind.value, + "search": search, + "limit": limit, + "next": cursor, + }, + ) + return Paginated[Tag].model_validate(payload) + + return async_cursor_paginate(fetch, start=next) + + async 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( + await self._http.request( + "POST", "/v1/tags", json=_create_body(tag) + ) + ) + + async def get(self, kind: TagKind, name: str) -> Tag: + return Tag.model_validate( + await self._http.request("GET", _path(kind, name)) + ) + + async def update(self, kind: TagKind, name: str, patch: TagUpdate) -> Tag: + return Tag.model_validate( + await self._http.request( + "PATCH", _path(kind, name), json=_update_body(kind, patch) + ) + ) + + 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") diff --git a/introspection_sdk/_async/runner.py b/introspection_sdk/_async/runner.py new file mode 100644 index 0000000..4aae855 --- /dev/null +++ b/introspection_sdk/_async/runner.py @@ -0,0 +1,218 @@ +"""Re-exported by :mod:`introspection_sdk.runner`.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Mapping +from datetime import datetime + +from introspection_sdk._errors import RunnerExpiredError +from introspection_sdk._http import _AsyncHttpClient +from introspection_sdk.runner_resources import ( + AsyncAutomations, + AsyncConversations, + AsyncEvents, + AsyncFiles, + AsyncMetrics, + AsyncRunnerAppConnections, + AsyncShares, + AsyncTasks, +) +from introspection_sdk.schemas.runner import ( + RunnerContext, + RunnerDeployment, + RunnerSpec, +) + + +class AsyncRunner: + # unasync: async-only (the sync twin's docstring follows) + """Async twin of :class:`Runner`. + + Constructed indirectly by ``client.runtimes(...).run()`` / + ``client.experiments(...).run()`` on an ``AsyncIntrospectionClient``. + Holds a private ``httpx.AsyncClient``-backed HTTP client pointed at + ``spec.deployment.endpoint`` with the bearer JWT picked from + ``spec.session_token``. Awaitable lifecycle: ``await runner.close()``, + ``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, + *, + refresher: Callable[[], Awaitable[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 = AsyncTasks(self._http) + self._files = AsyncFiles(self._http) + self._conversations = AsyncConversations(self._http) + self._events = AsyncEvents(self._http) + self._metrics = AsyncMetrics(self._http) + self._shares = AsyncShares(self._http) + self._automations = AsyncAutomations(self._http) + self._connections = AsyncRunnerAppConnections( + self._http, spec.runtime_context.runtime_group_id + ) + + def _build_http(self, spec: RunnerSpec) -> _AsyncHttpClient: + return _AsyncHttpClient( + 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) -> AsyncTasks: + """DP ``/v1/tasks`` namespace bound to this Runner.""" + self._check_open() + return self._tasks + + @property + def files(self) -> AsyncFiles: + """DP ``/v1/files`` namespace bound to this Runner.""" + self._check_open() + return self._files + + @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 + + @property + def events(self) -> AsyncEvents: + """Read-only DP ``/v1/events`` namespace bound to this Runner.""" + self._check_open() + return self._events + + @property + def metrics(self) -> AsyncMetrics: + """Read-only DP ``/v1/metrics`` namespace bound to this Runner.""" + self._check_open() + return self._metrics + + @property + def shares(self) -> AsyncShares: + """DP ``/v1/shares`` read-sharing namespace bound to this Runner.""" + self._check_open() + return self._shares + + @property + def automations(self) -> AsyncAutomations: + """DP ``/v1/automations`` namespace bound to this Runner.""" + self._check_open() + return self._automations + + @property + def connections(self) -> AsyncRunnerAppConnections: + """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 + + async 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 = await self._refresher() + old_http = self._http + self._spec = new_spec + self._http = self._build_http(new_spec) + self._tasks = AsyncTasks(self._http) + self._files = AsyncFiles(self._http) + self._conversations = AsyncConversations(self._http) + self._events = AsyncEvents(self._http) + self._metrics = AsyncMetrics(self._http) + self._shares = AsyncShares(self._http) + self._automations = AsyncAutomations(self._http) + self._connections = AsyncRunnerAppConnections( + self._http, new_spec.runtime_context.runtime_group_id + ) + try: + await old_http.aclose() + except Exception: # noqa: BLE001 — best-effort cleanup + pass + + async 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: + await self._http.aclose() + except Exception: # noqa: BLE001 — best-effort cleanup + pass + + async def __aenter__(self) -> AsyncRunner: + return self + + async def __aexit__(self, *_exc_info: object) -> None: + await self.close() 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 new file mode 100644 index 0000000..5aba01f --- /dev/null +++ b/introspection_sdk/_async/runner_resources/_reads.py @@ -0,0 +1,200 @@ +"""Re-exported by :mod:`introspection_sdk.runner_resources._reads`.""" + +from __future__ import annotations + +import re +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 + +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 AsyncArrowPageIterator: + """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], Awaitable[RawResponse]], + *, + start: str | None = None, + ) -> None: + self._fetch = fetch + self._start = start + + async def __aiter__(self) -> AsyncIterator[pa.Table]: + cursor = self._start + while True: + raw = await 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 + + async def read_all(self) -> pa.Table: + """Fetch every page and concatenate into one ``pyarrow.Table``.""" + pa = import_pyarrow() + tables = [table async for table in self] + if not tables: + return pa.table({}) + return pa.concat_tables(tables) diff --git a/introspection_sdk/_async/runner_resources/automations.py b/introspection_sdk/_async/runner_resources/automations.py new file mode 100644 index 0000000..0ce8b69 --- /dev/null +++ b/introspection_sdk/_async/runner_resources/automations.py @@ -0,0 +1,270 @@ +"""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 _AsyncHttpClient +from introspection_sdk.pagination import ( + AsyncPager, + async_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 AsyncAutomations: + """DP ``/v1/automations`` namespace.""" + + 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]]: + # 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. + + 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).""" + + 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: + """Read one automation, soft-deleted ones included.""" + 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: + """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 = 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: + """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 = 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: + """Soft-delete an automation. A project default + (``observation_synthesis``, ``project_check_in``) cannot be deleted + and answers 409; disable it instead.""" + await self._http.request( + "DELETE", _path(automation_id), expect="empty" + ) + + async 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 = await self._http.request( + "POST", f"{_path(automation_id)}/trigger" + ) + return AutomationTriggerResponse.model_validate(payload) diff --git a/introspection_sdk/_async/runner_resources/connections.py b/introspection_sdk/_async/runner_resources/connections.py new file mode 100644 index 0000000..335ba8d --- /dev/null +++ b/introspection_sdk/_async/runner_resources/connections.py @@ -0,0 +1,142 @@ +"""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 _AsyncHttpClient +from introspection_sdk.pagination import ( + AsyncPager, + async_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 AsyncAppConnections: + """DP ``/v1/connections`` namespace.""" + + 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]]: + """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.""" + + 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: + """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 = 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: + """Read one connection.""" + return Connection.model_validate( + await self._http.request("GET", _path(connection_id)) + ) + + async def delete(self, connection_id: UUID | str) -> None: + """Remove one connection.""" + await self._http.request( + "DELETE", _path(connection_id), expect="empty" + ) + + +class AsyncRunnerAppConnections(AsyncAppConnections): + """``runner.connections``: :class:`AppConnections` whose ``create`` + defaults ``runtime`` to the runner's runtime group.""" + + def __init__( + self, http: _AsyncHttpClient, runtime_group_id: UUID | None + ) -> None: + super().__init__(http) + self._runtime_group_id = runtime_group_id + + async 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 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 new file mode 100644 index 0000000..4522a05 --- /dev/null +++ b/introspection_sdk/_async/runner_resources/conversations.py @@ -0,0 +1,753 @@ +"""Re-exported by :mod:`introspection_sdk.runner_resources.conversations`.""" + +from __future__ import annotations + +import builtins +from collections.abc import AsyncIterator +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, _AsyncHttpClient +from introspection_sdk.pagination import ( + AsyncPager, + async_cursor_paginate, +) +from introspection_sdk.runner_resources._conversations_common import ( + ConversationExportParams, +) +from introspection_sdk.runner_resources._reads import ( + ARROW_ACCEPT_HEADERS, + TRAJECTORY_ACCEPT_HEADERS, + AsyncArrowPageIterator, + 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 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: _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]: + # 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 + 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. + """ + # 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 + 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. + """ + + async 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 = await 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 AsyncPager( + fetch, + items=lambda page: page.data, + next_cursor=_conversation_items_next, + start=next, + ) + + async 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 = await self._http.request( + "GET", + f"/v1/conversations/{conversation_id}/items/{item_id}", + params=params, + ) + return GenAiSpan.model_validate(_normalize_item_payload(payload)) + + +class AsyncConversations: + # unasync: async-only (the sync twin's docstring follows) + """Async twin of :class:`Conversations`. Read-only + (``/v1/conversations``). + + Both :meth:`list` and :meth:`items.list ` + return an auto-paging :class:`~introspection_sdk.pagination.AsyncPager` + 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) + + 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", + ) -> 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, + lookback=lookback, + start_date=start_date, + end_date=end_date, + ) + + async 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 = await 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 = await self._http.request( + "GET", "/v1/conversations", params=params + ) + return Paginated[Conversation].model_validate(payload) + + return async_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, + ) -> 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, + lookback=lookback, + start_date=start_date, + end_date=end_date, + ) + + async 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 = await self._http.request( + "GET", + "/v1/conversations", + params=params, + headers=ARROW_ACCEPT_HEADERS, + expect="raw", + ) + assert isinstance(raw, RawResponse) + return raw + + return AsyncArrowPageIterator(fetch, start=next) + + async def iterate( + self, + *, + max_items: int | None = None, + **kwargs: Any, + ) -> AsyncIterator[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 + async for record in self.list(**kwargs): + yield record + yielded += 1 + if max_items is not None and yielded >= max_items: + return + + async def get(self, conversation_id: str) -> Conversation: + """Fetch one conversation summary with its complete agent index.""" + payload = await self._http.request( + "GET", f"/v1/conversations/{conversation_id}" + ) + return Conversation.model_validate(payload) + + async def export_json( + self, + conversation_id: str, + **params: Unpack[ConversationExportParams], + ) -> GenAiSpanList: + """Export one complete conversation as the standard GenAI-span list.""" + payload = await 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], + ) -> AsyncIterator[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), + ) + + async 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 = await 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) + + async 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 = await 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) + + async 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 await self._find_latest_turn_id(conversation_id) + if target_id is None: + return None + 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 + ``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`, 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. + async 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/_async/runner_resources/events.py b/introspection_sdk/_async/runner_resources/events.py new file mode 100644 index 0000000..f29152e --- /dev/null +++ b/introspection_sdk/_async/runner_resources/events.py @@ -0,0 +1,417 @@ +"""Re-exported by :mod:`introspection_sdk.runner_resources.events`.""" + +from __future__ import annotations + +import builtins +import json +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 +from introspection_sdk.pagination import ( + AsyncPager, + async_cursor_paginate, +) +from introspection_sdk.runner_resources._events_common import ( + UNKNOWN_EVENT_SKIPS, +) +from introspection_sdk.runner_resources._reads import ( + ARROW_ACCEPT_HEADERS, + AsyncArrowPageIterator, + 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 AsyncEvents: + """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: _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]]: + # 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. + + ``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 + + async 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 = await 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 = await self._http.request( + "GET", "/v1/events", params=params + ) + return validate_event_page(payload) + + return async_cursor_paginate(fetch, start=next) + + async 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 = await self._http.request("GET", f"/v1/events/{event_id}") + return validate_single_event(payload) + + async def iterate( + self, + event_name: str | IntrospectionEventName, + *, + max_items: int | None = None, + **kwargs: Any, + ) -> AsyncIterator[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 + async 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, + ) -> 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, + 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) -> 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 = await self._http.request( + "GET", + "/v1/events", + params=params, + headers=ARROW_ACCEPT_HEADERS, + expect="raw", + ) + assert isinstance(raw, RawResponse) + return raw + + return AsyncArrowPageIterator(fetch, start=next) diff --git a/introspection_sdk/_async/runner_resources/files.py b/introspection_sdk/_async/runner_resources/files.py new file mode 100644 index 0000000..a5726cd --- /dev/null +++ b/introspection_sdk/_async/runner_resources/files.py @@ -0,0 +1,335 @@ +"""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 AsyncIterator, Iterator +from pathlib import Path +from typing import IO, Any + +from introspection_sdk._http import _AsyncHttpClient +from introspection_sdk.pagination import ( + AsyncPager, + async_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 AsyncFileVersions: + def __init__(self, http: _AsyncHttpClient) -> None: + self._http = http + + def list( + self, + file_id: str, + *, + limit: int = 100, + 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] = { + "limit": limit, + "next": cursor, + "include_total": include_total, + } + payload = await self._http.request( + "GET", f"/v1/files/{file_id}/versions", params=params + ) + return Paginated[File].model_validate(payload) + + return async_cursor_paginate(fetch, start=next) + + async def get(self, file_id: str, version_id: str) -> File: + payload = await self._http.request( + "GET", f"/v1/files/{file_id}/versions/{version_id}" + ) + return File.model_validate(payload) + + async 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 = await self._http.request( + "POST", + f"/v1/files/{file_id}/versions", + files=files, + data=data, + ) + return File.model_validate(payload) + + +class AsyncFiles: + def __init__(self, http: _AsyncHttpClient) -> None: + self._http = http + self.versions = AsyncFileVersions(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, + ) -> 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, + digits, ``_`` and ``-``. A server that predates the filter ignores it + and returns the unfiltered list.""" + + async 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 = await self._http.request( + "GET", "/v1/files", params=params + ) + return Paginated[File].model_validate(payload) + + return async_cursor_paginate(fetch, start=next) + + async 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: + # 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 + file. A request that adds a new version to an existing file keeps + that file's tags; change them with :meth:`update`. + + Example: + >>> await runner.files.upload( + ... file=Path("input.jsonl"), + ... 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, + ct, + ): + files = {"file": (n, body, ct)} + data = _upload_form(n, file_type, metadata, tags) + payload = await self._http.request( + "POST", "/v1/files", files=files, data=data + ) + return File.model_validate(payload) + + async 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 = await self._http.request( + "POST", "/v1/files", json=body.model_dump(exclude_none=True) + ) + return File.model_validate(payload) + + async def get(self, file_id: str) -> File: + payload = await self._http.request("GET", f"/v1/files/{file_id}") + return File.model_validate(payload) + + async 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 = await self._http.request( + "PATCH", f"/v1/files/{file_id}", json=body + ) + return File.model_validate(payload) + + async def delete(self, file_id: str) -> None: + await self._http.request( + "DELETE", f"/v1/files/{file_id}", expect="empty" + ) + + async def download(self, file_id: str) -> bytes: + return await self._http.request( + "GET", f"/v1/files/{file_id}/content", expect="bytes" + ) + + def download_stream(self, file_id: str) -> AsyncIterator[bytes]: + return self._http.stream_bytes(f"/v1/files/{file_id}/content") diff --git a/introspection_sdk/_async/runner_resources/metrics.py b/introspection_sdk/_async/runner_resources/metrics.py new file mode 100644 index 0000000..61646ff --- /dev/null +++ b/introspection_sdk/_async/runner_resources/metrics.py @@ -0,0 +1,39 @@ +"""Re-exported by :mod:`introspection_sdk.runner_resources.metrics`.""" + +from __future__ import annotations + +from typing import Any + +from introspection_sdk._http import _AsyncHttpClient +from introspection_sdk.schemas.metrics import ( + MetricQueryRequest, + MetricQueryResponse, +) + + +class AsyncMetrics: + """Read-only Metrics API (``POST /v1/metrics``).""" + + def __init__(self, http: _AsyncHttpClient) -> None: + self._http = http + + 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). + 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 = 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 new file mode 100644 index 0000000..14587fd --- /dev/null +++ b/introspection_sdk/_async/runner_resources/shares.py @@ -0,0 +1,175 @@ +"""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 +from introspection_sdk.pagination import ( + AsyncPager, + async_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 AsyncShares: + """Asynchronous `/v1/shares` resource.""" + + def __init__(self, http: _AsyncHttpClient) -> 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, + ) -> AsyncPager[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.""" + + async def fetch(cursor: str | None) -> Paginated[ResourceShare]: + payload = await 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 async_cursor_paginate(fetch, start=next) + + async 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 = await 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) + + async def get(self, share_id: str) -> ResourceShare: + payload = await self._http.request("GET", f"/v1/shares/{share_id}") + return ResourceShare.model_validate(payload) + + async 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 = await self._http.request( + "PATCH", + f"/v1/shares/{share_id}", + json=_update_body(visible_from), + ) + return ResourceShare.model_validate(payload) + + async def delete(self, share_id: str) -> None: + await self._http.request( + "DELETE", f"/v1/shares/{share_id}", expect="empty" + ) diff --git a/introspection_sdk/_async/runner_resources/tasks.py b/introspection_sdk/_async/runner_resources/tasks.py new file mode 100644 index 0000000..57a4694 --- /dev/null +++ b/introspection_sdk/_async/runner_resources/tasks.py @@ -0,0 +1,434 @@ +"""Re-exported by :mod:`introspection_sdk.runner_resources.tasks`.""" + +from __future__ import annotations + +import builtins +from collections.abc import AsyncIterator +from typing import Any + +from introspection_sdk._errors import RunFailedError, StreamIncompleteError +from introspection_sdk._http import _AsyncHttpClient +from introspection_sdk.pagination import ( + AsyncPager, + async_cursor_paginate, +) +from introspection_sdk.resumable import ( + stream_resumable_async, +) +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 AsyncRunHandle: + # unasync: async-only (the sync twin's docstring follows) + """Async twin of :class:`RunHandle`. + + Returned by ``AsyncTasks.start(...)`` and ``AsyncTaskRuns.create(...)``. + ``await handle.stream()`` is replaced by ``async for ev in + handle.stream()`` to iterate AG-UI events; ``await handle.text()`` + collects text frames; ``await handle.cancel()`` uses the default abort + 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, + run: TaskRun, + runs: AsyncTaskRuns, + ) -> 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, + ) -> AsyncIterator[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, + ) + + async def cancel( + self, + options: TaskCancelRequest | dict[str, Any] | None = None, + ) -> TaskCancelResponse: + return await self._runs.cancel( + str(self.run.task_id), self.run.id, options=options + ) + + async 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] = [] + async 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 AsyncTaskRuns: + def __init__(self, http: _AsyncHttpClient) -> None: + self._http = http + + async 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, + ) -> AsyncRunHandle: + 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 = await self._http.request( + "POST", f"/v1/tasks/{task_id}/runs", json=body + ) + res = TaskRunResponse.model_validate(payload) + return AsyncRunHandle(None, res.run, self) + + async def resume( + self, + task_id: str, + *, + resume: list[ResumeEntry | dict[str, Any]], + ) -> AsyncRunHandle: + payload = await self._http.request( + "POST", f"/v1/tasks/{task_id}/runs", json=_resume_body(resume) + ) + res = TaskRunResponse.model_validate(payload) + return AsyncRunHandle(None, res.run, self) + + async def get(self, task_id: str, run_id: str) -> TaskRun: + payload = await self._http.request( + "GET", f"/v1/tasks/{task_id}/runs/{run_id}" + ) + return TaskRun.model_validate(payload) + + async 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 await self._cancel_with(task_id, run_id, request) + payload = await self._http.request( + "POST", f"/v1/tasks/{task_id}/runs/{run_id}/cancel" + ) + return TaskCancelResponse.model_validate(payload) + + async def _cancel_with( + self, task_id: str, run_id: str, request: TaskCancelRequest + ) -> TaskCancelResponse: + payload = await 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, + ) -> 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, + run_id, + max_reconnects=max_reconnects, + backoff=backoff, + timeout=timeout, + ) + + +class AsyncTasks: + def __init__(self, http: _AsyncHttpClient) -> None: + self._http = http + self.runs = AsyncTaskRuns(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, + ) -> 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] = { + "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 = await self._http.request( + "GET", "/v1/tasks", params=params + ) + return Paginated[Task].model_validate(payload) + + return async_cursor_paginate(fetch, start=next) + + async 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 = await self._http.request("POST", "/v1/tasks", json=body) + return TaskCreateResponse.model_validate(payload) + + async def get(self, task_id: str) -> Task: + payload = await self._http.request("GET", f"/v1/tasks/{task_id}") + return Task.model_validate(payload) + + async 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 = await self._http.request( + "PATCH", f"/v1/tasks/{task_id}", json=body + ) + return Task.model_validate(payload) + + async 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. + """ + await self._http.request( + "DELETE", f"/v1/tasks/{task_id}", expect="empty" + ) + + async def archive(self, task_id: str) -> None: + await self._http.request( + "POST", f"/v1/tasks/{task_id}/archive", expect="empty" + ) + + async def unarchive(self, task_id: str) -> None: + await self._http.request( + "POST", f"/v1/tasks/{task_id}/unarchive", expect="empty" + ) + + 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. + + 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 = 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 new file mode 100644 index 0000000..27b191c --- /dev/null +++ b/introspection_sdk/_async/streaming.py @@ -0,0 +1,39 @@ +"""Re-exported by :mod:`introspection_sdk.streaming`.""" + +from __future__ import annotations + +import json +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 + + +async def _parse_sse_async( + lines: AsyncIterable[str], +) -> AsyncIterator[_SseEvent]: + acc = _SseAccumulator() + async 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_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. + """ + async for event in _parse_sse_async(lines): + if event.event != "ag_ui": + continue + 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.py b/introspection_sdk/_http.py index da962c8..0f6c2ea 100644 --- a/introspection_sdk/_http.py +++ b/introspection_sdk/_http.py @@ -16,444 +16,21 @@ from __future__ import annotations -import asyncio -import time -from collections.abc import AsyncIterator, Iterator, Mapping -from dataclasses import dataclass -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._errors import ( - NetworkError, - RunnerExpiredError, - _parse_retry_after, - error_from_response, +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 -if TYPE_CHECKING: - from introspection_sdk.auth import ( - AsyncCredentialProvider, - 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 - - -@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] - - -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 - 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: - res = await 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 await 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 - await asyncio.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() - - async def stream_bytes( - self, - path: str, - *, - params: Mapping[str, Any] | None = None, - headers: Mapping[str, str] | None = None, - ) -> AsyncIterator[bytes]: - req_headers = await self._request_headers(headers) - try: - async with self._client.stream( - "GET", - path, - params=_clean_params(params), - headers=req_headers, - ) as res: - if res.status_code >= 400: - await res.aread() - raise error_from_response(res) - async for chunk in res.aiter_bytes(): - yield chunk - except httpx.HTTPError as exc: - raise NetworkError(str(exc)) from exc - - async def stream_sse_lines( - self, - path: str, - *, - params: Mapping[str, Any] | None = None, - headers: Mapping[str, str] | None = None, - ) -> AsyncIterator[str]: - self._check_open() - req_headers = await self._request_headers( - {"Accept": "text/event-stream", **(headers or {})} - ) - try: - async with self._client.stream( - "GET", path, params=_clean_params(params), headers=req_headers - ) as res: - if res.status_code >= 400: - await res.aread() - raise error_from_response(res) - async for line in res.aiter_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 +__all__ = [ + "DEFAULT_MAX_RETRIES", + "DEFAULT_RETRY_BASE", + "RawResponse", +] 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/_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/introspection_sdk/auth.py b/introspection_sdk/auth.py index 1b21f7a..295ad62 100644 --- a/introspection_sdk/auth.py +++ b/introspection_sdk/auth.py @@ -45,24 +45,46 @@ 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 datetime import datetime +from typing import TYPE_CHECKING import httpx2 as httpx -from pydantic import BaseModel, ConfigDict +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, - NetworkError, ValidationError, - error_from_response, +) +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: @@ -96,634 +118,6 @@ "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" -_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" - - -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, - 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 - - -async def _acp_post( - base_api_url: str, - path: str, - *, - data: dict[str, str] | None = None, - json: dict[str, str] | None = None, - transport: httpx.AsyncBaseTransport | None = None, -) -> httpx.Response: - try: - async with httpx.AsyncClient( - base_url=base_api_url, timeout=30.0, transport=transport - ) as client: - res = await 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()) - - -async def _apost_token_form( - base_api_url: str, - form: dict[str, str], - *, - transport: httpx.AsyncBaseTransport | None = None, -) -> OAuthToken: - res = await _acp_post( - base_api_url, _TOKEN_PATH, data=form, transport=transport - ) - return OAuthToken.model_validate(res.json()) - - -# --- client_credentials (service account) --------------------------- - - -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 - ) - - -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( - 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 - ) - - -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( - 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 - ) - - -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]: - # 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, - ) - - -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( - _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, - ) - - -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( - _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, - ) - - -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, - client_id: str, - *, - base_api_url: str | None = None, - transport: httpx.AsyncBaseTransport | None = None, -) -> None: - """Async twin of :func:`revoke_session`.""" - await _acp_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, - ) - - -# --- 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): - """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`.""" - ... - - -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.""" @@ -748,94 +142,9 @@ 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) - - +# 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. diff --git a/introspection_sdk/client.py b/introspection_sdk/client.py index 09154ed..dfb6198 100644 --- a/introspection_sdk/client.py +++ b/introspection_sdk/client.py @@ -16,356 +16,11 @@ from __future__ import annotations -__all__ = ["AsyncIntrospectionClient", "IntrospectionClient"] - -import os - -import httpx2 as httpx - -from introspection_sdk._http import _AsyncHttpClient, _HttpClient -from introspection_sdk.auth import ( - AsyncCredentialProvider, - CredentialProvider, - async_service_account_token, - service_account_token, +from introspection_sdk._async.client import ( + AsyncIntrospectionClient, ) -from introspection_sdk.dev_target import client_headers -from introspection_sdk.resources import ( - Annotations, - AsyncAnnotations, - AsyncAutomations, - AsyncConnectors, - AsyncExperiments, - AsyncMembers, - AsyncRecipes, - AsyncRepositories, - AsyncRuntimes, - AsyncTags, - Automations, - Connectors, - Experiments, - Members, - Recipes, - Repositories, - Runtimes, - Tags, +from introspection_sdk._sync.client import ( + IntrospectionClient, ) -from introspection_sdk.runner_resources import ( - AppConnections, - AsyncAppConnections, - AsyncConversations, - AsyncEvents, - AsyncFiles, - AsyncMetrics, - AsyncShares, - AsyncTasks, - 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 - - -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 - repositories: AsyncRepositories - connectors: AsyncConnectors - members: AsyncMembers - annotations: AsyncAnnotations - tags: AsyncTags - automations: AsyncAutomations - events: AsyncEvents - tasks: AsyncTasks - files: AsyncFiles - conversations: AsyncConversations - metrics: AsyncMetrics - shares: AsyncShares - connections: AsyncAppConnections - - 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: AsyncCredentialProvider | None = None, - transport: httpx.AsyncBaseTransport | 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 = _AsyncHttpClient( - 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 = _AsyncHttpClient( - api_url=dp_url or self._base_api_url, - token=self._token, - additional_headers=self._additional_headers, - transport=transport, - credentials=credentials, - ) - self.runtimes = AsyncRuntimes( - self._http, - additional_headers=self._additional_headers, - ) - self.experiments = AsyncExperiments( - self._http, - additional_headers=self._additional_headers, - ) - self.recipes = AsyncRecipes(self._http) - self.repositories = AsyncRepositories(self._http, self._dp_http) - self.connectors = AsyncConnectors(self._http) - self.members = AsyncMembers(self._http) - self.annotations = AsyncAnnotations(self._http, self._dp_http) - self.tags = AsyncTags(self._dp_http) - self.automations = AsyncAutomations(self._dp_http) - self.events = AsyncEvents(self._dp_http) - self.tasks = AsyncTasks(self._dp_http) - self.files = AsyncFiles(self._dp_http) - self.conversations = AsyncConversations(self._dp_http) - self.metrics = AsyncMetrics(self._dp_http) - self.shares = AsyncShares(self._dp_http) - self.connections = AsyncAppConnections(self._dp_http) - - @classmethod - async 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.AsyncBaseTransport | None = None, - ) -> AsyncIntrospectionClient: - """Async twin of - :meth:`IntrospectionClient.from_service_account`. - - Mints the ``client_credentials`` token without blocking the event - loop (see - :func:`~introspection_sdk.auth.async_service_account_token`) and - returns a ready :class:`AsyncIntrospectionClient`:: - - client = await AsyncIntrospectionClient.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 = await client.runtimes("customer-agent").run() - """ - token = await async_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, - ) - - async def shutdown(self) -> None: - """Graceful shutdown — closes the underlying HTTP client.""" - try: - await self._http.aclose() - await self._dp_http.aclose() - except Exception: # noqa: BLE001 — best-effort cleanup - pass - - async def __aenter__(self) -> AsyncIntrospectionClient: - return self - - async def __aexit__(self, *_exc_info: object) -> None: - await self.shutdown() +__all__ = ["AsyncIntrospectionClient", "IntrospectionClient"] diff --git a/introspection_sdk/protocols.py b/introspection_sdk/protocols.py index dffa1d6..9a20cf3 100644 --- a/introspection_sdk/protocols.py +++ b/introspection_sdk/protocols.py @@ -15,70 +15,34 @@ from __future__ import annotations -import builtins -from collections.abc import AsyncIterator, Iterator -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.runner_resources._reads import ( - ArrowPageIterator, - AsyncArrowPageIterator, - 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 AsyncRunHandle, 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._async.protocols import ( + AsyncAutomationsResource, + AsyncConnectionsResource, + AsyncConversationItemsResource, + AsyncConversationsResource, + AsyncDataPlaneResources, + AsyncEventsResource, + AsyncFilesResource, + AsyncFileVersionsResource, + AsyncMetricsResource, + AsyncSharesResource, + AsyncTaskRunsResource, + AsyncTasksResource, ) -from introspection_sdk.schemas.pagination import Paginated -from introspection_sdk.schemas.shares import ( - ResourceShare, - ShareResourceType, +from introspection_sdk._sync.protocols import ( + AutomationsResource, + ConnectionsResource, + ConversationItemsResource, + ConversationsResource, + DataPlaneResources, + EventsResource, + FilesResource, + FileVersionsResource, + MetricsResource, + SharesResource, + TaskRunsResource, + TasksResource, ) -from introspection_sdk.schemas.tasks import ( - Task, - TaskCancelRequest, - TaskCancelResponse, - TaskCreateResponse, - TaskFileRef, - TaskPrompt, - TaskRepoRequest, - TaskRun, - TaskRunKind, -) -from introspection_sdk.schemas.trajectory import Trajectory __all__ = [ "AsyncAutomationsResource", @@ -106,1119 +70,3 @@ "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): - """Protocol of :class:`~introspection_sdk.runner_resources.tasks.AsyncTaskRuns`.""" - - async 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, - ) -> AsyncRunHandle: ... - - async def resume( - self, task_id: str, *, resume: list[ResumeEntry | dict[str, Any]] - ) -> AsyncRunHandle: ... - - async def get(self, task_id: str, run_id: str) -> TaskRun: ... - - async 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, - ) -> AsyncIterator[AGUIEvent]: ... - - -@runtime_checkable -class AsyncTasksResource(Protocol): - """Protocol of :class:`~introspection_sdk.runner_resources.tasks.AsyncTasks`.""" - - @property - def runs(self) -> AsyncTaskRunsResource: ... - - 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, - ) -> AsyncPager[Task, Paginated[Task]]: ... - - async 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: ... - - async def get(self, task_id: str) -> Task: ... - - async 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: ... - - async def delete(self, task_id: str) -> None: ... - - async def archive(self, task_id: str) -> None: ... - - async def unarchive(self, task_id: str) -> None: ... - - async def start(self, *, prompt: str, **kwargs: Any) -> AsyncRunHandle: ... - - -@runtime_checkable -class AsyncFileVersionsResource(Protocol): - """Protocol of :class:`~introspection_sdk.runner_resources.files.AsyncFileVersions`.""" - - def list( - self, - file_id: str, - *, - limit: int = 100, - next: str | None = None, - include_total: bool = False, - ) -> AsyncPager[File, Paginated[File]]: ... - - async def get(self, file_id: str, version_id: str) -> File: ... - - async 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 AsyncFilesResource(Protocol): - """Protocol of :class:`~introspection_sdk.runner_resources.files.AsyncFiles`.""" - - @property - def versions(self) -> AsyncFileVersionsResource: ... - - 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, - ) -> AsyncPager[File, Paginated[File]]: ... - - async 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: ... - - async 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: ... - - async def get(self, file_id: str) -> File: ... - - async def update( - self, - file_id: str, - *, - name: str | None = None, - metadata: dict[str, Any] | None = None, - tags: builtins.list[str] | None = None, - ) -> File: ... - - async def delete(self, file_id: str) -> None: ... - - async def download(self, file_id: str) -> bytes: ... - - def download_stream(self, file_id: str) -> AsyncIterator[bytes]: ... - - -@runtime_checkable -class AsyncConversationItemsResource(Protocol): - """Protocol of :class:`~introspection_sdk.runner_resources.conversations.AsyncConversationItems`.""" - - 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]: ... - - async def get( - self, - conversation_id: str, - item_id: str, - *, - include: builtins.list[ConversationItemInclude] | None = None, - ) -> GenAiSpan: ... - - -@runtime_checkable -class AsyncConversationsResource(Protocol): - """Protocol of :class:`~introspection_sdk.runner_resources.conversations.AsyncConversations`.""" - - @property - def items(self) -> AsyncConversationItemsResource: ... - - 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", - ) -> AsyncPager[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, - ) -> AsyncArrowPageIterator: ... - - def iterate( - self, *, max_items: int | None = None, **kwargs: Any - ) -> AsyncIterator[Conversation]: ... - - async def get(self, conversation_id: str) -> Conversation: ... - - async def export_json( - self, conversation_id: str, **params: Unpack[ConversationExportParams] - ) -> GenAiSpanList: ... - - def export_stream( - self, - conversation_id: str, - format: ConversationExportFormat, - **params: Unpack[ConversationExportParams], - ) -> AsyncIterator[bytes]: ... - - async 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: ... - - async 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: ... - - async def retrieve( - self, conversation_id: str, item_id: str | None = None - ) -> GenAiSpan | None: ... - - -@runtime_checkable -class AsyncEventsResource(Protocol): - """Protocol of :class:`~introspection_sdk.runner_resources.events.AsyncEvents`.""" - - 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]]: ... - - async def get(self, event_id: str) -> Event | UnknownEvent: ... - - def iterate( - self, - event_name: str | IntrospectionEventName, - *, - max_items: int | None = None, - **kwargs: Any, - ) -> AsyncIterator[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, - ) -> AsyncArrowPageIterator: ... - - -@runtime_checkable -class AsyncMetricsResource(Protocol): - """Protocol of :class:`~introspection_sdk.runner_resources.metrics.AsyncMetrics`.""" - - async def query( - self, request: MetricQueryRequest | dict[str, Any] - ) -> MetricQueryResponse: ... - - -@runtime_checkable -class AsyncSharesResource(Protocol): - """Protocol of :class:`~introspection_sdk.runner_resources.shares.AsyncShares`.""" - - 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, - ) -> AsyncPager[ResourceShare, Paginated[ResourceShare]]: ... - - async 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: ... - - async def get(self, share_id: str) -> ResourceShare: ... - - async def update( - self, - share_id: str, - *, - visible_from: datetime | None, - ) -> ResourceShare: ... - - async def delete(self, share_id: str) -> None: ... - - -@runtime_checkable -class AsyncAutomationsResource(Protocol): - """Protocol of :class:`~introspection_sdk.runner_resources.automations.AsyncAutomations`.""" - - 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]]: ... - - async def get(self, automation_id: UUID | str) -> Automation: ... - - 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 | None = None, - task_id: UUID | None = None, - next_trigger_at: datetime | None = None, - metadata: MetadataInput | None = None, - enabled: bool | None = None, - ) -> Automation: ... - - 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 | None = None, - task_id: UUID | None = None, - next_trigger_at: datetime | None = None, - metadata: MetadataInput | None = None, - enabled: bool | None = None, - ) -> Automation: ... - - async def delete(self, automation_id: UUID | str) -> None: ... - - async 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: ... - - -@runtime_checkable -class AsyncConnectionsResource(Protocol): - """Protocol of :class:`~introspection_sdk.runner_resources.connections.AsyncAppConnections`.""" - - 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 def create( - self, *, app: str, runtime: str | UUID - ) -> ConnectPage: ... - - async def get(self, connection_id: UUID | str) -> Connection: ... - - async def delete(self, connection_id: UUID | str) -> None: ... - - -@runtime_checkable -class AsyncDataPlaneResources(Protocol): - """The data-plane namespaces :class:`~introspection_sdk.AsyncIntrospectionClient` - and :class:`~introspection_sdk.runner.AsyncRunner` both expose.""" - - @property - def tasks(self) -> AsyncTasksResource: ... - - @property - def files(self) -> AsyncFilesResource: ... - - @property - def conversations(self) -> AsyncConversationsResource: ... - - @property - def events(self) -> AsyncEventsResource: ... - - @property - def metrics(self) -> AsyncMetricsResource: ... - - @property - def shares(self) -> AsyncSharesResource: ... - - @property - def automations(self) -> AsyncAutomationsResource: ... - - @property - def connections(self) -> AsyncConnectionsResource: ... diff --git a/introspection_sdk/proxy/_transport.py b/introspection_sdk/proxy/_transport.py index 2b94b52..93f8c49 100644 --- a/introspection_sdk/proxy/_transport.py +++ b/introspection_sdk/proxy/_transport.py @@ -2,126 +2,22 @@ from __future__ import annotations -from collections.abc import Awaitable, 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) - - -async def handle_async_request( - http: Any, - config: ProxyConfig, - request: Any, - *, - direct: Any, - egress: Any, - forward: Any | None, - send: Callable[[Any, Any], Awaitable[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 await 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() - ) +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 index 93a7075..741b7cd 100644 --- a/introspection_sdk/proxy/httpx.py +++ b/introspection_sdk/proxy/httpx.py @@ -2,124 +2,11 @@ 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_async_request, - handle_request, - unique_transports, +from introspection_sdk._async.proxy.httpx import ( + AsyncIntrospectionTransport, +) +from introspection_sdk._sync.proxy.httpx import ( + IntrospectionTransport, ) - - -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.""" - - def __init__( - self, - config: ProxyConfig | None = None, - *, - verify: ssl.SSLContext | bool | str = True, - _direct: httpx.AsyncBaseTransport | None = None, - _egress: httpx.AsyncBaseTransport | None = None, - _forward: httpx.AsyncBaseTransport | None = None, - ) -> None: - self.config = config or ProxyConfig.from_env() - ssl_context = httpx.create_ssl_context(verify=verify) - self._direct = _direct or httpx.AsyncHTTPTransport(verify=ssl_context) - self._egress = _egress or httpx.AsyncHTTPTransport(verify=ssl_context) - self._forward = _forward - if self._forward is None and self.config.forward_proxy_url: - self._forward = httpx.AsyncHTTPTransport( - 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 - ) -> AsyncIntrospectionTransport: - return cls(ProxyConfig.from_env(), verify=verify) - - async def handle_async_request( - self, request: httpx.Request - ) -> httpx.Response: - return await handle_async_request( - httpx, - self.config, - request, - direct=self._direct, - egress=self._egress, - forward=self._forward, - send=lambda transport, value: transport.handle_async_request( - value - ), - ) - - async def aclose(self) -> None: - for transport in unique_transports( - self._direct, self._egress, self._forward - ): - await transport.aclose() - __all__ = ["AsyncIntrospectionTransport", "IntrospectionTransport"] diff --git a/introspection_sdk/proxy/httpx2.py b/introspection_sdk/proxy/httpx2.py index 00bb049..782ad2b 100644 --- a/introspection_sdk/proxy/httpx2.py +++ b/introspection_sdk/proxy/httpx2.py @@ -2,119 +2,11 @@ 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_async_request, - handle_request, - unique_transports, +from introspection_sdk._async.proxy.httpx2 import ( + AsyncIntrospectionTransport, +) +from introspection_sdk._sync.proxy.httpx2 import ( + IntrospectionTransport, ) - - -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.""" - - def __init__( - self, - config: ProxyConfig | None = None, - *, - verify: ssl.SSLContext | bool | str = True, - _direct: httpx.AsyncBaseTransport | None = None, - _egress: httpx.AsyncBaseTransport | None = None, - _forward: httpx.AsyncBaseTransport | None = None, - ) -> None: - self.config = config or ProxyConfig.from_env() - ssl_context = httpx.create_ssl_context(verify=verify) - self._direct = _direct or httpx.AsyncHTTPTransport(verify=ssl_context) - self._egress = _egress or httpx.AsyncHTTPTransport(verify=ssl_context) - self._forward = _forward - if self._forward is None and self.config.forward_proxy_url: - self._forward = httpx.AsyncHTTPTransport( - 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 - ) -> AsyncIntrospectionTransport: - return cls(ProxyConfig.from_env(), verify=verify) - - async def handle_async_request( - self, request: httpx.Request - ) -> httpx.Response: - return await handle_async_request( - httpx, - self.config, - request, - direct=self._direct, - egress=self._egress, - forward=self._forward, - send=lambda transport, value: transport.handle_async_request( - value - ), - ) - - async def aclose(self) -> None: - for transport in unique_transports( - self._direct, self._egress, self._forward - ): - await transport.aclose() - __all__ = ["AsyncIntrospectionTransport", "IntrospectionTransport"] 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/resources/connectors.py b/introspection_sdk/resources/connectors.py index e079995..c8012b7 100644 --- a/introspection_sdk/resources/connectors.py +++ b/introspection_sdk/resources/connectors.py @@ -14,934 +14,14 @@ from __future__ import annotations -import builtins -from typing import Any -from uuid import UUID - -from introspection_sdk._http import _AsyncHttpClient, _HttpClient -from introspection_sdk.pagination import ( - AsyncPager, - Pager, - async_cursor_paginate, - cursor_paginate, +from introspection_sdk._async.resources.connectors import ( + AsyncConnections, + AsyncConnectors, ) -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._sync.resources.connectors import ( + Connections, + Connectors, ) -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) - - -class AsyncConnections: - """Async twin of :class:`Connections` - (CP ``/v1/connectors/{connector_id}/connections``).""" - - def __init__(self, http: _AsyncHttpClient) -> None: - self._http = http - - def list( - self, - connector_id: UUID, - *, - limit: int | None = None, - next: str | None = None, - ) -> AsyncPager[Connection, Paginated[Connection]]: - """List a connector's connections. ``await`` the returned - :class:`AsyncPager` for the first page, or ``async for`` it to - stream every connection across pages.""" - - async def fetch(cursor: str | None) -> Paginated[Connection]: - params: dict[str, Any] = {"limit": limit, "next": cursor} - payload = await self._http.request( - "GET", - f"/v1/connectors/{connector_id}/connections", - params=params, - ) - return Paginated[Connection].model_validate(payload) - - return async_cursor_paginate(fetch, start=next) - - async 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 = await 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) - - async 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 = await 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) - - async def get(self, connector_id: UUID, connection_id: UUID) -> Connection: - payload = await self._http.request( - "GET", - f"/v1/connectors/{connector_id}/connections/{connection_id}", - ) - return Connection.model_validate(payload) - - async def revoke(self, connector_id: UUID, connection_id: UUID) -> None: - """Revoke a connection — destroys the stored provider token.""" - await self._http.request( - "DELETE", - f"/v1/connectors/{connector_id}/connections/{connection_id}", - expect="empty", - ) - - -class AsyncConnectors: - """Async twin of :class:`Connectors` (CP ``/v1/connectors``).""" - - def __init__(self, http: _AsyncHttpClient) -> None: - self._http = http - self.connections = AsyncConnections(http) - - def list( - self, - *, - limit: int | None = None, - next: str | None = None, - ) -> AsyncPager[Connector, Paginated[Connector]]: - """List connectors. ``await`` the returned :class:`AsyncPager` - for the first page, or ``async for`` it to stream every - connector across pages.""" - - async def fetch(cursor: str | None) -> Paginated[Connector]: - params: dict[str, Any] = { - "limit": limit, - "next": cursor, - } - payload = await self._http.request( - "GET", "/v1/connectors", params=params - ) - return Paginated[Connector].model_validate(payload) - - return async_cursor_paginate(fetch, start=next) - - async 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 = await 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) - - async 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 = await 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"]] - - async 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 = await self._http.request( - "GET", - "/v1/connectors/custom/apps", - params={"q": query, "limit": limit}, - ) - return [ConnectorApp.model_validate(app) for app in payload["data"]] - - async 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 = await self._http.request( - "POST", - "/v1/connectors/discover-oauth", - json=ConnectorOAuthDiscoveryRequest(issuer=issuer).model_dump( - mode="json" - ), - ) - return ConnectorOAuthDiscoveryResponse.model_validate(payload) - - async def get(self, connector_id: UUID) -> Connector: - payload = await self._http.request( - "GET", - f"/v1/connectors/{connector_id}", - ) - return Connector.model_validate(payload) - - async 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 = await 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) - - async def delete(self, connector_id: UUID) -> None: - """Soft-delete a connector; its connections are revoked.""" - await self._http.request( - "DELETE", - f"/v1/connectors/{connector_id}", - expect="empty", - ) - - async 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 = await 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) - __all__ = [ "AsyncConnections", diff --git a/introspection_sdk/resources/experiments.py b/introspection_sdk/resources/experiments.py index 9fbe93d..0fbe06a 100644 --- a/introspection_sdk/resources/experiments.py +++ b/introspection_sdk/resources/experiments.py @@ -11,481 +11,15 @@ 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 _AsyncHttpClient, _HttpClient -from introspection_sdk.pagination import ( - AsyncPager, - Pager, - async_cursor_paginate, - cursor_paginate, +from introspection_sdk._async.resources.experiments import ( + AsyncExperimentHandle, + AsyncExperiments, ) -from introspection_sdk.runner import AsyncRunner, 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, +from introspection_sdk._sync.resources.experiments import ( + ExperimentHandle, + Experiments, ) - -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) - - -class AsyncExperiments: - """Async twin of :class:`Experiments` (CP ``/v1/experiments``). - - Also callable: ``client.experiments(id)`` returns an - :class:`AsyncExperimentHandle`. - """ - - def __init__( - self, - http: _AsyncHttpClient, - *, - additional_headers: Mapping[str, str] | None = None, - ) -> None: - self._http = http - self._additional_headers = additional_headers - - def __call__(self, experiment_id: UUID) -> AsyncExperimentHandle: - return AsyncExperimentHandle(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, - ) -> AsyncPager[Experiment, Paginated[Experiment]]: - """List experiments. ``await`` the returned :class:`AsyncPager` for - the first page, or ``async for`` it to stream every experiment across - pages.""" - - async 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 = await self._http.request( - "GET", "/v1/experiments", params=params - ) - return Paginated[Experiment].model_validate(payload) - - return async_cursor_paginate(fetch, start=next) - - async def get( - self, experiment_id: UUID, *, project: str | UUID | None = None - ) -> Experiment: - params: dict[str, Any] = {} - if project: - params["project"] = str(project) - payload = await self._http.request( - "GET", - f"/v1/experiments/{experiment_id}", - params=params or None, - ) - return Experiment.model_validate(payload) - - async 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( - await self._http.request("POST", "/v1/experiments", json=body) - ) - - async def update( - self, - experiment_id: UUID, - document: Mapping[str, Any], - *, - project: str | UUID | None = None, - ) -> Experiment: - return Experiment.model_validate( - await self._http.request( - "PATCH", - f"/v1/experiments/{experiment_id}", - params={ - "project": str(project) if project is not None else None - }, - json=dict(document), - ) - ) - - async def delete( - self, - experiment_id: UUID, - *, - project: str | UUID | None = None, - ) -> None: - await self._http.request( - "DELETE", - f"/v1/experiments/{experiment_id}", - params={"project": str(project) if project is not None else None}, - expect="empty", - ) - - async def _post_run( - self, - experiment_id: UUID, - options: RunRequest, - ) -> RunnerSpec: - body: dict[str, Any] = options.model_dump( - exclude_none=True, mode="json" - ) - payload = await self._http.request( - "POST", f"/v1/experiments/{experiment_id}/run", json=body - ) - return RunnerSpec.model_validate(payload) - - async def _start(self, experiment_id: UUID) -> Experiment: - payload = await self._http.request( - "POST", f"/v1/experiments/{experiment_id}/start" - ) - return Experiment.model_validate(payload) - - async def _end(self, experiment_id: UUID) -> Experiment: - payload = await self._http.request( - "POST", - f"/v1/experiments/{experiment_id}/end", - ) - return Experiment.model_validate(payload) - - async def _cancel(self, experiment_id: UUID) -> Experiment: - payload = await self._http.request( - "POST", f"/v1/experiments/{experiment_id}/cancel" - ) - return Experiment.model_validate(payload) - - -class AsyncExperimentHandle: - """Async twin of :class:`ExperimentHandle`. - - Built by ``client.experiments(id)``. - """ - - def __init__( - self, - experiments: AsyncExperiments, - *, - experiment_id: UUID, - ) -> None: - self._experiments = experiments - self._experiment_id = experiment_id - - @property - def experiment_id(self) -> UUID: - return self._experiment_id - - 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, - ) - eid = self._experiment_id - - async def refresher() -> RunnerSpec: - return await self._experiments._post_run(eid, options) - - spec = await refresher() - return AsyncRunner( - spec, - refresher=refresher, - additional_headers=self._experiments._additional_headers, - ) - - async def start(self) -> Experiment: - return await self._experiments._start(self._experiment_id) - - async def end(self) -> Experiment: - return await self._experiments._end(self._experiment_id) - - async def cancel(self) -> Experiment: - return await self._experiments._cancel(self._experiment_id) - - __all__ = [ "AsyncExperimentHandle", "AsyncExperiments", diff --git a/introspection_sdk/resources/members.py b/introspection_sdk/resources/members.py index 47b0c03..06a6ee5 100644 --- a/introspection_sdk/resources/members.py +++ b/introspection_sdk/resources/members.py @@ -15,287 +15,14 @@ from __future__ import annotations -import builtins -from typing import Any -from uuid import UUID - -from pydantic import BaseModel - -from introspection_sdk._http import _AsyncHttpClient, _HttpClient -from introspection_sdk.pagination import ( - AsyncPager, - Pager, - async_cursor_paginate, - cursor_paginate, +from introspection_sdk._async.resources.members import ( + AsyncMembers, ) -from introspection_sdk.schemas.members import ( - MEMBER_CREATE_ADAPTER, - BusinessMemberCreate, - CustomerMemberCreate, - Member, - MemberType, - MemberUpdateRequest, +from introspection_sdk._sync.resources.members import ( + Members, ) -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) - - -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}", - json=_update_body( - name=name, - image_url=image_url, - role=role, - tags=tags, - metadata=metadata, - policy_attributes=policy_attributes, - ), - ) - return Member.model_validate(payload) +__all__ = [ + "AsyncMembers", + "Members", +] diff --git a/introspection_sdk/resources/recipes.py b/introspection_sdk/resources/recipes.py index 8a69574..531a430 100644 --- a/introspection_sdk/resources/recipes.py +++ b/introspection_sdk/resources/recipes.py @@ -8,98 +8,14 @@ from __future__ import annotations -from typing import Any -from uuid import UUID - -from introspection_sdk._http import _AsyncHttpClient, _HttpClient -from introspection_sdk.pagination import ( - AsyncPager, - Pager, - async_cursor_paginate, - cursor_paginate, +from introspection_sdk._async.resources.recipes import ( + AsyncRecipes, +) +from introspection_sdk._sync.resources.recipes import ( + Recipes, ) -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``).""" - - def __init__(self, http: _AsyncHttpClient) -> 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, - ) -> AsyncPager[Recipe, Paginated[Recipe]]: - """List recipes. ``await`` the returned :class:`AsyncPager` for the - first page, or ``async for`` it to stream every recipe across - pages.""" - - async 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 = await self._http.request( - "GET", "/v1/recipes", params=params - ) - return Paginated[Recipe].model_validate(payload) - - return async_cursor_paginate(fetch, start=next) - async def get(self, recipe_id: UUID) -> Recipe: - payload = await self._http.request("GET", f"/v1/recipes/{recipe_id}") - return Recipe.model_validate(payload) +__all__ = [ + "AsyncRecipes", + "Recipes", +] diff --git a/introspection_sdk/resources/repositories.py b/introspection_sdk/resources/repositories.py index 41656db..7d51ff2 100644 --- a/introspection_sdk/resources/repositories.py +++ b/introspection_sdk/resources/repositories.py @@ -15,390 +15,17 @@ 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, _AsyncHttpClient, _HttpClient -from introspection_sdk.pagination import ( - AsyncPager, - Pager, - async_cursor_paginate, - cursor_paginate, +from introspection_sdk._async.resources.repositories import ( + AsyncRepositories, + AsyncRepositoryContents, + AsyncRepositoryMerges, ) -from introspection_sdk.schemas.pagination import Paginated -from introspection_sdk.schemas.repositories import ( - Repository, - RepositoryCommit, - RepositoryCommitDetail, - RepositoryContent, - RepositoryDirectory, - RepositoryEntry, - RepositoryFile, - RepositoryMerge, +from introspection_sdk._sync.resources.repositories import ( + Repositories, + RepositoryContents, + RepositoryMerges, ) -_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) - - -class AsyncRepositoryContents: - """Async twin of :class:`RepositoryContents`.""" - - def __init__(self, http: _AsyncHttpClient) -> None: - self._http = http - - def __call__( - self, - repository_id: UUID | str, - path: str = "", - ref: str | None = None, - limit: int | None = None, - ) -> AsyncPager[RepositoryEntry, RepositoryDirectory]: - """List a directory's entries across pages. - - Iterating raises :class:`ValueError` when ``path`` is a file. - """ - - async def fetch(cursor: str | None) -> RepositoryDirectory: - payload = await self._http.request( - "GET", - _contents_path(repository_id, path), - params={"ref": ref, "cursor": cursor, "limit": limit}, - ) - return _directory(payload, path) - - return AsyncPager(fetch, items=_pager_items, next_cursor=_pager_next) - - async 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 = await self._http.request( - "GET", - _contents_path(repository_id, path), - params={"ref": ref}, - ) - return _CONTENT.validate_python(payload) - - -class AsyncRepositoryMerges: - """Async twin of :class:`RepositoryMerges`.""" - - def __init__(self, http: _AsyncHttpClient) -> None: - self._http = http - - async def create( - self, - repository_id: UUID | str, - base: str, - head: str, - commit_message: str | None = None, - ) -> RepositoryMerge | None: - """Async twin of :meth:`RepositoryMerges.create`.""" - response = await self._http.request( - "POST", - _merges_path(repository_id), - json=_merge_body(base, head, commit_message), - expect="raw", - ) - return _merge(response) - - -class AsyncRepositories: - """Async twin of :class:`Repositories`.""" - - contents: AsyncRepositoryContents - merges: AsyncRepositoryMerges - - def __init__( - self, http: _AsyncHttpClient, dp_http: _AsyncHttpClient - ) -> None: - self._http = http - self._dp_http = dp_http - self.contents = AsyncRepositoryContents(dp_http) - self.merges = AsyncRepositoryMerges(dp_http) - - async def list( - self, - *, - project: str | UUID | None = None, - slug: str | None = None, - ) -> builtins.list[Repository]: - """Async twin of :meth:`Repositories.list`.""" - payload = await self._http.request( - "GET", - "/v1/repositories", - params={"project": project, "slug": slug}, - ) - return _REPOSITORY_LIST.validate_python(payload) - - async def get( - self, - repository_id: UUID | str, - *, - project: str | UUID | None = None, - ) -> Repository: - payload = await 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, - ) -> AsyncPager[RepositoryCommit, Paginated[RepositoryCommit]]: - """Async twin of :meth:`Repositories.commits`.""" - - async def fetch(cursor: str | None) -> Paginated[RepositoryCommit]: - payload = await 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 async_cursor_paginate(fetch) - - async def commit( - self, repository_id: UUID | str, sha: str - ) -> RepositoryCommitDetail: - """Async twin of :meth:`Repositories.commit`.""" - payload = await self._dp_http.request( - "GET", _commit_path(repository_id, sha) - ) - return RepositoryCommitDetail.model_validate(payload) - - __all__ = [ "AsyncRepositories", "AsyncRepositoryContents", diff --git a/introspection_sdk/resources/runtimes.py b/introspection_sdk/resources/runtimes.py index fee6415..a9dbaf0 100644 --- a/introspection_sdk/resources/runtimes.py +++ b/introspection_sdk/resources/runtimes.py @@ -12,479 +12,16 @@ 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 _AsyncHttpClient, _HttpClient -from introspection_sdk.pagination import ( - AsyncPager, - Pager, - async_cursor_paginate, - cursor_paginate, +from introspection_sdk._async.resources.runtimes import ( + AsyncRuntimes, ) -from introspection_sdk.runner import AsyncRunner, Runner -from introspection_sdk.schemas.pagination import Paginated -from introspection_sdk.schemas.runner import ( - RunCaller, - RunnerIdentity, - RunnerSpec, - RunRequest, +from introspection_sdk._sync.resources.runtimes import ( + Runtimes, +) +from introspection_sdk.resources._runtimes_common import ( + AsyncRuntimeHandle, + RuntimeHandle, ) -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: - """Async twin of :class:`Runtimes` (CP ``/v1/runtimes``). - - Also callable: ``client.runtimes("runtime")`` returns an - :class:`AsyncRuntimeHandle` for that runtime. - """ - - def __init__( - self, - http: _AsyncHttpClient, - *, - 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 - ) -> AsyncRuntimeHandle: - # 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 AsyncRuntimeHandle( - self, - runtime=runtime, - project=project, - ) - - def handle(self, runtime_id: UUID) -> AsyncRuntimeHandle: - """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 AsyncRuntimeHandle( - 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, - ) -> AsyncPager[Runtime, Paginated[Runtime]]: - """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.""" - - async 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 = await self._http.request( - "GET", "/v1/runtimes", params=params - ) - return Paginated[Runtime].model_validate(payload) - - return async_cursor_paginate(fetch, start=next) - - async def get( - self, runtime_id: UUID, *, project: str | None = None - ) -> Runtime: - payload = await self._http.request( - "GET", - f"/v1/runtimes/{runtime_id}", - params={"project": project}, - ) - return Runtime.model_validate(payload) - - async def resolve( - self, runtime: str | UUID, *, project: str | None = None - ) -> Runtime: - """Async twin of :meth:`Runtimes.resolve`. - - Resolve a runtime by slug or runtime group id on the caller's project — 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. Resolves to the newest runtime for the selector; - raises :class:`~introspection_sdk.NotFoundError` if nothing matches. - """ - page = await self.list( - runtime=str(runtime), - limit=1, - project=project, - ).page() - if not page.records: - raise _no_runtime(runtime) - return page.records[0] - - # --- /run -------------------------------------------------------- - - async 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 = await self._http.request( - "POST", - _run_path(runtime), - params={"project": project}, - json=body, - ) - 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", @@ -492,34 +29,3 @@ async def refresher() -> RunnerSpec: "RuntimeHandle", "Runtimes", ] - - -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 _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/resources/tags.py b/introspection_sdk/resources/tags.py index 64dc47e..fc4d151 100644 --- a/introspection_sdk/resources/tags.py +++ b/introspection_sdk/resources/tags.py @@ -2,161 +2,11 @@ from __future__ import annotations -from urllib.parse import quote - -from pydantic import TypeAdapter - -from introspection_sdk._errors import ValidationError -from introspection_sdk._http import _AsyncHttpClient, _HttpClient -from introspection_sdk.pagination import ( - AsyncPager, - Pager, - async_cursor_paginate, - cursor_paginate, +from introspection_sdk._async.resources.tags import ( + AsyncTags, ) -from introspection_sdk.schemas.pagination import Paginated -from introspection_sdk.schemas.tags import ( - Tag, - TagCreate, - TagKind, - TagUpdate, +from introspection_sdk._sync.resources.tags import ( + Tags, ) -_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") - - -class AsyncTags: - def __init__(self, http: _AsyncHttpClient) -> None: - self._http = http - - def list( - self, - kind: TagKind, - *, - search: str | None = None, - limit: int | None = None, - next: str | None = None, - ) -> AsyncPager[Tag, Paginated[Tag]]: - """List one catalogue. `kind` is required: no listing spans both.""" - - async def fetch(cursor: str | None) -> Paginated[Tag]: - payload = await self._http.request( - "GET", - "/v1/tags", - params={ - "kind": kind.value, - "search": search, - "limit": limit, - "next": cursor, - }, - ) - return Paginated[Tag].model_validate(payload) - - return async_cursor_paginate(fetch, start=next) - - async 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( - await self._http.request( - "POST", "/v1/tags", json=_create_body(tag) - ) - ) - - async def get(self, kind: TagKind, name: str) -> Tag: - return Tag.model_validate( - await self._http.request("GET", _path(kind, name)) - ) - - async def update(self, kind: TagKind, name: str, patch: TagUpdate) -> Tag: - return Tag.model_validate( - await self._http.request( - "PATCH", _path(kind, name), json=_update_body(kind, patch) - ) - ) - - 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/runner.py b/introspection_sdk/runner.py index d2cee65..5e2390b 100644 --- a/introspection_sdk/runner.py +++ b/introspection_sdk/runner.py @@ -18,393 +18,11 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable, Mapping -from datetime import datetime - -from introspection_sdk._errors import RunnerExpiredError -from introspection_sdk._http import _AsyncHttpClient, _HttpClient -from introspection_sdk.runner_resources import ( - AsyncAutomations, - AsyncConversations, - AsyncEvents, - AsyncFiles, - AsyncMetrics, - AsyncRunnerAppConnections, - AsyncShares, - AsyncTasks, - Automations, - Conversations, - Events, - Files, - Metrics, - RunnerAppConnections, - Shares, - Tasks, +from introspection_sdk._async.runner import ( + AsyncRunner, ) -from introspection_sdk.schemas.runner import ( - RunnerContext, - RunnerDeployment, - RunnerSpec, +from introspection_sdk._sync.runner import ( + Runner, ) - -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: - """Async twin of :class:`Runner`. - - Constructed indirectly by ``client.runtimes(...).run()`` / - ``client.experiments(...).run()`` on an ``AsyncIntrospectionClient``. - Holds a private ``httpx.AsyncClient``-backed HTTP client pointed at - ``spec.deployment.endpoint`` with the bearer JWT picked from - ``spec.session_token``. Awaitable lifecycle: ``await runner.close()``, - ``await runner.refresh()``, and ``async with`` support. - """ - - def __init__( - self, - spec: RunnerSpec, - *, - refresher: Callable[[], Awaitable[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 = AsyncTasks(self._http) - self._files = AsyncFiles(self._http) - self._conversations = AsyncConversations(self._http) - self._events = AsyncEvents(self._http) - self._metrics = AsyncMetrics(self._http) - self._shares = AsyncShares(self._http) - self._automations = AsyncAutomations(self._http) - self._connections = AsyncRunnerAppConnections( - self._http, spec.runtime_context.runtime_group_id - ) - - def _build_http(self, spec: RunnerSpec) -> _AsyncHttpClient: - return _AsyncHttpClient( - 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) -> AsyncTasks: - """DP ``/v1/tasks`` namespace bound to this Runner.""" - self._check_open() - return self._tasks - - @property - def files(self) -> AsyncFiles: - """DP ``/v1/files`` namespace bound to this Runner.""" - self._check_open() - return self._files - - @property - def conversations(self) -> AsyncConversations: - """Read-only DP ``/v1/conversations`` namespace bound to this - Runner.""" - self._check_open() - return self._conversations - - @property - def events(self) -> AsyncEvents: - """Read-only DP ``/v1/events`` namespace bound to this Runner.""" - self._check_open() - return self._events - - @property - def metrics(self) -> AsyncMetrics: - """Read-only DP ``/v1/metrics`` namespace bound to this Runner.""" - self._check_open() - return self._metrics - - @property - def shares(self) -> AsyncShares: - """DP ``/v1/shares`` read-sharing namespace bound to this Runner.""" - self._check_open() - return self._shares - - @property - def automations(self) -> AsyncAutomations: - """DP ``/v1/automations`` namespace bound to this Runner.""" - self._check_open() - return self._automations - - @property - def connections(self) -> AsyncRunnerAppConnections: - """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 - - async 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 = await self._refresher() - old_http = self._http - self._spec = new_spec - self._http = self._build_http(new_spec) - self._tasks = AsyncTasks(self._http) - self._files = AsyncFiles(self._http) - self._conversations = AsyncConversations(self._http) - self._events = AsyncEvents(self._http) - self._metrics = AsyncMetrics(self._http) - self._shares = AsyncShares(self._http) - self._automations = AsyncAutomations(self._http) - self._connections = AsyncRunnerAppConnections( - self._http, new_spec.runtime_context.runtime_group_id - ) - try: - await old_http.aclose() - except Exception: # noqa: BLE001 — best-effort cleanup - pass - - async 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: - await self._http.aclose() - except Exception: # noqa: BLE001 — best-effort cleanup - pass - - async def __aenter__(self) -> AsyncRunner: - return self - - async def __aexit__(self, *_exc_info: object) -> None: - await self.close() - - __all__ = ["AsyncRunner", "Runner"] 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() diff --git a/introspection_sdk/runner_resources/_reads.py b/introspection_sdk/runner_resources/_reads.py index eceb185..675f12f 100644 --- a/introspection_sdk/runner_resources/_reads.py +++ b/introspection_sdk/runner_resources/_reads.py @@ -26,231 +26,42 @@ from __future__ import annotations -import re -from collections.abc import AsyncIterator, Awaitable, 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 = 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]], - *, - start: str | None = None, - ) -> None: - self._fetch = fetch - self._start = start - - async def __aiter__(self) -> AsyncIterator[pa.Table]: - cursor = self._start - while True: - raw = await 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 - - async def read_all(self) -> pa.Table: - """Fetch every page and concatenate into one ``pyarrow.Table``.""" - pa = import_pyarrow() - tables = [table async for table in self] - if not tables: - return pa.table({}) - return pa.concat_tables(tables) +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 index 797a34d..e59b757 100644 --- a/introspection_sdk/runner_resources/automations.py +++ b/introspection_sdk/runner_resources/automations.py @@ -21,385 +21,16 @@ 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 _AsyncHttpClient, _HttpClient -from introspection_sdk.pagination import ( - AsyncPager, - Pager, - async_cursor_paginate, - cursor_paginate, +from introspection_sdk._async.runner_resources.automations import ( + AsyncAutomations, + MetadataInput, ) -from introspection_sdk.schemas.automations import ( - Automation, - AutomationCreateRequest, - AutomationKind, - AutomationMetadata, - AutomationTriggerResponse, - AutomationTriggerType, - AutomationUpdateRequest, +from introspection_sdk._sync.runner_resources.automations import ( + Automations, ) -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) - - -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" - ) - async def trigger( - self, automation_id: UUID | str - ) -> AutomationTriggerResponse: - """Async twin of :meth:`Automations.trigger`.""" - payload = await self._http.request( - "POST", f"{_path(automation_id)}/trigger" - ) - return AutomationTriggerResponse.model_validate(payload) +__all__ = [ + "AsyncAutomations", + "Automations", + "MetadataInput", +] diff --git a/introspection_sdk/runner_resources/connections.py b/introspection_sdk/runner_resources/connections.py index 48fd7e0..bf68a01 100644 --- a/introspection_sdk/runner_resources/connections.py +++ b/introspection_sdk/runner_resources/connections.py @@ -14,206 +14,20 @@ from __future__ import annotations -from typing import Any -from urllib.parse import quote -from uuid import UUID - -from introspection_sdk._http import _AsyncHttpClient, _HttpClient -from introspection_sdk.pagination import ( - AsyncPager, - Pager, - async_cursor_paginate, - cursor_paginate, +from introspection_sdk._async.runner_resources.connections import ( + CONNECTIONS_PATH, + AsyncAppConnections, + AsyncRunnerAppConnections, ) -from introspection_sdk.schemas.connections import ( - Connection, - ConnectionCreateRequest, - ConnectPage, +from introspection_sdk._sync.runner_resources.connections import ( + AppConnections, + RunnerAppConnections, ) -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) - ) - - -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`.""" - 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`.""" - await self._http.request( - "DELETE", _path(connection_id), expect="empty" - ) - - -class AsyncRunnerAppConnections(AsyncAppConnections): - """Async twin of :class:`RunnerAppConnections`.""" - - def __init__( - self, http: _AsyncHttpClient, runtime_group_id: UUID | None - ) -> None: - super().__init__(http) - self._runtime_group_id = runtime_group_id - async def create( - self, *, app: str, runtime: str | UUID | None = None - ) -> ConnectPage: - """Async twin of :meth:`RunnerAppConnections.create`.""" - return await super().create( - app=app, runtime=_runner_runtime(runtime, self._runtime_group_id) - ) +__all__ = [ + "AppConnections", + "AsyncAppConnections", + "AsyncRunnerAppConnections", + "CONNECTIONS_PATH", + "RunnerAppConnections", +] diff --git a/introspection_sdk/runner_resources/conversations.py b/introspection_sdk/runner_resources/conversations.py index 51c1f14..d3d7ef9 100644 --- a/introspection_sdk/runner_resources/conversations.py +++ b/introspection_sdk/runner_resources/conversations.py @@ -14,1143 +14,38 @@ from __future__ import annotations -import builtins -from collections.abc import AsyncIterator, Iterator -from datetime import datetime -from typing import Any, Literal, TypedDict, 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.pagination import ( - AsyncPager, - Pager, - async_cursor_paginate, - cursor_paginate, +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.runner_resources._reads import ( - ARROW_ACCEPT_HEADERS, - TRAJECTORY_ACCEPT_HEADERS, - ArrowPageIterator, - AsyncArrowPageIterator, - ReadFormat, - decode_arrow_page, - decode_arrow_table, - resolve_window, +from introspection_sdk._sync.runner_resources.conversations import ( + ConversationItems, + Conversations, ) -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] +from introspection_sdk.runner_resources._conversations_common import ( + ConversationExportParams, ) -ConversationExportFormat = Literal["json", "trajectory", "arrow"] -"""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"} - - -#: 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" +__all__ = [ + "AsyncConversationItems", + "AsyncConversations", + "ConversationExportFormat", + "ConversationExportParams", + "ConversationItems", + "ConversationResolution", + "ConversationSentiment", + "Conversations", + "JSON_EXPORT_HEADERS", + "LATEST_TURN_SCAN_LIMIT", + "TRAJECTORY_ADAPTER", + "build_conversation_params", + "build_export_params", ] -"""``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, - } - - -# 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``. - - 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 - - -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. - - 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. - """ - - async 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 = await 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 AsyncPager( - fetch, - items=lambda page: page.data, - next_cursor=_conversation_items_next, - start=next, - ) - - async 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 = await self._http.request( - "GET", - f"/v1/conversations/{conversation_id}/items/{item_id}", - params=params, - ) - return GenAiSpan.model_validate(_normalize_item_payload(payload)) - - -class AsyncConversations: - """Async twin of :class:`Conversations`. Read-only - (``/v1/conversations``). - - Both :meth:`list` and :meth:`items.list ` - return an auto-paging :class:`~introspection_sdk.pagination.AsyncPager` - and pass each page's opaque ``next`` token back unchanged. - """ - - def __init__(self, http: _AsyncHttpClient) -> None: - self._http = http - self.items = AsyncConversationItems(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", - ) -> AsyncPager[Conversation, Paginated[Conversation]]: - """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.""" - resolved_start, resolved_end = resolve_window( - start=start, - end=end, - lookback=lookback, - start_date=start_date, - end_date=end_date, - ) - - async 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 = await 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 = await self._http.request( - "GET", "/v1/conversations", params=params - ) - return Paginated[Conversation].model_validate(payload) - - return async_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, - ) -> AsyncArrowPageIterator: - """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.""" - resolved_start, resolved_end = resolve_window( - start=start, - end=end, - lookback=lookback, - start_date=start_date, - end_date=end_date, - ) - - async 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 = await self._http.request( - "GET", - "/v1/conversations", - params=params, - headers=ARROW_ACCEPT_HEADERS, - expect="raw", - ) - assert isinstance(raw, RawResponse) - return raw - - return AsyncArrowPageIterator(fetch, start=next) - - async def iterate( - self, - *, - max_items: int | None = None, - **kwargs: Any, - ) -> AsyncIterator[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 - async for record in self.list(**kwargs): - yield record - yielded += 1 - if max_items is not None and yielded >= max_items: - return - - async def get(self, conversation_id: str) -> Conversation: - """Fetch one conversation summary with its complete agent index.""" - payload = await self._http.request( - "GET", f"/v1/conversations/{conversation_id}" - ) - return Conversation.model_validate(payload) - - async def export_json( - self, - conversation_id: str, - **params: Unpack[ConversationExportParams], - ) -> GenAiSpanList: - """Async twin of :meth:`Conversations.export_json`.""" - payload = await 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], - ) -> AsyncIterator[bytes]: - """Async byte-stream twin of :meth:`Conversations.export_stream`.""" - return self._http.stream_bytes( - _export_path(conversation_id), - params=build_export_params(**params), - headers=_export_headers(format), - ) - - async 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: - """Async twin of :meth:`Conversations.export_trajectory`.""" - payload = await 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) - - async 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: - """Async twin of :meth:`Conversations.export_arrow`.""" - raw = await 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) - - async 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 await self._find_latest_turn_id(conversation_id) - if target_id is None: - return None - return await self.items.get(conversation_id, target_id) - - async 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`, exactly as the sync twin. - """ - fallback: GenAiSpan | None = None - scanned = 0 - # The route is descending-only, so the first match is the latest. - async 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 - scanned += 1 - if scanned >= LATEST_TURN_SCAN_LIMIT: - break - return fallback.span_id if fallback else None diff --git a/introspection_sdk/runner_resources/events.py b/introspection_sdk/runner_resources/events.py index 7506def..019877a 100644 --- a/introspection_sdk/runner_resources/events.py +++ b/introspection_sdk/runner_resources/events.py @@ -28,631 +28,39 @@ from __future__ import annotations -import builtins -import json import logging -from collections.abc import AsyncIterator, Iterator -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.pagination import ( - AsyncPager, - Pager, - async_cursor_paginate, - cursor_paginate, +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.runner_resources._reads import ( - ARROW_ACCEPT_HEADERS, - ArrowPageIterator, - AsyncArrowPageIterator, - ReadFormat, - decode_arrow_page, - resolve_window, +from introspection_sdk._sync.runner_resources.events import ( + Events, ) -from introspection_sdk.schemas.events import ( - KNOWN_EVENT_NAMES, - Event, - EventSortField, - IntrospectionEventName, - UnknownEvent, +from introspection_sdk.runner_resources._events_common import ( + UNKNOWN_EVENT_SKIPS, + UnknownEventCounter, ) -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"}) - - -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) - - -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, - 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 = await 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 = await self._http.request( - "GET", "/v1/events", params=params - ) - return validate_event_page(payload) - - return async_cursor_paginate(fetch, start=next) - - async def get(self, event_id: str) -> Event | UnknownEvent: - """Async twin of :meth:`Events.get`.""" - payload = await self._http.request("GET", f"/v1/events/{event_id}") - return validate_single_event(payload) - - async def iterate( - self, - event_name: str | IntrospectionEventName, - *, - max_items: int | None = None, - **kwargs: Any, - ) -> AsyncIterator[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 - async 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, - ) -> AsyncArrowPageIterator: - """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.""" - 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) -> 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 = await self._http.request( - "GET", - "/v1/events", - params=params, - headers=ARROW_ACCEPT_HEADERS, - expect="raw", - ) - assert isinstance(raw, RawResponse) - return raw - - return AsyncArrowPageIterator(fetch, start=next) +__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 index cd05d12..3fd867c 100644 --- a/introspection_sdk/runner_resources/files.py +++ b/introspection_sdk/runner_resources/files.py @@ -6,518 +6,23 @@ from __future__ import annotations -import builtins -import contextlib -import json -import mimetypes -from collections.abc import AsyncIterator, Iterator -from pathlib import Path -from typing import IO, Any - -from introspection_sdk._http import _AsyncHttpClient, _HttpClient -from introspection_sdk.pagination import ( - AsyncPager, - Pager, - async_cursor_paginate, - cursor_paginate, +from introspection_sdk._async.runner_resources.files import ( + AsyncFiles, + AsyncFileVersions, + FileLike, ) -from introspection_sdk.schemas.files import ( - File, - FileCreateTextRequest, - FileType, - FileUpdateRequest, +from introspection_sdk._async.runner_resources.files import ( + _materialise_upload as _materialise_upload, +) +from introspection_sdk._sync.runner_resources.files import ( + Files, + FileVersions, ) -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") - - -class AsyncFileVersions: - def __init__(self, http: _AsyncHttpClient) -> None: - self._http = http - - def list( - self, - file_id: str, - *, - limit: int = 100, - next: str | None = None, - include_total: bool = False, - ) -> AsyncPager[File, Paginated[File]]: - """List versions of a file. ``await`` the returned - :class:`AsyncPager` for the first page, or ``async for`` it to - stream every version across pages.""" - - async def fetch(cursor: str | None) -> Paginated[File]: - params: dict[str, Any] = { - "limit": limit, - "next": cursor, - "include_total": include_total, - } - payload = await self._http.request( - "GET", f"/v1/files/{file_id}/versions", params=params - ) - return Paginated[File].model_validate(payload) - - return async_cursor_paginate(fetch, start=next) - - async def get(self, file_id: str, version_id: str) -> File: - payload = await self._http.request( - "GET", f"/v1/files/{file_id}/versions/{version_id}" - ) - return File.model_validate(payload) - - async 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 = await self._http.request( - "POST", - f"/v1/files/{file_id}/versions", - files=files, - data=data, - ) - return File.model_validate(payload) - - -class AsyncFiles: - def __init__(self, http: _AsyncHttpClient) -> None: - self._http = http - self.versions = AsyncFileVersions(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, - ) -> AsyncPager[File, Paginated[File]]: - """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.""" - - async 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 = await self._http.request( - "GET", "/v1/files", params=params - ) - return Paginated[File].model_validate(payload) - - return async_cursor_paginate(fetch, start=next) - - async 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: - >>> await 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 = await self._http.request( - "POST", "/v1/files", files=files, data=data - ) - return File.model_validate(payload) - - async 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 = await self._http.request( - "POST", "/v1/files", json=body.model_dump(exclude_none=True) - ) - return File.model_validate(payload) - - async def get(self, file_id: str) -> File: - payload = await self._http.request("GET", f"/v1/files/{file_id}") - return File.model_validate(payload) - - async 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 = await self._http.request( - "PATCH", f"/v1/files/{file_id}", json=body - ) - return File.model_validate(payload) - - async def delete(self, file_id: str) -> None: - await self._http.request( - "DELETE", f"/v1/files/{file_id}", expect="empty" - ) - - async def download(self, file_id: str) -> bytes: - return await self._http.request( - "GET", f"/v1/files/{file_id}/content", expect="bytes" - ) - def download_stream(self, file_id: str) -> AsyncIterator[bytes]: - return self._http.stream_bytes(f"/v1/files/{file_id}/content") +__all__ = [ + "AsyncFileVersions", + "AsyncFiles", + "FileLike", + "FileVersions", + "Files", +] diff --git a/introspection_sdk/runner_resources/metrics.py b/introspection_sdk/runner_resources/metrics.py index 7617ef0..3f1b0d3 100644 --- a/introspection_sdk/runner_resources/metrics.py +++ b/introspection_sdk/runner_resources/metrics.py @@ -10,54 +10,14 @@ from __future__ import annotations -from typing import Any - -from introspection_sdk._http import _AsyncHttpClient, _HttpClient -from introspection_sdk.schemas.metrics import ( - MetricQueryRequest, - MetricQueryResponse, +from introspection_sdk._async.runner_resources.metrics import ( + AsyncMetrics, +) +from introspection_sdk._sync.runner_resources.metrics import ( + Metrics, ) - -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) - - -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) +__all__ = [ + "AsyncMetrics", + "Metrics", +] diff --git a/introspection_sdk/runner_resources/shares.py b/introspection_sdk/runner_resources/shares.py index 4eb88db..cba9955 100644 --- a/introspection_sdk/runner_resources/shares.py +++ b/introspection_sdk/runner_resources/shares.py @@ -12,271 +12,14 @@ from __future__ import annotations -from datetime import datetime -from typing import Any - -from introspection_sdk._http import _AsyncHttpClient, _HttpClient -from introspection_sdk.pagination import ( - AsyncPager, - Pager, - async_cursor_paginate, - cursor_paginate, +from introspection_sdk._async.runner_resources.shares import ( + AsyncShares, ) -from introspection_sdk.schemas.pagination import Paginated -from introspection_sdk.schemas.shares import ( - ResourceShare, - ShareCreateRequest, - ShareResourceType, - ShareUpdateRequest, +from introspection_sdk._sync.runner_resources.shares import ( + Shares, ) - -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") - - -class AsyncShares: - """Asynchronous `/v1/shares` resource.""" - - def __init__(self, http: _AsyncHttpClient) -> 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, - ) -> AsyncPager[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.""" - - async def fetch(cursor: str | None) -> Paginated[ResourceShare]: - payload = await 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 async_cursor_paginate(fetch, start=next) - - async 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 = await 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) - - async def get(self, share_id: str) -> ResourceShare: - payload = await self._http.request("GET", f"/v1/shares/{share_id}") - return ResourceShare.model_validate(payload) - - async def update( - self, - share_id: str, - *, - visible_from: datetime | None, - ) -> ResourceShare: - """Async twin of :meth:`Shares.update`.""" - payload = await self._http.request( - "PATCH", - f"/v1/shares/{share_id}", - json=_update_body(visible_from), - ) - return ResourceShare.model_validate(payload) - - async def delete(self, share_id: str) -> None: - await self._http.request( - "DELETE", f"/v1/shares/{share_id}", expect="empty" - ) +__all__ = [ + "AsyncShares", + "Shares", +] diff --git a/introspection_sdk/runner_resources/tasks.py b/introspection_sdk/runner_resources/tasks.py index bba5496..0f2c6ea 100644 --- a/introspection_sdk/runner_resources/tasks.py +++ b/introspection_sdk/runner_resources/tasks.py @@ -6,710 +6,22 @@ from __future__ import annotations -import builtins -from collections.abc import AsyncIterator, Iterator -from typing import Any - -from introspection_sdk._errors import RunFailedError, StreamIncompleteError -from introspection_sdk._http import _AsyncHttpClient, _HttpClient -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 ( - AGUIEvent, - AssistantMessage, - CustomEvent, - MessagesSnapshotEvent, - ResumeEntry, - RunErrorEvent, - TextMessageChunkEvent, - TextMessageContentEvent, +from introspection_sdk._async.runner_resources.tasks import ( + AsyncRunHandle, + AsyncTaskRuns, + AsyncTasks, ) -from introspection_sdk.schemas.pagination import Paginated -from introspection_sdk.schemas.tasks import ( - Task, - TaskCancelRequest, - TaskCancelResponse, - TaskCreateResponse, - TaskFileRef, - TaskPrompt, - TaskRepoRequest, - TaskRun, - TaskRunKind, - TaskRunResponse, +from introspection_sdk._sync.runner_resources.tasks import ( + RunHandle, + TaskRuns, + Tasks, ) - -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) - - -class AsyncRunHandle: - """Async twin of :class:`RunHandle`. - - Returned by ``AsyncTasks.start(...)`` and ``AsyncTaskRuns.create(...)``. - ``await handle.stream()`` is replaced by ``async for ev in - handle.stream()`` to iterate AG-UI events; ``await handle.text()`` - collects text frames; ``await handle.cancel()`` uses the default abort - behavior and accepts typed options for explicit abort or drain cancellation. - """ - - def __init__( - self, - task: Task | None, - run: TaskRun, - runs: AsyncTaskRuns, - ) -> 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, - ) -> AsyncIterator[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, - ) - - async def cancel( - self, - options: TaskCancelRequest | dict[str, Any] | None = None, - ) -> TaskCancelResponse: - return await self._runs.cancel( - str(self.run.task_id), self.run.id, options=options - ) - - async 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] = [] - async 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 AsyncTaskRuns: - def __init__(self, http: _AsyncHttpClient) -> None: - self._http = http - - async 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, - ) -> AsyncRunHandle: - 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 = await self._http.request( - "POST", f"/v1/tasks/{task_id}/runs", json=body - ) - res = TaskRunResponse.model_validate(payload) - return AsyncRunHandle(None, res.run, self) - - async def resume( - self, - task_id: str, - *, - resume: list[ResumeEntry | dict[str, Any]], - ) -> AsyncRunHandle: - payload = await self._http.request( - "POST", f"/v1/tasks/{task_id}/runs", json=_resume_body(resume) - ) - res = TaskRunResponse.model_validate(payload) - return AsyncRunHandle(None, res.run, self) - - async def get(self, task_id: str, run_id: str) -> TaskRun: - payload = await self._http.request( - "GET", f"/v1/tasks/{task_id}/runs/{run_id}" - ) - return TaskRun.model_validate(payload) - - async 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 await self._cancel_with(task_id, run_id, request) - payload = await self._http.request( - "POST", f"/v1/tasks/{task_id}/runs/{run_id}/cancel" - ) - return TaskCancelResponse.model_validate(payload) - - async def _cancel_with( - self, task_id: str, run_id: str, request: TaskCancelRequest - ) -> TaskCancelResponse: - payload = await 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, - ) -> AsyncIterator[AGUIEvent]: - """Stream a run's AG-UI events with transparent resume — async twin of - :meth:`TaskRuns.stream`.""" - return stream_resumable_async( - self._http, - task_id, - run_id, - max_reconnects=max_reconnects, - backoff=backoff, - timeout=timeout, - ) - - -class AsyncTasks: - def __init__(self, http: _AsyncHttpClient) -> None: - self._http = http - self.runs = AsyncTaskRuns(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, - ) -> AsyncPager[Task, Paginated[Task]]: - """List tasks. ``await`` the returned :class:`AsyncPager` for the - first page, or ``async for`` it to stream every task across pages.""" - - async 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 = await self._http.request( - "GET", "/v1/tasks", params=params - ) - return Paginated[Task].model_validate(payload) - - return async_cursor_paginate(fetch, start=next) - - async 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 = await self._http.request("POST", "/v1/tasks", json=body) - return TaskCreateResponse.model_validate(payload) - - async def get(self, task_id: str) -> Task: - payload = await self._http.request("GET", f"/v1/tasks/{task_id}") - return Task.model_validate(payload) - - async 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 = await self._http.request( - "PATCH", f"/v1/tasks/{task_id}", json=body - ) - return Task.model_validate(payload) - - async 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. - """ - await self._http.request( - "DELETE", f"/v1/tasks/{task_id}", expect="empty" - ) - - async def archive(self, task_id: str) -> None: - await self._http.request( - "POST", f"/v1/tasks/{task_id}/archive", expect="empty" - ) - - async def unarchive(self, task_id: str) -> None: - await self._http.request( - "POST", f"/v1/tasks/{task_id}/unarchive", expect="empty" - ) - - async def start(self, *, prompt: str, **kwargs: Any) -> AsyncRunHandle: - """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 = await runner.tasks.start(prompt="Summarize this repo") - >>> print(await run.text()) - """ - res = await self.create(prompt=prompt, **kwargs) - return AsyncRunHandle(res.task, res.run, self.runs) +__all__ = [ + "AsyncRunHandle", + "AsyncTaskRuns", + "AsyncTasks", + "RunHandle", + "TaskRuns", + "Tasks", +] diff --git a/introspection_sdk/streaming.py b/introspection_sdk/streaming.py index e97d35d..c5e2215 100644 --- a/introspection_sdk/streaming.py +++ b/introspection_sdk/streaming.py @@ -6,113 +6,18 @@ from __future__ import annotations -import json -from collections.abc import AsyncIterable, AsyncIterator, Iterable, Iterator -from dataclasses import dataclass, field -from typing import Any - -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]: - acc = _SseAccumulator() - async 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) - - -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) - yield validate_ag_ui_event(payload) +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", +] diff --git a/pyproject.toml b/pyproject.toml index fc2d957..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", @@ -96,6 +105,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" @@ -159,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/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() 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"