cradle.sdk.client

   1import copy
   2import difflib
   3import logging
   4import time
   5import warnings
   6from collections.abc import Iterator
   7from importlib.metadata import version as pkg_version
   8from pathlib import Path
   9from typing import Any, BinaryIO, NoReturn, TypeVar
  10from urllib.parse import urlparse
  11
  12import httpx
  13import jwt
  14import pyarrow as pa
  15from pydantic import BaseModel, TypeAdapter
  16from typing_extensions import Generator, override
  17
  18from cradle.sdk.auth.device import DeviceAuth
  19from cradle.sdk.exceptions import ClientError as ClientError  # for re-export
  20from cradle.sdk.types.common import (
  21    ContextProject,
  22    ContextRound,
  23    ContextWorkspace,
  24    ListOptions,
  25)
  26from cradle.sdk.types.data import (
  27    AddTableRequest,
  28    ArtifactResponse,
  29    BaseTableCreate,
  30    BaseTableUpdate,
  31    DataLoadCreate,
  32    DataLoadResponse,
  33    DataVersionResponse,
  34    FileUploadResponse,
  35    ListArtifactResponse,
  36    ListDataLoadResponse,
  37    ListDataVersionResponse,
  38    ListTableResponse,
  39    QueryDataRequest,
  40    TableArchive,
  41    TableRename,
  42    TableResponse,
  43    ViewTableCreate,
  44    ViewTableUpdate,
  45)
  46from cradle.sdk.types.task import (
  47    ListTaskResponse,
  48    TaskCreate,
  49    TaskResponse,
  50    TaskState,
  51)
  52from cradle.sdk.types.workspace import (
  53    CustomPredictorContainerCreate,
  54    CustomPredictorContainerCreateResponse,
  55    CustomPredictorContainerUpdate,
  56    CustomPredictorCreateResponse,
  57    CustomPredictorResponse,
  58    ListCustomPredictorResponse,
  59    ListProjectResponse,
  60    ListRoundResponse,
  61    ProjectCreate,
  62    ProjectResponse,
  63    RoundCreate,
  64    RoundResponse,
  65    WorkspaceResponse,
  66)
  67
  68logger = logging.getLogger(__name__)
  69
  70T = TypeVar("T", bound=BaseModel)
  71
  72
  73class HttpClient:
  74    def __init__(
  75        self,
  76        prefix: str,
  77        base_url: str,
  78        auth: httpx.Auth | None = None,
  79        timeout: float = 60.0,
  80        user_agent: str | None = None,
  81        workspace: str | None = None,
  82        use_keyring: bool = True,
  83    ):
  84        base_url = base_url.rstrip("/")
  85        user_agent = user_agent or f"cradle-sdk-python/{pkg_version('cradle-sdk')}"
  86        headers = {
  87            "Accept": "application/json",
  88            "User-Agent": user_agent,
  89        }
  90        client = httpx.Client(auth=auth, headers=headers, timeout=timeout, follow_redirects=True)
  91        if auth is None:
  92            client.auth = DeviceAuth.from_strategy(
  93                client=client, workspace=workspace, base_url=base_url, use_keyring=use_keyring
  94            )
  95
  96        self.http_client = client
  97        self.prefix = prefix.strip("/")
  98        self.base_url = base_url.rstrip("/")
  99        self.workspace = workspace
 100
 101    def with_prefix(self, prefix: str) -> "HttpClient":
 102        c = copy.copy(self)
 103        c.prefix = f"{self.prefix}/{prefix.strip('/')}".strip("/")
 104        return c
 105
 106    def url(self, path: str) -> str:
 107        """Generate a URL for a given path.
 108
 109        This follows slightly weird rules, specific to how our API is designed.
 110
 111        The overall goal here is to make the URL generation loose with what it accepts, to never
 112        be confused by whether or not there are unexpected slashes in any part of the url. It's a very
 113        "do what I mean" approach.
 114
 115        - "path" is never interpreted as an absolute path, it is always relative to the prefix
 116        - if the path starts with ":", it is a verb endpoint and should be appended to the url + prefix, not assumed to be a subpath
 117        - everything else is assumed to be a subpath and should be appended to the url + prefix
 118        """
 119
 120        combined_path = f"{self.prefix}{path}"
 121        if not path.startswith(":") and not path.startswith("/"):
 122            combined_path = f"{self.prefix}/{path}"
 123
 124        # This special case is needed in case we have a ":verb" endpoint at the root of the API
 125        # like "https://cradle.bio/:list" - otherwise the logic below would generate
 126        # "https://cradle.bio:list" which is wrong.
 127        parsed_url = urlparse(self.base_url)
 128        base_url_has_path = bool(parsed_url.path and parsed_url.path not in {"", "/"})
 129        if combined_path.startswith(":") and base_url_has_path:
 130            return f"{self.base_url}{combined_path}"
 131
 132        return f"{self.base_url}/{combined_path.lstrip('/')}"
 133
 134    def request(
 135        self,
 136        method: str,
 137        path: str,
 138        json: dict[str, Any] | BaseModel | None = None,
 139        params: dict[str, Any] | None = None,
 140        **kwargs,
 141    ) -> httpx.Response:
 142        if params is not None:
 143            # HTTPX will send "...&foo=&...", which FastAPI would interpret as an empty string rather than None.
 144            kwargs["params"] = {k: v for k, v in params.items() if v is not None}
 145        if isinstance(json, BaseModel):
 146            json = json.model_dump(mode="json", by_alias=True)
 147        if json is not None:
 148            kwargs["json"] = json
 149
 150        return self.http_client.request(method, self.url(path), **kwargs)
 151
 152    def get(self, path: str, response_type: type[T] | Any | None, params: dict[str, Any] | None = None, **kwargs) -> T:
 153        return self._handle_response(self.request("GET", path, params=params, **kwargs), response_type)
 154
 155    def post(self, path: str, response_type: type[T] | Any | None, params: dict[str, Any] | None = None, **kwargs) -> T:
 156        return self._handle_response(self.request("POST", path, params=params, **kwargs), response_type)
 157
 158    def _handle_response(
 159        self, response: httpx.Response, response_type: type[T] | Iterator[str] | Iterator[bytes] | None
 160    ) -> Any:
 161        if not response.is_success:
 162            self.handle_error_response(response)
 163        if response_type is None:
 164            return None
 165
 166        ct = response.headers.get("content-type")
 167        if ct is None:
 168            raise ValueError("content type header missing")
 169
 170        ct = ct.split(";")[0].strip()
 171        if ct == "application/json":
 172            return TypeAdapter(response_type).validate_python(response.json())
 173        if ct == "application/x-ndjson" and response_type == Iterator[str]:
 174            return response.iter_lines()
 175        if ct == "application/octet-stream" and response_type == Iterator[bytes]:
 176            return response.iter_bytes()
 177        raise ValueError(f"Unsupported content type {ct}")
 178
 179    def handle_error_response(self, response: httpx.Response) -> NoReturn:
 180        auth = self.http_client.auth
 181        if (
 182            response.status_code == 403
 183            and self.workspace is not None
 184            and isinstance(auth, DeviceAuth)
 185            and auth.authorized_workspace_id != self.workspace
 186        ):
 187            auth.suggest_logout(self.workspace)
 188        try:
 189            body = response.json()
 190            error_msg = body.get("detail", response.text)
 191            if (error_id := body.get("error_id")) is not None:
 192                error_msg = f"{error_msg} (error ID: {error_id})"
 193            errors = body.get("errors", [])
 194        except Exception:  # noqa: BLE001
 195            error_msg = response.text
 196            errors = []
 197        raise ClientError(response.status_code, error_msg, errors=errors)
 198
 199
 200class DisabledAuth(httpx.Auth):
 201    """Auth implementation to use when authentication should be disabled"""
 202
 203    def __init__(self, workspace: str | None = None):
 204        self._workspace_name = workspace
 205
 206    @override
 207    def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
 208        claims = {
 209            "urn:cradle:email": "john-doe@cradle.bio",
 210            "urn:cradle:user_id": "john-doe",
 211            "urn:cradle:full_name": "John Doe",
 212            "urn:cradle:workspace_id": self._workspace_name,
 213        }
 214
 215        token = jwt.encode(claims, key="this_key_is_not_verified_by_the_backend")
 216
 217        request.headers["Authorization"] = f"Bearer ca_{token}"
 218        yield request
 219
 220
 221API_URL = "https://api.cradle.bio"
 222
 223
 224class Client:
 225    """The workspace API provides functionality for retrieving information about your workspace.
 226
 227    A workspace is the home for all data, projects and tasks for an organization. By
 228    design no information can be shared across workspaces, they are the top-level
 229    concept in the Cradle API.
 230
 231    Workspace creation and management is only available to Cradle administrators.
 232    """
 233
 234    def __init__(
 235        self,
 236        workspace: str,
 237        auth: httpx.Auth | None = None,
 238        base_url: str = API_URL,
 239        timeout: int = 60,
 240        user_agent: str | None = None,
 241        use_keyring: bool = True,
 242    ):
 243        self._base_client = HttpClient(
 244            prefix="/",
 245            base_url=base_url,
 246            auth=auth,
 247            timeout=timeout,
 248            user_agent=user_agent,
 249            workspace=workspace,
 250            use_keyring=use_keyring,
 251        )
 252        self._client = self._base_client.with_prefix(f"/v2/workspace/{workspace}/")
 253        self._workspace = workspace
 254
 255    def workspace(self) -> WorkspaceResponse:
 256        return self._base_client.get("/v2/workspace:get", WorkspaceResponse, dict(name=self._workspace))
 257
 258    @property
 259    def project(self) -> "ProjectClient":
 260        return ProjectClient(self._client)
 261
 262    @property
 263    def round(self) -> "RoundClient":
 264        return RoundClient(self._client)
 265
 266    @property
 267    def data(self) -> "DataClient":
 268        return DataClient(self._client)
 269
 270    @property
 271    def task(self) -> "TaskClient":
 272        return TaskClient(self._client)
 273
 274    @property
 275    def custom_predictor(self) -> "CustomPredictorClient":
 276        return CustomPredictorClient(self._client)
 277
 278
 279class ProjectClient:
 280    """The projects API provides functionality for managing and interacting with
 281    projects in a workspace.
 282
 283    Projects organize tasks in a workspace and represent the work involved in
 284    optimizing a specific protein or achieving a specific goal. Data is currently
 285    isolated to a specific project, but in the future we will support cross project
 286    data sharing within a workspace.
 287    """
 288
 289    def __init__(self, client: HttpClient):
 290        self._client = client.with_prefix("/project")
 291
 292    def get(self, project_id: int) -> ProjectResponse:
 293        """Get a project by `ID`."""
 294        return self._client.get(":get", ProjectResponse, dict(id=project_id))
 295
 296    def list(self) -> Generator[ProjectResponse, None, None]:
 297        """List all projects in the workspace."""
 298        opts = ListOptions()
 299        while True:
 300            resp = self._client.get(":list", ListProjectResponse, params=opts.model_dump())
 301            yield from resp.items
 302            if resp.cursor is None:
 303                return
 304            opts.cursor = resp.cursor
 305
 306    def get_by_name(self, name: str) -> ProjectResponse:
 307        """Get a project by name. Raises ValueError if not found."""
 308        for project in self.list():
 309            if project.name == name:
 310                return project
 311        raise ValueError(f"Project with name '{name}' not found")
 312
 313    def create(self, project: ProjectCreate) -> ProjectResponse:
 314        """Create a new project in the workspace."""
 315        return self._client.post(":create", ProjectResponse, json=project)
 316
 317    def update(self, project_id: int, project: ProjectCreate) -> ProjectResponse:
 318        """Update a project's information."""
 319        return self._client.post(":update", ProjectResponse, dict(id=project_id), json=project)
 320
 321    def archive(self, project_id: int) -> ProjectResponse:
 322        """Archive a project.
 323
 324        The data from the project will still be available.
 325        It will not be possible to create new tasks, data loads, etc. in the project.
 326        """
 327        return self._client.post(":archive", ProjectResponse, dict(id=project_id))
 328
 329    def unarchive(self, project_id: int) -> ProjectResponse:
 330        """Unarchive a previously archived project.
 331
 332        Returns an HTTP 422 `Unprocessable Entity` error if the project is not archived.
 333        """
 334        return self._client.post(":unarchive", ProjectResponse, dict(id=project_id))
 335
 336
 337class RoundClient:
 338    """The rounds API provides functionality for managing and interacting with rounds within a project.
 339
 340    Rounds are a way to organize the experiments within a project. Commonly, a round represents all
 341    in-vitro experiments related to a specific sub-goal or timeframe of a project.
 342    While rounds are typically ordered sequentially, multiple rounds can exist in parallel (for example, when
 343    testing multiple design methods in parallel).
 344
 345    Tasks, data loads, and reports can be (but don't have to be!) assigned to a round.
 346    """
 347
 348    def __init__(self, client: HttpClient):
 349        self._client = client.with_prefix("/round")
 350
 351    def get(self, round_id: int) -> RoundResponse:
 352        """Get a round by ID."""
 353        return self._client.get(":get", RoundResponse, dict(id=round_id))
 354
 355    def list(self, project_id: int | None = None) -> Generator[RoundResponse, None, None]:
 356        """List all rounds in the project."""
 357        params = {}
 358        if project_id is not None:
 359            params["project_id"] = project_id
 360
 361        opts = ListOptions()
 362        while True:
 363            resp = self._client.get(
 364                ":list",
 365                ListRoundResponse,
 366                params={**opts.model_dump(), **params},
 367            )
 368            yield from resp.items
 369            if resp.cursor is None:
 370                return
 371            opts.cursor = resp.cursor
 372
 373    def get_by_name(self, project_id: int, name: str) -> RoundResponse:
 374        """Get a round by name within a specific project. Raises ValueError if not found."""
 375        for round_ in self.list(project_id=project_id):
 376            if round_.name == name:
 377                return round_
 378        raise ValueError(f"Round with name '{name}' not found in project {project_id}")
 379
 380    def create(self, round_: RoundCreate) -> RoundResponse:
 381        """Create a new round in a project."""
 382        return self._client.post(":create", RoundResponse, json=round_)
 383
 384    def update(self, round_id: int, round_: RoundCreate) -> RoundResponse:
 385        """Update a round's name and description."""
 386        return self._client.post(":update", RoundResponse, dict(id=round_id), json=round_)
 387
 388    def archive(self, round_id: int) -> RoundResponse:
 389        """Archive a round.
 390
 391        The data from the round will still be available.
 392        It will not be possible to create new tasks, data loads, etc. in the round.
 393        """
 394        return self._client.post(":archive", RoundResponse, dict(id=round_id))
 395
 396    def unarchive(self, round_id: int) -> RoundResponse:
 397        """Unarchive a previously archived round.
 398
 399        Returns an HTTP 422 `Unprocessable Entity` error if the round is not archived.
 400        """
 401        return self._client.post(":unarchive", RoundResponse, dict(id=round_id))
 402
 403
 404class DataClient:
 405    """The data API provides functionality for managing and interacting with data in a workspace.
 406
 407    Data comes in the form of *tables* and *artifacts*.
 408    *Tables* are used to store row-based data, such as measurements, sequences, and other structured data.
 409    *Artifacts* are used to store the results of tasks, such as machine learning models, predictions, and other outputs.
 410
 411    While tables can be created and uploaded by users, artifacts are only produced by tasks and cannot be uploaded directly.
 412
 413    Artifacts and table rows (via their data load source) have a context specified. This context refers to the
 414    origin of the data and is used in other parts of the API to determine data visibility, e.g. to
 415    isolate data between projects.
 416
 417    See the [Artifacts](#tag/Artifacts) API endpoints for more details on artifact data.
 418
 419    See the [Tables](#tag/Tables) API endpoints for more details on tabular data.
 420    """
 421
 422    def __init__(self, client: HttpClient):
 423        self._client = client.with_prefix("/data")
 424
 425    @property
 426    def artifact(self) -> "ArtifactClient":
 427        return ArtifactClient(self._client)
 428
 429    @property
 430    def table(self) -> "TableClient":
 431        return TableClient(self._client)
 432
 433    @property
 434    def load(self) -> "DataLoadClient":
 435        return DataLoadClient(self._client)
 436
 437    def list_versions(
 438        self,
 439        project_id: int | None = None,
 440        table_id: int | None = None,
 441    ) -> Generator[DataVersionResponse, None, None]:
 442        warnings.warn(
 443            "DataClient.list_versions() is deprecated. Use snapshots instead to work with older state of data.",
 444            category=DeprecationWarning,
 445            stacklevel=2,
 446        )
 447
 448        params = {}
 449        if project_id is not None:
 450            params["project_id"] = project_id
 451        if table_id is not None:
 452            params["table_id"] = table_id
 453
 454        opts = ListOptions()
 455        while True:
 456            resp = self._client.get("/version:list", ListDataVersionResponse, params={**params, **opts.model_dump()})
 457            yield from resp.items
 458            if resp.cursor is None:
 459                return
 460            opts.cursor = resp.cursor
 461
 462
 463class ArtifactClient:
 464    """Artifacts are produced by tasks and can be referenced as inputs to other tasks. They can generally
 465    not be uploaded and downloaded directly. They have a well-defined type that determines for which
 466    purpose they can be used.
 467    """
 468
 469    def __init__(self, client: HttpClient):
 470        self._client = client.with_prefix("/artifact")
 471
 472    def get(self, artifact_id: int) -> ArtifactResponse:
 473        """Get information about an artifact."""
 474        return self._client.get(":get", ArtifactResponse, dict(id=artifact_id))
 475
 476    def list(
 477        self,
 478        project_id: int | None = None,
 479        round_id: int | None = None,
 480    ) -> Generator[ArtifactResponse, None, None]:
 481        """List artifacts in the workspace."""
 482        opts = ListOptions()
 483
 484        params = {}
 485        if project_id is not None:
 486            params["project_id"] = project_id
 487        if round_id is not None:
 488            params["round_id"] = round_id
 489
 490        while True:
 491            resp = self._client.get(
 492                ":list",
 493                ListArtifactResponse,
 494                params={**opts.model_dump(), **params},
 495            )
 496            yield from resp.items
 497            if resp.cursor is None:
 498                return
 499            opts.cursor = resp.cursor
 500
 501
 502class _IteratorReader:
 503    def __init__(self, iterator: Iterator[bytes]):
 504        self.iterator = iterator
 505        self.buffer = b""
 506        self._closed = False
 507
 508    @property
 509    def closed(self) -> bool:
 510        return self._closed
 511
 512    def close(self) -> None:
 513        self._closed = True
 514        self._buffer = b""
 515
 516    def read(self, size=-1):
 517        try:
 518            while size < 0 or len(self.buffer) < size:
 519                self.buffer += next(self.iterator)
 520        except StopIteration:
 521            pass
 522        if size < 0:
 523            out, self.buffer = self.buffer, b""
 524        else:
 525            out, self.buffer = self.buffer[:size], self.buffer[size:]
 526        return out
 527
 528    def __enter__(self):
 529        return self
 530
 531    def __exit__(self, exc_type, exc_val, exc_tb):
 532        self.close()
 533
 534
 535class TableClient:
 536    """Tables store row-based data with a well-defined column schema. Such data may come from spreadsheets
 537    or other tabular data stores.
 538
 539    Each table is created with a well-defined column schema, which can include primitive columns such
 540    as numbers and strings, as well as nested struct columns and arrays.
 541    A table's schema can be extended but existing columns cannot generally be modified or removed.
 542
 543    Table data as well as it's column schema itself are version controlled. Each change to the contained
 544    rows is tracked as a data version.
 545    Tables can be queried at any historic version unless some destructive change has been made, such as
 546    hard deletion of historic data for compliance reasons.
 547
 548    Because tables are versioned, each row is immutable. This means that it is not possible to change individual
 549    values in a row. Instead, the data load that added the row can be undone and the rows can be loaded
 550    again with the changed data.
 551
 552    See the [Data Loads](#tag/Data-Loads) API endpoints on how to add and remove data to tables.
 553    """
 554
 555    def __init__(self, client: HttpClient):
 556        self._client = client.with_prefix("/table")
 557
 558    def query(
 559        self,
 560        query: str,
 561        *,
 562        project_id: int | None,
 563        version_id: int | None = None,
 564    ) -> Generator[pa.RecordBatch, None, None]:
 565        """Query data with the given SQL query and retrieve the result as arrow record batches.
 566
 567        Args:
 568            query: The SQL query the execute.
 569            project_id: project_id to filter the source data by. Can be set to none to query data
 570                access all projects the authenticated user has access to.
 571            version_id: Data version ID at which to execute the query.
 572
 573        Example:
 574            ```
 575            result = client.query("SELECT * FROM source_table")
 576            table = pyarrow.Table.from_batches(result)
 577
 578            # Write as CSV. Does only work with primitive columns.
 579            pyarrow.csv.write_csv(t, "result.csv")
 580
 581            # Write as Parquet. Does work with structs and array columns as well.
 582            pyarrow.parquet.write_table(t, "result.parquet")
 583            ```
 584        """
 585        request = QueryDataRequest(
 586            query=query,
 587            project_id=project_id,
 588            version_id=version_id,
 589        )
 590        with self._client.http_client.stream(
 591            "POST", self._client.url(":query"), json=request.model_dump(by_alias=True)
 592        ) as response:
 593            if not response.is_success:
 594                response.read()  # Required in streaming request to the error handling can access content.
 595                self._client.handle_error_response(response)
 596
 597            ct = response.headers.get("content-type")
 598            if ct is None:
 599                raise ValueError("content type header missing")
 600            ct = ct.split(";")[0].strip()
 601
 602            if ct != "application/vnd.apache.arrow.stream":
 603                raise ValueError(f"unexpected content type {ct}")
 604            yield from pa.ipc.open_stream(_IteratorReader(response.iter_bytes()))
 605
 606    def get_by_id(self, table_id: int) -> TableResponse:
 607        """Retrieve the table by its ID.
 608        The ID is an opaque identifier for the table returned at table creation time and is distinct from the table reference.
 609        """
 610        return self._client.get(":getById", TableResponse, dict(id=table_id))
 611
 612    def get(self, reference: str) -> TableResponse:
 613        """Get a table by its reference."""
 614        return self._client.get(":get", TableResponse, dict(reference=reference))
 615
 616    def list(self) -> Generator[TableResponse, None, None]:
 617        """List all current base tables and views in the workspace."""
 618        opts = ListOptions()
 619        while True:
 620            resp = self._client.get(":list", ListTableResponse, params=opts.model_dump())
 621            yield from resp.items
 622            if resp.cursor is None:
 623                return
 624            opts.cursor = resp.cursor
 625
 626    def list_versions(self, table_id: int) -> Generator[TableResponse, None, None]:
 627        """List all versions of a specific table.
 628
 629        Deprecated: Table versions will no longer be tracked in future platform versions.
 630        """
 631        warnings.warn(
 632            "TableClient.list_versions() is deprecated. Use snapshots instead to work with older state of data.",
 633            category=DeprecationWarning,
 634            stacklevel=2,
 635        )
 636
 637        opts = ListOptions()
 638        while True:
 639            resp = self._client.get(
 640                ":listVersions",
 641                ListTableResponse,
 642                params=dict(**opts.model_dump(), id=table_id),
 643            )
 644            yield from resp.items
 645            if resp.cursor is None:
 646                return
 647            opts.cursor = resp.cursor
 648
 649    def list_archived(self, reference: str) -> Generator[TableResponse, None, None]:
 650        """List all archived tables that match the given reference."""
 651        opts = ListOptions()
 652        while True:
 653            resp = self._client.get(
 654                ":listArchived",
 655                ListTableResponse,
 656                params=dict(**opts.model_dump(), reference=reference),
 657            )
 658            yield from resp.items
 659            if resp.cursor is None:
 660                return
 661            opts.cursor = resp.cursor
 662
 663    def create(self, table: ViewTableCreate | BaseTableCreate) -> TableResponse:
 664        """Create a new table or view."""
 665        return self._client.post(":create", TableResponse, json=table)
 666
 667    def update(self, reference: str, table: ViewTableUpdate | BaseTableUpdate) -> TableResponse:
 668        """Update a table or view.
 669
 670        Tables can only be updated in forward-compatible ways.
 671        New columns can be added but not be removed.
 672        Existing columns can be turned from non-nullable to nullable but not the other way around.
 673        Existing columns cannot be renamed or changed in type.
 674
 675        Views can be updated arbitrarily.
 676        """
 677        # Server differentiates between assembly_format being None or unset,
 678        # so make sure to propagate it correctly.
 679        body = table.model_dump(mode="json", by_alias=True)
 680        if "assembly_format" not in table.model_fields_set:
 681            body.pop("assembly_format")
 682
 683        return self._client.post(":update", TableResponse, params=dict(reference=reference), json=body)
 684
 685    def archive(self, reference: str, archive: TableArchive | None = None) -> TableResponse:
 686        """Archive a table or view.
 687
 688        Archiving makes the table or view inaccessible for regular operations
 689        but preserves it for historical purposes.
 690        """
 691        if not archive:
 692            archive = TableArchive()
 693        return self._client.post(":archive", TableResponse, params=dict(reference=reference), json=archive)
 694
 695    def unarchive(self, table_id: int) -> TableResponse:
 696        """Unarchive a previously archived table or view.
 697
 698        This makes the table or view available for regular operations again.
 699        If an active table with the same reference already exists, the unarchive operation
 700        will fail with an HTTP 409 `Conflict error`.
 701        """
 702        return self._client.post(":unarchive", TableResponse, params=dict(id=table_id))
 703
 704    def rename(self, reference: str, rename: TableRename) -> TableResponse:
 705        """Rename the table."""
 706        return self._client.post(":rename", TableResponse, params=dict(reference=reference), json=rename)
 707
 708
 709class DataLoadClient:
 710    """The data API provides functionality for managing and interacting with data in a workspace.
 711
 712    Data comes in the form of *tables* and *artifacts*.
 713    *Tables* are used to store row-based data, such as measurements, sequences, and other structured data.
 714    *Artifacts* are used to store the results of tasks, such as machine learning models, predictions, and other outputs.
 715
 716    While tables can be created and uploaded by users, artifacts are only produced by tasks and cannot be uploaded directly.
 717
 718    Artifacts and table rows (via their data load source) have a context specified. This context refers to the
 719    origin of the data and is used in other parts of the API to determine data visibility, e.g. to
 720    isolate data between projects.
 721
 722    See the [Artifacts](#tag/Artifacts) API endpoints for more details on artifact data.
 723
 724    See the [Tables](#tag/Tables) API endpoints for more details on tabular data.
 725    """
 726
 727    def __init__(self, client: HttpClient):
 728        self._client = client.with_prefix("/load")
 729
 730    def list(
 731        self, project_id: int | None = None, round_id: int | None = None
 732    ) -> Generator[DataLoadResponse, None, None]:
 733        """List all data loads in the workspace."""
 734        params = {}
 735        if project_id is not None:
 736            params["project_id"] = project_id
 737        if round_id is not None:
 738            params["round_id"] = round_id
 739
 740        opts = ListOptions()
 741        while True:
 742            resp = self._client.get(":list", ListDataLoadResponse, params={**params, **opts.model_dump()})
 743            yield from resp.items
 744            if resp.cursor is None:
 745                return
 746            opts.cursor = resp.cursor
 747
 748    def get(self, load_id: int) -> DataLoadResponse:
 749        """Get a data load by ID."""
 750        return self._client.get(":get", DataLoadResponse, dict(id=load_id))
 751
 752    def create(self, load: DataLoadCreate) -> DataLoadResponse:
 753        """Create a new data load.
 754        After creation, the data load will be empty. Files can subsequently be added using the `uploadFile` endpoint.
 755        """
 756        return self._client.post(":create", DataLoadResponse, json=load)
 757
 758    def upload_file(
 759        self,
 760        load_id: int,
 761        file: Path,
 762        *,
 763        description: str | None = None,
 764        filepath: str | Path | None = None,
 765        table_reference: str | None = None,
 766        source_file_id: int | None = None,
 767    ) -> FileUploadResponse:
 768        """Upload a file to the cloud storage bucket and add upload information to load."""
 769        data = {}
 770        if description is not None:
 771            data["description"] = description
 772        if filepath is not None:
 773            data["filepath"] = str(filepath)
 774        if table_reference is not None:
 775            data["table_reference"] = table_reference
 776        if source_file_id is not None:
 777            data["source_file_id"] = source_file_id
 778
 779        with file.open("rb") as f:
 780            return self._client.post(
 781                ":uploadFile",
 782                FileUploadResponse,
 783                params=dict(id=load_id),
 784                files=dict(file=(file.name, f, "application/octet-stream")),
 785                data=data,
 786            )
 787
 788    def download_file(self, file_id: str, buffer: BinaryIO):
 789        """Download a previously uploaded file from a load."""
 790        bytes_stream: Iterator[bytes] = self._client.get(":downloadFile", Iterator[bytes], dict(file_id=file_id))
 791        buffer.writelines(bytes_stream)
 792
 793    def finalize(self, load_id: int) -> DataLoadResponse:
 794        """Finalize the data load and start ingesting rows from the uploaded files.
 795
 796        The ingestion process will run in the background. The progress can be tracked by polling the data load
 797        and waiting for the status to switch from `LOADING` to `COMPLETED` or `FAILED`.
 798        """
 799        return self._client.post(":finalize", DataLoadResponse, params=dict(id=load_id))
 800
 801    def delete(self, load_id: int) -> DataLoadResponse:
 802        """Delete the data load if it has not been completed yet.
 803
 804        The deletion will happen asynchronously afterwards and is irreversible.
 805        """
 806        return self._client.post(":delete", DataLoadResponse, params=dict(id=load_id))
 807
 808    def undo(self, load_id: int) -> DataLoadResponse:
 809        """Append an entry to the changelog undoing the load.
 810
 811        The rows from this data load will no longer show at subsequent data versions.
 812        Previously undone loads can be redone to restore their data.
 813        """
 814        return self._client.post(":undo", DataLoadResponse, params=dict(id=load_id))
 815
 816    def redo(self, load_id: int) -> DataLoadResponse:
 817        """Append an entry to the changelog redoing the load. The rows from this data load will be visible again at subsequent data versions."""
 818        return self._client.post(":redo", DataLoadResponse, params=dict(id=load_id))
 819
 820    def add_table(self, load_id: int, request: AddTableRequest) -> DataLoadResponse:
 821        """Register a new table in the data load configuration."""
 822        return self._client.post(":addTable", DataLoadResponse, params=dict(id=load_id), json=request)
 823
 824
 825class TaskClient:
 826    """Create endpoints for all supported task types.
 827
 828    When creating a task, a context must be specified. The context determines data visibility: data can either be visible
 829    in the entire project, or only in a specific round within a project.
 830    Artifact and table data also has a context and a task can only access data with a compatible context. A data context
 831    is compatible when it a workspace context or, if it is a project or round context, belongs to the same project.
 832
 833    For example, suppose project `A` has rounds `abc` and `def` and project `B` has round `xyz`.
 834    A task with context `project=A` or `round=abc` can only see data with context `project=A` or `round=abc` or `round=def`.
 835    A task with context `project=B` can only see data with context `project=B` or `round=xyz`.
 836
 837    Any data a task creates will be saved with the same context as the task.
 838
 839    Tasks always use the latest version of the data in the workspace, unless a specific version is provided.
 840    """
 841
 842    def __init__(self, client: HttpClient):
 843        self._client = client.with_prefix("/task")
 844
 845    def list(self, project_id: int | None = None, round_id: int | None = None) -> Generator[TaskResponse, None, None]:
 846        """List all tasks in a workspace, project or round.
 847
 848        To list all tasks in a workspace, leave `project_id` and `round_id` unset.
 849        To list all tasks in a project, specify `project_id` and leave `round_id` unset.
 850        To list all tasks in a round, specify `round_id` and leave `project_id` unset.
 851
 852        Only one of `project_id` or `round_id` must be specified at once.
 853        """
 854        params = {}
 855        if project_id is not None:
 856            params["project_id"] = project_id
 857        if round_id is not None:
 858            params["round_id"] = round_id
 859
 860        opts = ListOptions()
 861        while True:
 862            resp = self._client.get(":list", ListTaskResponse, params={**params, **opts.model_dump()})
 863            yield from resp.items
 864            if resp.cursor is None:
 865                return
 866            opts.cursor = resp.cursor
 867
 868    def get(self, task_id: int) -> TaskResponse:
 869        """Obtain the current state and the result of a task.
 870
 871        This endpoint can be called periodically to monitor the status of a task and to detect
 872        when it finished executing.
 873
 874        If the `state` field of the response is `COMPLETED`, then the `result` field contains the
 875        task result (often containing IDs of artifacts and tables created by the task).
 876
 877        If the `state` field of the response is `FAILED`, then the `error` field contains
 878        a description explaining why the task has failed.
 879
 880        The states `COMPLETED`, `FAILED` and `CANCELLED` are terminal states. When the task is in
 881        any other state, it will eventually transition into one of these three.
 882        """
 883        return self._client.get(":get", TaskResponse, dict(id=task_id))
 884
 885    def get_by_name(
 886        self,
 887        name: str,
 888        project_id: int | None = None,
 889        round_id: int | None = None,
 890    ) -> TaskResponse:
 891        """Get a task by unique name within their workspace, project, or round context.
 892
 893        If a task with name "TaskA" exists in project with ID 123, the `project_id` must be specified.
 894        It is not sufficient to leave it unset as tasks with the same name may exist at the workspace or
 895        round context or in a different project.
 896        """
 897        tasks = self.list(project_id=project_id, round_id=round_id)
 898        # When listing for workspace or project we still need to filter it down since they'll
 899        # return tasks from their sub-contexts.
 900        if round_id is not None:
 901            scope_desc = f"round {round_id}"
 902            tasks = [task for task in tasks if task.context == ContextRound(round_id=round_id)]
 903        elif project_id is not None:
 904            scope_desc = f"project {project_id}"
 905            tasks = [task for task in tasks if task.context == ContextProject(project_id=project_id)]
 906        else:
 907            scope_desc = "workspace"
 908            tasks = [task for task in tasks if task.context == ContextWorkspace()]
 909
 910        for task in tasks:
 911            if task.name == name:
 912                return task
 913        raise ValueError(f"Task with name '{name}' not found in {scope_desc}")
 914
 915    def get_by_idempotency_key(
 916        self,
 917        idempotency_key: str,
 918        round_id: int | None = None,
 919        project_id: int | None = None,
 920    ) -> TaskResponse:
 921        """Get a task by its idempotency key within a specific round or project.
 922
 923        If a task with idempotency key "TaskA" exists in project with ID 123, the `project_id` must be specified.
 924        It is not sufficient to leave it unset as tasks with the same idempotency key may exist at the workspace or
 925        round context or in a different project.
 926        """
 927        params = {"idempotency_key": idempotency_key}
 928        if round_id is not None:
 929            params["round_id"] = str(round_id)
 930        if project_id is not None:
 931            params["project_id"] = str(project_id)
 932        return self._client.get(":getByIdempotencyKey", TaskResponse, params=params)
 933
 934    def create(self, task: TaskCreate) -> TaskResponse:
 935        return self._client.post(f"/{task.parameters.task_type}:create", TaskResponse, json=task)
 936
 937    def create_or_get(self, task: TaskCreate, skip_parameter_check: bool = False) -> TaskResponse:
 938        """Create a task or get existing one with matching (name or idempotency key) and parameters.
 939
 940        The task must have a name or an idempotency key set. If the parameter check is not skipped the existing task must have
 941        the same parameters as `task`. If an explicit `data_version_id` is specified, it must also match.
 942
 943        Raises ValueError if parameters don't match or if name is None.
 944        """
 945
 946        error_prefix: str = ""
 947
 948        # If using idempotency_key, try to create and handle 409 Conflict by fetching existing task
 949        if task.idempotency_key is not None:
 950            error_prefix = f"Task with idempotency key '{task.idempotency_key}'"
 951            try:
 952                return self.create(task)
 953            except ClientError as e:
 954                if e.status_code != 409:
 955                    raise
 956
 957                if isinstance(task.context, ContextRound):
 958                    existing_task = self.get_by_idempotency_key(task.idempotency_key, round_id=task.context.round_id)
 959                elif isinstance(task.context, ContextProject):
 960                    existing_task = self.get_by_idempotency_key(
 961                        task.idempotency_key, project_id=task.context.project_id
 962                    )
 963                else:
 964                    existing_task = self.get_by_idempotency_key(task.idempotency_key)
 965        elif name := task.name:
 966            error_prefix = f"Task with name '{name}'"
 967            try:
 968                if isinstance(task.context, ContextRound):
 969                    existing_task = self.get_by_name(name, round_id=task.context.round_id)
 970                elif isinstance(task.context, ContextProject):
 971                    existing_task = self.get_by_name(name, project_id=task.context.project_id)
 972                else:
 973                    existing_task = self.get_by_name(name)
 974            except ValueError:
 975                existing_task = None
 976        else:
 977            raise ValueError("Task must have `name` or `idempotency_key` set")
 978
 979        if existing_task is None:
 980            return self.create(task)
 981
 982        if skip_parameter_check:
 983            return existing_task
 984
 985        if existing_task.parameters != task.parameters:
 986            existing_params = existing_task.parameters.model_dump_json(indent=2, by_alias=True)
 987            new_params = task.parameters.model_dump_json(indent=2, by_alias=True)
 988            diff = "\n".join(
 989                difflib.unified_diff(
 990                    existing_params.splitlines(),
 991                    new_params.splitlines(),
 992                    fromfile="existing_parameters",
 993                    tofile="new_parameters",
 994                    lineterm="",
 995                )
 996            )
 997            raise ValueError(f"{error_prefix} exists but has different parameters:\n{diff}")
 998
 999        if task.data_version_id is not None and existing_task.data_version_id != task.data_version_id:
1000            raise ValueError(
1001                f"{error_prefix} exists but has different data_version_id (existing: {existing_task.data_version_id}, new: {task.data_version_id})"
1002            )
1003
1004        return existing_task
1005
1006    def cancel(self, task_id: int) -> TaskResponse:
1007        """Cancel the task if possible.
1008
1009        Only tasks that have not yet terminated may be canceled. There is no guarantee that cancellation succeeds
1010        before the task successfully completes.
1011        """
1012        return self._client.post(":cancel", TaskResponse, dict(id=task_id))
1013
1014    def recover(self, task_id: int) -> TaskResponse:
1015        """Recover a task that has failed or been cancelled by putting it into state EXECUTING.
1016
1017        Note that this is best-effort and is not guaranteed to fix any issues in the underlying
1018        backend execution, and may also cause unexpected side-effects such as duplicate data loads.
1019        Main use-case is tasks that failed due to intermittent issues in the backend which have been resolved.
1020        """
1021        return self._client.post(":recover", TaskResponse, dict(id=task_id))
1022
1023    def archive(self, task_id: int) -> TaskResponse:
1024        """Archive the task.
1025
1026        This will make the task disappear from regular task listings. It will not remove data that has been
1027        produced by the task. If data removal is required, use the `data_load_id` for the task's results
1028        and call the `/data/load:undo` endpoint separately.
1029        """
1030        return self._client.post(":archive", TaskResponse, dict(id=task_id))
1031
1032    def unarchive(self, task_id: int) -> TaskResponse:
1033        """Unarchive the task."""
1034        return self._client.post(":unarchive", TaskResponse, dict(id=task_id))
1035
1036    def wait(self, task_id: int, timeout: float = 60.0) -> TaskResponse:
1037        start = time.monotonic()
1038
1039        while time.monotonic() - start < timeout:
1040            try:
1041                task = self.get(task_id)
1042                if task.state == TaskState.FAILED:
1043                    raise ValueError(f"Task {task_id} failed: {task.errors}")
1044                if task.state == TaskState.CANCELLED:
1045                    raise ValueError(f"Task {task_id} was cancelled")
1046                if task.state == TaskState.COMPLETED:
1047                    break
1048            except httpx.TransportError:
1049                logger.warning("Transport error while waiting for task, retrying...")
1050            except ClientError as e:
1051                if 500 <= e.status_code < 600:
1052                    logger.warning("Internal server error while waiting for task, retrying...")
1053                else:
1054                    raise
1055
1056            time.sleep(3)
1057        print()
1058        return self.get(task_id)
1059
1060
1061class CustomPredictorClient:
1062    """Custom predictors can be used instead of or in addition to predictors trained on the Cradle platform.
1063
1064    A custom predictor is a function that, given a sequence, provides one or more scores for that sequence.
1065    These APIs allow to upload a Docker image that contains a custom predictor to the Cradle platform.
1066
1067    If you would like to use custom predictors, please get in touch with your customer success representative.
1068    """
1069
1070    def __init__(self, client: HttpClient):
1071        self._client = client.with_prefix("/customPredictor")
1072
1073    def get_current(self, name: str) -> CustomPredictorResponse:
1074        """Return the custom predictor with the most recent version"""
1075        return self._client.get(":get", CustomPredictorResponse, params={"name": name})
1076
1077    def get_version(self, version_id: int) -> CustomPredictorResponse:
1078        """Return the custom predictor with the given version ID"""
1079        return self._client.get(":getVersion", CustomPredictorResponse, params={"version_id": version_id})
1080
1081    def list(self) -> Generator[CustomPredictorResponse, None, None]:
1082        """List the active and being-provisioned versions of every predictor in the workspace."""
1083        opts = ListOptions()
1084        while True:
1085            resp = self._client.get(":list", ListCustomPredictorResponse, params=opts.model_dump())
1086            yield from resp.items
1087            if resp.cursor is None:
1088                return
1089            opts.cursor = resp.cursor
1090
1091    def create(
1092        self, cp: CustomPredictorContainerCreate, container_image: Path, upload_timeout: float = 300
1093    ) -> CustomPredictorContainerCreateResponse:
1094        """Create a new custom predictor.
1095
1096        The newly created predictor will initially be in `PROVISIONING` state.
1097        This method provides the container image at the given path on the
1098        filesystem, which should be a `tar` file as created by `docker save`.
1099
1100        The returned custom predictor can be polled regularly (using the
1101        `get_version()` method) until it transitions to either `ACTIVE` or
1102        `FAILED` state. Once it is in `ACTIVE` state it can be used. When it
1103        is in `FAILED` state, it means the uploaded container image could not
1104        be used. In this case, the `error` field will have more information and
1105        a new upload can be attempted by using the `update()` method.
1106        """
1107        with container_image.open("rb") as f:
1108            client = self._client
1109            predictor = client.post(":create", CustomPredictorCreateResponse, json=cp)
1110
1111            # Upload to a signed URL - use a fresh client so the API client's auth isn't attached.
1112            response = httpx.put(
1113                predictor.root.upload_url,
1114                headers=predictor.root.upload_headers,
1115                content=f,
1116                timeout=upload_timeout,
1117                follow_redirects=True,
1118            )
1119        if not response.is_success:
1120            raise ClientError(response.status_code, response.text, [])
1121
1122        return predictor.root
1123
1124    def update(
1125        self, name: str, cp: CustomPredictorContainerUpdate, container_image: Path, upload_timeout: float = 300
1126    ) -> CustomPredictorContainerCreateResponse:
1127        """Create a new version for a custom predictor.
1128
1129        See the `create()` method for how to provide a new container image for
1130        the updated custom predictor.
1131        """
1132        with container_image.open("rb") as f:
1133            client = self._client
1134            predictor = client.post(":update", CustomPredictorCreateResponse, params={"name": name}, json=cp)
1135
1136            # Upload to a signed URL - use a fresh client so the API client's auth isn't attached.
1137            response = httpx.put(
1138                predictor.root.upload_url,
1139                headers=predictor.root.upload_headers,
1140                content=f,
1141                timeout=upload_timeout,
1142                follow_redirects=True,
1143            )
1144
1145        if not response.is_success:
1146            raise ClientError(response.status_code, response.text, [])
1147
1148        return predictor.root
1149
1150    def delete(self, name: str) -> None:
1151        """Delete the custom predictor with the given name
1152
1153        This transitions all versions of this custom predictor (the initial one
1154        created with the `:create` endpoint and all subsequent versions created with
1155        the `:update` endpoint) to the `INACTIVE` state, unless it is already in
1156        one of the `INACTIVE` or `FAILED` states.
1157        """
1158        self._client.post(":delete", response_type=None, params={"name": name})
logger = <Logger cradle.sdk.client (WARNING)>
class HttpClient:
 74class HttpClient:
 75    def __init__(
 76        self,
 77        prefix: str,
 78        base_url: str,
 79        auth: httpx.Auth | None = None,
 80        timeout: float = 60.0,
 81        user_agent: str | None = None,
 82        workspace: str | None = None,
 83        use_keyring: bool = True,
 84    ):
 85        base_url = base_url.rstrip("/")
 86        user_agent = user_agent or f"cradle-sdk-python/{pkg_version('cradle-sdk')}"
 87        headers = {
 88            "Accept": "application/json",
 89            "User-Agent": user_agent,
 90        }
 91        client = httpx.Client(auth=auth, headers=headers, timeout=timeout, follow_redirects=True)
 92        if auth is None:
 93            client.auth = DeviceAuth.from_strategy(
 94                client=client, workspace=workspace, base_url=base_url, use_keyring=use_keyring
 95            )
 96
 97        self.http_client = client
 98        self.prefix = prefix.strip("/")
 99        self.base_url = base_url.rstrip("/")
100        self.workspace = workspace
101
102    def with_prefix(self, prefix: str) -> "HttpClient":
103        c = copy.copy(self)
104        c.prefix = f"{self.prefix}/{prefix.strip('/')}".strip("/")
105        return c
106
107    def url(self, path: str) -> str:
108        """Generate a URL for a given path.
109
110        This follows slightly weird rules, specific to how our API is designed.
111
112        The overall goal here is to make the URL generation loose with what it accepts, to never
113        be confused by whether or not there are unexpected slashes in any part of the url. It's a very
114        "do what I mean" approach.
115
116        - "path" is never interpreted as an absolute path, it is always relative to the prefix
117        - if the path starts with ":", it is a verb endpoint and should be appended to the url + prefix, not assumed to be a subpath
118        - everything else is assumed to be a subpath and should be appended to the url + prefix
119        """
120
121        combined_path = f"{self.prefix}{path}"
122        if not path.startswith(":") and not path.startswith("/"):
123            combined_path = f"{self.prefix}/{path}"
124
125        # This special case is needed in case we have a ":verb" endpoint at the root of the API
126        # like "https://cradle.bio/:list" - otherwise the logic below would generate
127        # "https://cradle.bio:list" which is wrong.
128        parsed_url = urlparse(self.base_url)
129        base_url_has_path = bool(parsed_url.path and parsed_url.path not in {"", "/"})
130        if combined_path.startswith(":") and base_url_has_path:
131            return f"{self.base_url}{combined_path}"
132
133        return f"{self.base_url}/{combined_path.lstrip('/')}"
134
135    def request(
136        self,
137        method: str,
138        path: str,
139        json: dict[str, Any] | BaseModel | None = None,
140        params: dict[str, Any] | None = None,
141        **kwargs,
142    ) -> httpx.Response:
143        if params is not None:
144            # HTTPX will send "...&foo=&...", which FastAPI would interpret as an empty string rather than None.
145            kwargs["params"] = {k: v for k, v in params.items() if v is not None}
146        if isinstance(json, BaseModel):
147            json = json.model_dump(mode="json", by_alias=True)
148        if json is not None:
149            kwargs["json"] = json
150
151        return self.http_client.request(method, self.url(path), **kwargs)
152
153    def get(self, path: str, response_type: type[T] | Any | None, params: dict[str, Any] | None = None, **kwargs) -> T:
154        return self._handle_response(self.request("GET", path, params=params, **kwargs), response_type)
155
156    def post(self, path: str, response_type: type[T] | Any | None, params: dict[str, Any] | None = None, **kwargs) -> T:
157        return self._handle_response(self.request("POST", path, params=params, **kwargs), response_type)
158
159    def _handle_response(
160        self, response: httpx.Response, response_type: type[T] | Iterator[str] | Iterator[bytes] | None
161    ) -> Any:
162        if not response.is_success:
163            self.handle_error_response(response)
164        if response_type is None:
165            return None
166
167        ct = response.headers.get("content-type")
168        if ct is None:
169            raise ValueError("content type header missing")
170
171        ct = ct.split(";")[0].strip()
172        if ct == "application/json":
173            return TypeAdapter(response_type).validate_python(response.json())
174        if ct == "application/x-ndjson" and response_type == Iterator[str]:
175            return response.iter_lines()
176        if ct == "application/octet-stream" and response_type == Iterator[bytes]:
177            return response.iter_bytes()
178        raise ValueError(f"Unsupported content type {ct}")
179
180    def handle_error_response(self, response: httpx.Response) -> NoReturn:
181        auth = self.http_client.auth
182        if (
183            response.status_code == 403
184            and self.workspace is not None
185            and isinstance(auth, DeviceAuth)
186            and auth.authorized_workspace_id != self.workspace
187        ):
188            auth.suggest_logout(self.workspace)
189        try:
190            body = response.json()
191            error_msg = body.get("detail", response.text)
192            if (error_id := body.get("error_id")) is not None:
193                error_msg = f"{error_msg} (error ID: {error_id})"
194            errors = body.get("errors", [])
195        except Exception:  # noqa: BLE001
196            error_msg = response.text
197            errors = []
198        raise ClientError(response.status_code, error_msg, errors=errors)
HttpClient( prefix: str, base_url: str, auth: httpx.Auth | None = None, timeout: float = 60.0, user_agent: str | None = None, workspace: str | None = None, use_keyring: bool = True)
 75    def __init__(
 76        self,
 77        prefix: str,
 78        base_url: str,
 79        auth: httpx.Auth | None = None,
 80        timeout: float = 60.0,
 81        user_agent: str | None = None,
 82        workspace: str | None = None,
 83        use_keyring: bool = True,
 84    ):
 85        base_url = base_url.rstrip("/")
 86        user_agent = user_agent or f"cradle-sdk-python/{pkg_version('cradle-sdk')}"
 87        headers = {
 88            "Accept": "application/json",
 89            "User-Agent": user_agent,
 90        }
 91        client = httpx.Client(auth=auth, headers=headers, timeout=timeout, follow_redirects=True)
 92        if auth is None:
 93            client.auth = DeviceAuth.from_strategy(
 94                client=client, workspace=workspace, base_url=base_url, use_keyring=use_keyring
 95            )
 96
 97        self.http_client = client
 98        self.prefix = prefix.strip("/")
 99        self.base_url = base_url.rstrip("/")
100        self.workspace = workspace
http_client
prefix
base_url
workspace
def with_prefix(self, prefix: str) -> HttpClient:
102    def with_prefix(self, prefix: str) -> "HttpClient":
103        c = copy.copy(self)
104        c.prefix = f"{self.prefix}/{prefix.strip('/')}".strip("/")
105        return c
def url(self, path: str) -> str:
107    def url(self, path: str) -> str:
108        """Generate a URL for a given path.
109
110        This follows slightly weird rules, specific to how our API is designed.
111
112        The overall goal here is to make the URL generation loose with what it accepts, to never
113        be confused by whether or not there are unexpected slashes in any part of the url. It's a very
114        "do what I mean" approach.
115
116        - "path" is never interpreted as an absolute path, it is always relative to the prefix
117        - if the path starts with ":", it is a verb endpoint and should be appended to the url + prefix, not assumed to be a subpath
118        - everything else is assumed to be a subpath and should be appended to the url + prefix
119        """
120
121        combined_path = f"{self.prefix}{path}"
122        if not path.startswith(":") and not path.startswith("/"):
123            combined_path = f"{self.prefix}/{path}"
124
125        # This special case is needed in case we have a ":verb" endpoint at the root of the API
126        # like "https://cradle.bio/:list" - otherwise the logic below would generate
127        # "https://cradle.bio:list" which is wrong.
128        parsed_url = urlparse(self.base_url)
129        base_url_has_path = bool(parsed_url.path and parsed_url.path not in {"", "/"})
130        if combined_path.startswith(":") and base_url_has_path:
131            return f"{self.base_url}{combined_path}"
132
133        return f"{self.base_url}/{combined_path.lstrip('/')}"

Generate a URL for a given path.

This follows slightly weird rules, specific to how our API is designed.

The overall goal here is to make the URL generation loose with what it accepts, to never be confused by whether or not there are unexpected slashes in any part of the url. It's a very "do what I mean" approach.

  • "path" is never interpreted as an absolute path, it is always relative to the prefix
  • if the path starts with ":", it is a verb endpoint and should be appended to the url + prefix, not assumed to be a subpath
  • everything else is assumed to be a subpath and should be appended to the url + prefix
def request( self, method: str, path: str, json: dict[str, typing.Any] | pydantic.main.BaseModel | None = None, params: dict[str, typing.Any] | None = None, **kwargs) -> httpx.Response:
135    def request(
136        self,
137        method: str,
138        path: str,
139        json: dict[str, Any] | BaseModel | None = None,
140        params: dict[str, Any] | None = None,
141        **kwargs,
142    ) -> httpx.Response:
143        if params is not None:
144            # HTTPX will send "...&foo=&...", which FastAPI would interpret as an empty string rather than None.
145            kwargs["params"] = {k: v for k, v in params.items() if v is not None}
146        if isinstance(json, BaseModel):
147            json = json.model_dump(mode="json", by_alias=True)
148        if json is not None:
149            kwargs["json"] = json
150
151        return self.http_client.request(method, self.url(path), **kwargs)
def get( self, path: str, response_type: type[~T] | typing.Any | None, params: dict[str, typing.Any] | None = None, **kwargs) -> ~T:
153    def get(self, path: str, response_type: type[T] | Any | None, params: dict[str, Any] | None = None, **kwargs) -> T:
154        return self._handle_response(self.request("GET", path, params=params, **kwargs), response_type)
def post( self, path: str, response_type: type[~T] | typing.Any | None, params: dict[str, typing.Any] | None = None, **kwargs) -> ~T:
156    def post(self, path: str, response_type: type[T] | Any | None, params: dict[str, Any] | None = None, **kwargs) -> T:
157        return self._handle_response(self.request("POST", path, params=params, **kwargs), response_type)
def handle_error_response(self, response: httpx.Response) -> NoReturn:
180    def handle_error_response(self, response: httpx.Response) -> NoReturn:
181        auth = self.http_client.auth
182        if (
183            response.status_code == 403
184            and self.workspace is not None
185            and isinstance(auth, DeviceAuth)
186            and auth.authorized_workspace_id != self.workspace
187        ):
188            auth.suggest_logout(self.workspace)
189        try:
190            body = response.json()
191            error_msg = body.get("detail", response.text)
192            if (error_id := body.get("error_id")) is not None:
193                error_msg = f"{error_msg} (error ID: {error_id})"
194            errors = body.get("errors", [])
195        except Exception:  # noqa: BLE001
196            error_msg = response.text
197            errors = []
198        raise ClientError(response.status_code, error_msg, errors=errors)
class DisabledAuth(httpx.Auth):
201class DisabledAuth(httpx.Auth):
202    """Auth implementation to use when authentication should be disabled"""
203
204    def __init__(self, workspace: str | None = None):
205        self._workspace_name = workspace
206
207    @override
208    def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
209        claims = {
210            "urn:cradle:email": "john-doe@cradle.bio",
211            "urn:cradle:user_id": "john-doe",
212            "urn:cradle:full_name": "John Doe",
213            "urn:cradle:workspace_id": self._workspace_name,
214        }
215
216        token = jwt.encode(claims, key="this_key_is_not_verified_by_the_backend")
217
218        request.headers["Authorization"] = f"Bearer ca_{token}"
219        yield request

Auth implementation to use when authentication should be disabled

DisabledAuth(workspace: str | None = None)
204    def __init__(self, workspace: str | None = None):
205        self._workspace_name = workspace
@override
def auth_flow( self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, NoneType]:
207    @override
208    def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
209        claims = {
210            "urn:cradle:email": "john-doe@cradle.bio",
211            "urn:cradle:user_id": "john-doe",
212            "urn:cradle:full_name": "John Doe",
213            "urn:cradle:workspace_id": self._workspace_name,
214        }
215
216        token = jwt.encode(claims, key="this_key_is_not_verified_by_the_backend")
217
218        request.headers["Authorization"] = f"Bearer ca_{token}"
219        yield request

Execute the authentication flow.

To dispatch a request, yield it:

yield request

The client will .send() the response back into the flow generator. You can access it like so:

response = yield request

A return (or reaching the end of the generator) will result in the client returning the last response obtained from the server.

You can dispatch as many requests as is necessary.

API_URL = 'https://api.cradle.bio'
class Client:
225class Client:
226    """The workspace API provides functionality for retrieving information about your workspace.
227
228    A workspace is the home for all data, projects and tasks for an organization. By
229    design no information can be shared across workspaces, they are the top-level
230    concept in the Cradle API.
231
232    Workspace creation and management is only available to Cradle administrators.
233    """
234
235    def __init__(
236        self,
237        workspace: str,
238        auth: httpx.Auth | None = None,
239        base_url: str = API_URL,
240        timeout: int = 60,
241        user_agent: str | None = None,
242        use_keyring: bool = True,
243    ):
244        self._base_client = HttpClient(
245            prefix="/",
246            base_url=base_url,
247            auth=auth,
248            timeout=timeout,
249            user_agent=user_agent,
250            workspace=workspace,
251            use_keyring=use_keyring,
252        )
253        self._client = self._base_client.with_prefix(f"/v2/workspace/{workspace}/")
254        self._workspace = workspace
255
256    def workspace(self) -> WorkspaceResponse:
257        return self._base_client.get("/v2/workspace:get", WorkspaceResponse, dict(name=self._workspace))
258
259    @property
260    def project(self) -> "ProjectClient":
261        return ProjectClient(self._client)
262
263    @property
264    def round(self) -> "RoundClient":
265        return RoundClient(self._client)
266
267    @property
268    def data(self) -> "DataClient":
269        return DataClient(self._client)
270
271    @property
272    def task(self) -> "TaskClient":
273        return TaskClient(self._client)
274
275    @property
276    def custom_predictor(self) -> "CustomPredictorClient":
277        return CustomPredictorClient(self._client)

The workspace API provides functionality for retrieving information about your workspace.

A workspace is the home for all data, projects and tasks for an organization. By design no information can be shared across workspaces, they are the top-level concept in the Cradle API.

Workspace creation and management is only available to Cradle administrators.

Client( workspace: str, auth: httpx.Auth | None = None, base_url: str = 'https://api.cradle.bio', timeout: int = 60, user_agent: str | None = None, use_keyring: bool = True)
235    def __init__(
236        self,
237        workspace: str,
238        auth: httpx.Auth | None = None,
239        base_url: str = API_URL,
240        timeout: int = 60,
241        user_agent: str | None = None,
242        use_keyring: bool = True,
243    ):
244        self._base_client = HttpClient(
245            prefix="/",
246            base_url=base_url,
247            auth=auth,
248            timeout=timeout,
249            user_agent=user_agent,
250            workspace=workspace,
251            use_keyring=use_keyring,
252        )
253        self._client = self._base_client.with_prefix(f"/v2/workspace/{workspace}/")
254        self._workspace = workspace
def workspace(self) -> cradle.sdk.types.workspace.WorkspaceResponse:
256    def workspace(self) -> WorkspaceResponse:
257        return self._base_client.get("/v2/workspace:get", WorkspaceResponse, dict(name=self._workspace))
project: ProjectClient
259    @property
260    def project(self) -> "ProjectClient":
261        return ProjectClient(self._client)
round: RoundClient
263    @property
264    def round(self) -> "RoundClient":
265        return RoundClient(self._client)
data: DataClient
267    @property
268    def data(self) -> "DataClient":
269        return DataClient(self._client)
task: TaskClient
271    @property
272    def task(self) -> "TaskClient":
273        return TaskClient(self._client)
custom_predictor: CustomPredictorClient
275    @property
276    def custom_predictor(self) -> "CustomPredictorClient":
277        return CustomPredictorClient(self._client)
class ProjectClient:
280class ProjectClient:
281    """The projects API provides functionality for managing and interacting with
282    projects in a workspace.
283
284    Projects organize tasks in a workspace and represent the work involved in
285    optimizing a specific protein or achieving a specific goal. Data is currently
286    isolated to a specific project, but in the future we will support cross project
287    data sharing within a workspace.
288    """
289
290    def __init__(self, client: HttpClient):
291        self._client = client.with_prefix("/project")
292
293    def get(self, project_id: int) -> ProjectResponse:
294        """Get a project by `ID`."""
295        return self._client.get(":get", ProjectResponse, dict(id=project_id))
296
297    def list(self) -> Generator[ProjectResponse, None, None]:
298        """List all projects in the workspace."""
299        opts = ListOptions()
300        while True:
301            resp = self._client.get(":list", ListProjectResponse, params=opts.model_dump())
302            yield from resp.items
303            if resp.cursor is None:
304                return
305            opts.cursor = resp.cursor
306
307    def get_by_name(self, name: str) -> ProjectResponse:
308        """Get a project by name. Raises ValueError if not found."""
309        for project in self.list():
310            if project.name == name:
311                return project
312        raise ValueError(f"Project with name '{name}' not found")
313
314    def create(self, project: ProjectCreate) -> ProjectResponse:
315        """Create a new project in the workspace."""
316        return self._client.post(":create", ProjectResponse, json=project)
317
318    def update(self, project_id: int, project: ProjectCreate) -> ProjectResponse:
319        """Update a project's information."""
320        return self._client.post(":update", ProjectResponse, dict(id=project_id), json=project)
321
322    def archive(self, project_id: int) -> ProjectResponse:
323        """Archive a project.
324
325        The data from the project will still be available.
326        It will not be possible to create new tasks, data loads, etc. in the project.
327        """
328        return self._client.post(":archive", ProjectResponse, dict(id=project_id))
329
330    def unarchive(self, project_id: int) -> ProjectResponse:
331        """Unarchive a previously archived project.
332
333        Returns an HTTP 422 `Unprocessable Entity` error if the project is not archived.
334        """
335        return self._client.post(":unarchive", ProjectResponse, dict(id=project_id))

The projects API provides functionality for managing and interacting with projects in a workspace.

Projects organize tasks in a workspace and represent the work involved in optimizing a specific protein or achieving a specific goal. Data is currently isolated to a specific project, but in the future we will support cross project data sharing within a workspace.

ProjectClient(client: HttpClient)
290    def __init__(self, client: HttpClient):
291        self._client = client.with_prefix("/project")
def get(self, project_id: int) -> cradle.sdk.types.workspace.ProjectResponse:
293    def get(self, project_id: int) -> ProjectResponse:
294        """Get a project by `ID`."""
295        return self._client.get(":get", ProjectResponse, dict(id=project_id))

Get a project by ID.

def list( self) -> Generator[cradle.sdk.types.workspace.ProjectResponse, NoneType, NoneType]:
297    def list(self) -> Generator[ProjectResponse, None, None]:
298        """List all projects in the workspace."""
299        opts = ListOptions()
300        while True:
301            resp = self._client.get(":list", ListProjectResponse, params=opts.model_dump())
302            yield from resp.items
303            if resp.cursor is None:
304                return
305            opts.cursor = resp.cursor

List all projects in the workspace.

def get_by_name(self, name: str) -> cradle.sdk.types.workspace.ProjectResponse:
307    def get_by_name(self, name: str) -> ProjectResponse:
308        """Get a project by name. Raises ValueError if not found."""
309        for project in self.list():
310            if project.name == name:
311                return project
312        raise ValueError(f"Project with name '{name}' not found")

Get a project by name. Raises ValueError if not found.

314    def create(self, project: ProjectCreate) -> ProjectResponse:
315        """Create a new project in the workspace."""
316        return self._client.post(":create", ProjectResponse, json=project)

Create a new project in the workspace.

def update( self, project_id: int, project: cradle.sdk.types.workspace.ProjectCreate) -> cradle.sdk.types.workspace.ProjectResponse:
318    def update(self, project_id: int, project: ProjectCreate) -> ProjectResponse:
319        """Update a project's information."""
320        return self._client.post(":update", ProjectResponse, dict(id=project_id), json=project)

Update a project's information.

def archive(self, project_id: int) -> cradle.sdk.types.workspace.ProjectResponse:
322    def archive(self, project_id: int) -> ProjectResponse:
323        """Archive a project.
324
325        The data from the project will still be available.
326        It will not be possible to create new tasks, data loads, etc. in the project.
327        """
328        return self._client.post(":archive", ProjectResponse, dict(id=project_id))

Archive a project.

The data from the project will still be available. It will not be possible to create new tasks, data loads, etc. in the project.

def unarchive(self, project_id: int) -> cradle.sdk.types.workspace.ProjectResponse:
330    def unarchive(self, project_id: int) -> ProjectResponse:
331        """Unarchive a previously archived project.
332
333        Returns an HTTP 422 `Unprocessable Entity` error if the project is not archived.
334        """
335        return self._client.post(":unarchive", ProjectResponse, dict(id=project_id))

Unarchive a previously archived project.

Returns an HTTP 422 Unprocessable Entity error if the project is not archived.

class RoundClient:
338class RoundClient:
339    """The rounds API provides functionality for managing and interacting with rounds within a project.
340
341    Rounds are a way to organize the experiments within a project. Commonly, a round represents all
342    in-vitro experiments related to a specific sub-goal or timeframe of a project.
343    While rounds are typically ordered sequentially, multiple rounds can exist in parallel (for example, when
344    testing multiple design methods in parallel).
345
346    Tasks, data loads, and reports can be (but don't have to be!) assigned to a round.
347    """
348
349    def __init__(self, client: HttpClient):
350        self._client = client.with_prefix("/round")
351
352    def get(self, round_id: int) -> RoundResponse:
353        """Get a round by ID."""
354        return self._client.get(":get", RoundResponse, dict(id=round_id))
355
356    def list(self, project_id: int | None = None) -> Generator[RoundResponse, None, None]:
357        """List all rounds in the project."""
358        params = {}
359        if project_id is not None:
360            params["project_id"] = project_id
361
362        opts = ListOptions()
363        while True:
364            resp = self._client.get(
365                ":list",
366                ListRoundResponse,
367                params={**opts.model_dump(), **params},
368            )
369            yield from resp.items
370            if resp.cursor is None:
371                return
372            opts.cursor = resp.cursor
373
374    def get_by_name(self, project_id: int, name: str) -> RoundResponse:
375        """Get a round by name within a specific project. Raises ValueError if not found."""
376        for round_ in self.list(project_id=project_id):
377            if round_.name == name:
378                return round_
379        raise ValueError(f"Round with name '{name}' not found in project {project_id}")
380
381    def create(self, round_: RoundCreate) -> RoundResponse:
382        """Create a new round in a project."""
383        return self._client.post(":create", RoundResponse, json=round_)
384
385    def update(self, round_id: int, round_: RoundCreate) -> RoundResponse:
386        """Update a round's name and description."""
387        return self._client.post(":update", RoundResponse, dict(id=round_id), json=round_)
388
389    def archive(self, round_id: int) -> RoundResponse:
390        """Archive a round.
391
392        The data from the round will still be available.
393        It will not be possible to create new tasks, data loads, etc. in the round.
394        """
395        return self._client.post(":archive", RoundResponse, dict(id=round_id))
396
397    def unarchive(self, round_id: int) -> RoundResponse:
398        """Unarchive a previously archived round.
399
400        Returns an HTTP 422 `Unprocessable Entity` error if the round is not archived.
401        """
402        return self._client.post(":unarchive", RoundResponse, dict(id=round_id))

The rounds API provides functionality for managing and interacting with rounds within a project.

Rounds are a way to organize the experiments within a project. Commonly, a round represents all in-vitro experiments related to a specific sub-goal or timeframe of a project. While rounds are typically ordered sequentially, multiple rounds can exist in parallel (for example, when testing multiple design methods in parallel).

Tasks, data loads, and reports can be (but don't have to be!) assigned to a round.

RoundClient(client: HttpClient)
349    def __init__(self, client: HttpClient):
350        self._client = client.with_prefix("/round")
def get(self, round_id: int) -> cradle.sdk.types.workspace.RoundResponse:
352    def get(self, round_id: int) -> RoundResponse:
353        """Get a round by ID."""
354        return self._client.get(":get", RoundResponse, dict(id=round_id))

Get a round by ID.

def list( self, project_id: int | None = None) -> Generator[cradle.sdk.types.workspace.RoundResponse, NoneType, NoneType]:
356    def list(self, project_id: int | None = None) -> Generator[RoundResponse, None, None]:
357        """List all rounds in the project."""
358        params = {}
359        if project_id is not None:
360            params["project_id"] = project_id
361
362        opts = ListOptions()
363        while True:
364            resp = self._client.get(
365                ":list",
366                ListRoundResponse,
367                params={**opts.model_dump(), **params},
368            )
369            yield from resp.items
370            if resp.cursor is None:
371                return
372            opts.cursor = resp.cursor

List all rounds in the project.

def get_by_name( self, project_id: int, name: str) -> cradle.sdk.types.workspace.RoundResponse:
374    def get_by_name(self, project_id: int, name: str) -> RoundResponse:
375        """Get a round by name within a specific project. Raises ValueError if not found."""
376        for round_ in self.list(project_id=project_id):
377            if round_.name == name:
378                return round_
379        raise ValueError(f"Round with name '{name}' not found in project {project_id}")

Get a round by name within a specific project. Raises ValueError if not found.

381    def create(self, round_: RoundCreate) -> RoundResponse:
382        """Create a new round in a project."""
383        return self._client.post(":create", RoundResponse, json=round_)

Create a new round in a project.

def update( self, round_id: int, round_: cradle.sdk.types.workspace.RoundCreate) -> cradle.sdk.types.workspace.RoundResponse:
385    def update(self, round_id: int, round_: RoundCreate) -> RoundResponse:
386        """Update a round's name and description."""
387        return self._client.post(":update", RoundResponse, dict(id=round_id), json=round_)

Update a round's name and description.

def archive(self, round_id: int) -> cradle.sdk.types.workspace.RoundResponse:
389    def archive(self, round_id: int) -> RoundResponse:
390        """Archive a round.
391
392        The data from the round will still be available.
393        It will not be possible to create new tasks, data loads, etc. in the round.
394        """
395        return self._client.post(":archive", RoundResponse, dict(id=round_id))

Archive a round.

The data from the round will still be available. It will not be possible to create new tasks, data loads, etc. in the round.

def unarchive(self, round_id: int) -> cradle.sdk.types.workspace.RoundResponse:
397    def unarchive(self, round_id: int) -> RoundResponse:
398        """Unarchive a previously archived round.
399
400        Returns an HTTP 422 `Unprocessable Entity` error if the round is not archived.
401        """
402        return self._client.post(":unarchive", RoundResponse, dict(id=round_id))

Unarchive a previously archived round.

Returns an HTTP 422 Unprocessable Entity error if the round is not archived.

class DataClient:
405class DataClient:
406    """The data API provides functionality for managing and interacting with data in a workspace.
407
408    Data comes in the form of *tables* and *artifacts*.
409    *Tables* are used to store row-based data, such as measurements, sequences, and other structured data.
410    *Artifacts* are used to store the results of tasks, such as machine learning models, predictions, and other outputs.
411
412    While tables can be created and uploaded by users, artifacts are only produced by tasks and cannot be uploaded directly.
413
414    Artifacts and table rows (via their data load source) have a context specified. This context refers to the
415    origin of the data and is used in other parts of the API to determine data visibility, e.g. to
416    isolate data between projects.
417
418    See the [Artifacts](#tag/Artifacts) API endpoints for more details on artifact data.
419
420    See the [Tables](#tag/Tables) API endpoints for more details on tabular data.
421    """
422
423    def __init__(self, client: HttpClient):
424        self._client = client.with_prefix("/data")
425
426    @property
427    def artifact(self) -> "ArtifactClient":
428        return ArtifactClient(self._client)
429
430    @property
431    def table(self) -> "TableClient":
432        return TableClient(self._client)
433
434    @property
435    def load(self) -> "DataLoadClient":
436        return DataLoadClient(self._client)
437
438    def list_versions(
439        self,
440        project_id: int | None = None,
441        table_id: int | None = None,
442    ) -> Generator[DataVersionResponse, None, None]:
443        warnings.warn(
444            "DataClient.list_versions() is deprecated. Use snapshots instead to work with older state of data.",
445            category=DeprecationWarning,
446            stacklevel=2,
447        )
448
449        params = {}
450        if project_id is not None:
451            params["project_id"] = project_id
452        if table_id is not None:
453            params["table_id"] = table_id
454
455        opts = ListOptions()
456        while True:
457            resp = self._client.get("/version:list", ListDataVersionResponse, params={**params, **opts.model_dump()})
458            yield from resp.items
459            if resp.cursor is None:
460                return
461            opts.cursor = resp.cursor

The data API provides functionality for managing and interacting with data in a workspace.

Data comes in the form of tables and artifacts. Tables are used to store row-based data, such as measurements, sequences, and other structured data. Artifacts are used to store the results of tasks, such as machine learning models, predictions, and other outputs.

While tables can be created and uploaded by users, artifacts are only produced by tasks and cannot be uploaded directly.

Artifacts and table rows (via their data load source) have a context specified. This context refers to the origin of the data and is used in other parts of the API to determine data visibility, e.g. to isolate data between projects.

See the Artifacts API endpoints for more details on artifact data.

See the Tables API endpoints for more details on tabular data.

DataClient(client: HttpClient)
423    def __init__(self, client: HttpClient):
424        self._client = client.with_prefix("/data")
artifact: ArtifactClient
426    @property
427    def artifact(self) -> "ArtifactClient":
428        return ArtifactClient(self._client)
table: TableClient
430    @property
431    def table(self) -> "TableClient":
432        return TableClient(self._client)
load: DataLoadClient
434    @property
435    def load(self) -> "DataLoadClient":
436        return DataLoadClient(self._client)
def list_versions( self, project_id: int | None = None, table_id: int | None = None) -> Generator[cradle.sdk.types.data.DataVersionResponse, NoneType, NoneType]:
438    def list_versions(
439        self,
440        project_id: int | None = None,
441        table_id: int | None = None,
442    ) -> Generator[DataVersionResponse, None, None]:
443        warnings.warn(
444            "DataClient.list_versions() is deprecated. Use snapshots instead to work with older state of data.",
445            category=DeprecationWarning,
446            stacklevel=2,
447        )
448
449        params = {}
450        if project_id is not None:
451            params["project_id"] = project_id
452        if table_id is not None:
453            params["table_id"] = table_id
454
455        opts = ListOptions()
456        while True:
457            resp = self._client.get("/version:list", ListDataVersionResponse, params={**params, **opts.model_dump()})
458            yield from resp.items
459            if resp.cursor is None:
460                return
461            opts.cursor = resp.cursor
class ArtifactClient:
464class ArtifactClient:
465    """Artifacts are produced by tasks and can be referenced as inputs to other tasks. They can generally
466    not be uploaded and downloaded directly. They have a well-defined type that determines for which
467    purpose they can be used.
468    """
469
470    def __init__(self, client: HttpClient):
471        self._client = client.with_prefix("/artifact")
472
473    def get(self, artifact_id: int) -> ArtifactResponse:
474        """Get information about an artifact."""
475        return self._client.get(":get", ArtifactResponse, dict(id=artifact_id))
476
477    def list(
478        self,
479        project_id: int | None = None,
480        round_id: int | None = None,
481    ) -> Generator[ArtifactResponse, None, None]:
482        """List artifacts in the workspace."""
483        opts = ListOptions()
484
485        params = {}
486        if project_id is not None:
487            params["project_id"] = project_id
488        if round_id is not None:
489            params["round_id"] = round_id
490
491        while True:
492            resp = self._client.get(
493                ":list",
494                ListArtifactResponse,
495                params={**opts.model_dump(), **params},
496            )
497            yield from resp.items
498            if resp.cursor is None:
499                return
500            opts.cursor = resp.cursor

Artifacts are produced by tasks and can be referenced as inputs to other tasks. They can generally not be uploaded and downloaded directly. They have a well-defined type that determines for which purpose they can be used.

ArtifactClient(client: HttpClient)
470    def __init__(self, client: HttpClient):
471        self._client = client.with_prefix("/artifact")
def get(self, artifact_id: int) -> cradle.sdk.types.data.ArtifactResponse:
473    def get(self, artifact_id: int) -> ArtifactResponse:
474        """Get information about an artifact."""
475        return self._client.get(":get", ArtifactResponse, dict(id=artifact_id))

Get information about an artifact.

def list( self, project_id: int | None = None, round_id: int | None = None) -> Generator[cradle.sdk.types.data.ArtifactResponse, NoneType, NoneType]:
477    def list(
478        self,
479        project_id: int | None = None,
480        round_id: int | None = None,
481    ) -> Generator[ArtifactResponse, None, None]:
482        """List artifacts in the workspace."""
483        opts = ListOptions()
484
485        params = {}
486        if project_id is not None:
487            params["project_id"] = project_id
488        if round_id is not None:
489            params["round_id"] = round_id
490
491        while True:
492            resp = self._client.get(
493                ":list",
494                ListArtifactResponse,
495                params={**opts.model_dump(), **params},
496            )
497            yield from resp.items
498            if resp.cursor is None:
499                return
500            opts.cursor = resp.cursor

List artifacts in the workspace.

class TableClient:
536class TableClient:
537    """Tables store row-based data with a well-defined column schema. Such data may come from spreadsheets
538    or other tabular data stores.
539
540    Each table is created with a well-defined column schema, which can include primitive columns such
541    as numbers and strings, as well as nested struct columns and arrays.
542    A table's schema can be extended but existing columns cannot generally be modified or removed.
543
544    Table data as well as it's column schema itself are version controlled. Each change to the contained
545    rows is tracked as a data version.
546    Tables can be queried at any historic version unless some destructive change has been made, such as
547    hard deletion of historic data for compliance reasons.
548
549    Because tables are versioned, each row is immutable. This means that it is not possible to change individual
550    values in a row. Instead, the data load that added the row can be undone and the rows can be loaded
551    again with the changed data.
552
553    See the [Data Loads](#tag/Data-Loads) API endpoints on how to add and remove data to tables.
554    """
555
556    def __init__(self, client: HttpClient):
557        self._client = client.with_prefix("/table")
558
559    def query(
560        self,
561        query: str,
562        *,
563        project_id: int | None,
564        version_id: int | None = None,
565    ) -> Generator[pa.RecordBatch, None, None]:
566        """Query data with the given SQL query and retrieve the result as arrow record batches.
567
568        Args:
569            query: The SQL query the execute.
570            project_id: project_id to filter the source data by. Can be set to none to query data
571                access all projects the authenticated user has access to.
572            version_id: Data version ID at which to execute the query.
573
574        Example:
575            ```
576            result = client.query("SELECT * FROM source_table")
577            table = pyarrow.Table.from_batches(result)
578
579            # Write as CSV. Does only work with primitive columns.
580            pyarrow.csv.write_csv(t, "result.csv")
581
582            # Write as Parquet. Does work with structs and array columns as well.
583            pyarrow.parquet.write_table(t, "result.parquet")
584            ```
585        """
586        request = QueryDataRequest(
587            query=query,
588            project_id=project_id,
589            version_id=version_id,
590        )
591        with self._client.http_client.stream(
592            "POST", self._client.url(":query"), json=request.model_dump(by_alias=True)
593        ) as response:
594            if not response.is_success:
595                response.read()  # Required in streaming request to the error handling can access content.
596                self._client.handle_error_response(response)
597
598            ct = response.headers.get("content-type")
599            if ct is None:
600                raise ValueError("content type header missing")
601            ct = ct.split(";")[0].strip()
602
603            if ct != "application/vnd.apache.arrow.stream":
604                raise ValueError(f"unexpected content type {ct}")
605            yield from pa.ipc.open_stream(_IteratorReader(response.iter_bytes()))
606
607    def get_by_id(self, table_id: int) -> TableResponse:
608        """Retrieve the table by its ID.
609        The ID is an opaque identifier for the table returned at table creation time and is distinct from the table reference.
610        """
611        return self._client.get(":getById", TableResponse, dict(id=table_id))
612
613    def get(self, reference: str) -> TableResponse:
614        """Get a table by its reference."""
615        return self._client.get(":get", TableResponse, dict(reference=reference))
616
617    def list(self) -> Generator[TableResponse, None, None]:
618        """List all current base tables and views in the workspace."""
619        opts = ListOptions()
620        while True:
621            resp = self._client.get(":list", ListTableResponse, params=opts.model_dump())
622            yield from resp.items
623            if resp.cursor is None:
624                return
625            opts.cursor = resp.cursor
626
627    def list_versions(self, table_id: int) -> Generator[TableResponse, None, None]:
628        """List all versions of a specific table.
629
630        Deprecated: Table versions will no longer be tracked in future platform versions.
631        """
632        warnings.warn(
633            "TableClient.list_versions() is deprecated. Use snapshots instead to work with older state of data.",
634            category=DeprecationWarning,
635            stacklevel=2,
636        )
637
638        opts = ListOptions()
639        while True:
640            resp = self._client.get(
641                ":listVersions",
642                ListTableResponse,
643                params=dict(**opts.model_dump(), id=table_id),
644            )
645            yield from resp.items
646            if resp.cursor is None:
647                return
648            opts.cursor = resp.cursor
649
650    def list_archived(self, reference: str) -> Generator[TableResponse, None, None]:
651        """List all archived tables that match the given reference."""
652        opts = ListOptions()
653        while True:
654            resp = self._client.get(
655                ":listArchived",
656                ListTableResponse,
657                params=dict(**opts.model_dump(), reference=reference),
658            )
659            yield from resp.items
660            if resp.cursor is None:
661                return
662            opts.cursor = resp.cursor
663
664    def create(self, table: ViewTableCreate | BaseTableCreate) -> TableResponse:
665        """Create a new table or view."""
666        return self._client.post(":create", TableResponse, json=table)
667
668    def update(self, reference: str, table: ViewTableUpdate | BaseTableUpdate) -> TableResponse:
669        """Update a table or view.
670
671        Tables can only be updated in forward-compatible ways.
672        New columns can be added but not be removed.
673        Existing columns can be turned from non-nullable to nullable but not the other way around.
674        Existing columns cannot be renamed or changed in type.
675
676        Views can be updated arbitrarily.
677        """
678        # Server differentiates between assembly_format being None or unset,
679        # so make sure to propagate it correctly.
680        body = table.model_dump(mode="json", by_alias=True)
681        if "assembly_format" not in table.model_fields_set:
682            body.pop("assembly_format")
683
684        return self._client.post(":update", TableResponse, params=dict(reference=reference), json=body)
685
686    def archive(self, reference: str, archive: TableArchive | None = None) -> TableResponse:
687        """Archive a table or view.
688
689        Archiving makes the table or view inaccessible for regular operations
690        but preserves it for historical purposes.
691        """
692        if not archive:
693            archive = TableArchive()
694        return self._client.post(":archive", TableResponse, params=dict(reference=reference), json=archive)
695
696    def unarchive(self, table_id: int) -> TableResponse:
697        """Unarchive a previously archived table or view.
698
699        This makes the table or view available for regular operations again.
700        If an active table with the same reference already exists, the unarchive operation
701        will fail with an HTTP 409 `Conflict error`.
702        """
703        return self._client.post(":unarchive", TableResponse, params=dict(id=table_id))
704
705    def rename(self, reference: str, rename: TableRename) -> TableResponse:
706        """Rename the table."""
707        return self._client.post(":rename", TableResponse, params=dict(reference=reference), json=rename)

Tables store row-based data with a well-defined column schema. Such data may come from spreadsheets or other tabular data stores.

Each table is created with a well-defined column schema, which can include primitive columns such as numbers and strings, as well as nested struct columns and arrays. A table's schema can be extended but existing columns cannot generally be modified or removed.

Table data as well as it's column schema itself are version controlled. Each change to the contained rows is tracked as a data version. Tables can be queried at any historic version unless some destructive change has been made, such as hard deletion of historic data for compliance reasons.

Because tables are versioned, each row is immutable. This means that it is not possible to change individual values in a row. Instead, the data load that added the row can be undone and the rows can be loaded again with the changed data.

See the Data Loads API endpoints on how to add and remove data to tables.

TableClient(client: HttpClient)
556    def __init__(self, client: HttpClient):
557        self._client = client.with_prefix("/table")
def query( self, query: str, *, project_id: int | None, version_id: int | None = None) -> Generator[pyarrow.lib.RecordBatch, NoneType, NoneType]:
559    def query(
560        self,
561        query: str,
562        *,
563        project_id: int | None,
564        version_id: int | None = None,
565    ) -> Generator[pa.RecordBatch, None, None]:
566        """Query data with the given SQL query and retrieve the result as arrow record batches.
567
568        Args:
569            query: The SQL query the execute.
570            project_id: project_id to filter the source data by. Can be set to none to query data
571                access all projects the authenticated user has access to.
572            version_id: Data version ID at which to execute the query.
573
574        Example:
575            ```
576            result = client.query("SELECT * FROM source_table")
577            table = pyarrow.Table.from_batches(result)
578
579            # Write as CSV. Does only work with primitive columns.
580            pyarrow.csv.write_csv(t, "result.csv")
581
582            # Write as Parquet. Does work with structs and array columns as well.
583            pyarrow.parquet.write_table(t, "result.parquet")
584            ```
585        """
586        request = QueryDataRequest(
587            query=query,
588            project_id=project_id,
589            version_id=version_id,
590        )
591        with self._client.http_client.stream(
592            "POST", self._client.url(":query"), json=request.model_dump(by_alias=True)
593        ) as response:
594            if not response.is_success:
595                response.read()  # Required in streaming request to the error handling can access content.
596                self._client.handle_error_response(response)
597
598            ct = response.headers.get("content-type")
599            if ct is None:
600                raise ValueError("content type header missing")
601            ct = ct.split(";")[0].strip()
602
603            if ct != "application/vnd.apache.arrow.stream":
604                raise ValueError(f"unexpected content type {ct}")
605            yield from pa.ipc.open_stream(_IteratorReader(response.iter_bytes()))

Query data with the given SQL query and retrieve the result as arrow record batches.

Args: query: The SQL query the execute. project_id: project_id to filter the source data by. Can be set to none to query data access all projects the authenticated user has access to. version_id: Data version ID at which to execute the query.

Example:

result = client.query("SELECT * FROM source_table")
table = pyarrow.Table.from_batches(result)

# Write as CSV. Does only work with primitive columns.
pyarrow.csv.write_csv(t, "result.csv")

# Write as Parquet. Does work with structs and array columns as well.
pyarrow.parquet.write_table(t, "result.parquet")
def get_by_id( self, table_id: int) -> Annotated[cradle.sdk.types.data.BaseTableResponse | cradle.sdk.types.data.ViewTableResponse, FieldInfo(annotation=NoneType, required=True, discriminator='kind')]:
607    def get_by_id(self, table_id: int) -> TableResponse:
608        """Retrieve the table by its ID.
609        The ID is an opaque identifier for the table returned at table creation time and is distinct from the table reference.
610        """
611        return self._client.get(":getById", TableResponse, dict(id=table_id))

Retrieve the table by its ID. The ID is an opaque identifier for the table returned at table creation time and is distinct from the table reference.

def get( self, reference: str) -> Annotated[cradle.sdk.types.data.BaseTableResponse | cradle.sdk.types.data.ViewTableResponse, FieldInfo(annotation=NoneType, required=True, discriminator='kind')]:
613    def get(self, reference: str) -> TableResponse:
614        """Get a table by its reference."""
615        return self._client.get(":get", TableResponse, dict(reference=reference))

Get a table by its reference.

def list( self) -> Generator[Annotated[cradle.sdk.types.data.BaseTableResponse | cradle.sdk.types.data.ViewTableResponse, FieldInfo(annotation=NoneType, required=True, discriminator='kind')], NoneType, NoneType]:
617    def list(self) -> Generator[TableResponse, None, None]:
618        """List all current base tables and views in the workspace."""
619        opts = ListOptions()
620        while True:
621            resp = self._client.get(":list", ListTableResponse, params=opts.model_dump())
622            yield from resp.items
623            if resp.cursor is None:
624                return
625            opts.cursor = resp.cursor

List all current base tables and views in the workspace.

def list_versions( self, table_id: int) -> Generator[Annotated[cradle.sdk.types.data.BaseTableResponse | cradle.sdk.types.data.ViewTableResponse, FieldInfo(annotation=NoneType, required=True, discriminator='kind')], NoneType, NoneType]:
627    def list_versions(self, table_id: int) -> Generator[TableResponse, None, None]:
628        """List all versions of a specific table.
629
630        Deprecated: Table versions will no longer be tracked in future platform versions.
631        """
632        warnings.warn(
633            "TableClient.list_versions() is deprecated. Use snapshots instead to work with older state of data.",
634            category=DeprecationWarning,
635            stacklevel=2,
636        )
637
638        opts = ListOptions()
639        while True:
640            resp = self._client.get(
641                ":listVersions",
642                ListTableResponse,
643                params=dict(**opts.model_dump(), id=table_id),
644            )
645            yield from resp.items
646            if resp.cursor is None:
647                return
648            opts.cursor = resp.cursor

List all versions of a specific table.

Deprecated: Table versions will no longer be tracked in future platform versions.

def list_archived( self, reference: str) -> Generator[Annotated[cradle.sdk.types.data.BaseTableResponse | cradle.sdk.types.data.ViewTableResponse, FieldInfo(annotation=NoneType, required=True, discriminator='kind')], NoneType, NoneType]:
650    def list_archived(self, reference: str) -> Generator[TableResponse, None, None]:
651        """List all archived tables that match the given reference."""
652        opts = ListOptions()
653        while True:
654            resp = self._client.get(
655                ":listArchived",
656                ListTableResponse,
657                params=dict(**opts.model_dump(), reference=reference),
658            )
659            yield from resp.items
660            if resp.cursor is None:
661                return
662            opts.cursor = resp.cursor

List all archived tables that match the given reference.

def create( self, table: cradle.sdk.types.data.ViewTableCreate | cradle.sdk.types.data.BaseTableCreate) -> Annotated[cradle.sdk.types.data.BaseTableResponse | cradle.sdk.types.data.ViewTableResponse, FieldInfo(annotation=NoneType, required=True, discriminator='kind')]:
664    def create(self, table: ViewTableCreate | BaseTableCreate) -> TableResponse:
665        """Create a new table or view."""
666        return self._client.post(":create", TableResponse, json=table)

Create a new table or view.

def update( self, reference: str, table: cradle.sdk.types.data.ViewTableUpdate | cradle.sdk.types.data.BaseTableUpdate) -> Annotated[cradle.sdk.types.data.BaseTableResponse | cradle.sdk.types.data.ViewTableResponse, FieldInfo(annotation=NoneType, required=True, discriminator='kind')]:
668    def update(self, reference: str, table: ViewTableUpdate | BaseTableUpdate) -> TableResponse:
669        """Update a table or view.
670
671        Tables can only be updated in forward-compatible ways.
672        New columns can be added but not be removed.
673        Existing columns can be turned from non-nullable to nullable but not the other way around.
674        Existing columns cannot be renamed or changed in type.
675
676        Views can be updated arbitrarily.
677        """
678        # Server differentiates between assembly_format being None or unset,
679        # so make sure to propagate it correctly.
680        body = table.model_dump(mode="json", by_alias=True)
681        if "assembly_format" not in table.model_fields_set:
682            body.pop("assembly_format")
683
684        return self._client.post(":update", TableResponse, params=dict(reference=reference), json=body)

Update a table or view.

Tables can only be updated in forward-compatible ways. New columns can be added but not be removed. Existing columns can be turned from non-nullable to nullable but not the other way around. Existing columns cannot be renamed or changed in type.

Views can be updated arbitrarily.

def archive( self, reference: str, archive: cradle.sdk.types.data.TableArchive | None = None) -> Annotated[cradle.sdk.types.data.BaseTableResponse | cradle.sdk.types.data.ViewTableResponse, FieldInfo(annotation=NoneType, required=True, discriminator='kind')]:
686    def archive(self, reference: str, archive: TableArchive | None = None) -> TableResponse:
687        """Archive a table or view.
688
689        Archiving makes the table or view inaccessible for regular operations
690        but preserves it for historical purposes.
691        """
692        if not archive:
693            archive = TableArchive()
694        return self._client.post(":archive", TableResponse, params=dict(reference=reference), json=archive)

Archive a table or view.

Archiving makes the table or view inaccessible for regular operations but preserves it for historical purposes.

def unarchive( self, table_id: int) -> Annotated[cradle.sdk.types.data.BaseTableResponse | cradle.sdk.types.data.ViewTableResponse, FieldInfo(annotation=NoneType, required=True, discriminator='kind')]:
696    def unarchive(self, table_id: int) -> TableResponse:
697        """Unarchive a previously archived table or view.
698
699        This makes the table or view available for regular operations again.
700        If an active table with the same reference already exists, the unarchive operation
701        will fail with an HTTP 409 `Conflict error`.
702        """
703        return self._client.post(":unarchive", TableResponse, params=dict(id=table_id))

Unarchive a previously archived table or view.

This makes the table or view available for regular operations again. If an active table with the same reference already exists, the unarchive operation will fail with an HTTP 409 Conflict error.

def rename( self, reference: str, rename: cradle.sdk.types.data.TableRename) -> Annotated[cradle.sdk.types.data.BaseTableResponse | cradle.sdk.types.data.ViewTableResponse, FieldInfo(annotation=NoneType, required=True, discriminator='kind')]:
705    def rename(self, reference: str, rename: TableRename) -> TableResponse:
706        """Rename the table."""
707        return self._client.post(":rename", TableResponse, params=dict(reference=reference), json=rename)

Rename the table.

class DataLoadClient:
710class DataLoadClient:
711    """The data API provides functionality for managing and interacting with data in a workspace.
712
713    Data comes in the form of *tables* and *artifacts*.
714    *Tables* are used to store row-based data, such as measurements, sequences, and other structured data.
715    *Artifacts* are used to store the results of tasks, such as machine learning models, predictions, and other outputs.
716
717    While tables can be created and uploaded by users, artifacts are only produced by tasks and cannot be uploaded directly.
718
719    Artifacts and table rows (via their data load source) have a context specified. This context refers to the
720    origin of the data and is used in other parts of the API to determine data visibility, e.g. to
721    isolate data between projects.
722
723    See the [Artifacts](#tag/Artifacts) API endpoints for more details on artifact data.
724
725    See the [Tables](#tag/Tables) API endpoints for more details on tabular data.
726    """
727
728    def __init__(self, client: HttpClient):
729        self._client = client.with_prefix("/load")
730
731    def list(
732        self, project_id: int | None = None, round_id: int | None = None
733    ) -> Generator[DataLoadResponse, None, None]:
734        """List all data loads in the workspace."""
735        params = {}
736        if project_id is not None:
737            params["project_id"] = project_id
738        if round_id is not None:
739            params["round_id"] = round_id
740
741        opts = ListOptions()
742        while True:
743            resp = self._client.get(":list", ListDataLoadResponse, params={**params, **opts.model_dump()})
744            yield from resp.items
745            if resp.cursor is None:
746                return
747            opts.cursor = resp.cursor
748
749    def get(self, load_id: int) -> DataLoadResponse:
750        """Get a data load by ID."""
751        return self._client.get(":get", DataLoadResponse, dict(id=load_id))
752
753    def create(self, load: DataLoadCreate) -> DataLoadResponse:
754        """Create a new data load.
755        After creation, the data load will be empty. Files can subsequently be added using the `uploadFile` endpoint.
756        """
757        return self._client.post(":create", DataLoadResponse, json=load)
758
759    def upload_file(
760        self,
761        load_id: int,
762        file: Path,
763        *,
764        description: str | None = None,
765        filepath: str | Path | None = None,
766        table_reference: str | None = None,
767        source_file_id: int | None = None,
768    ) -> FileUploadResponse:
769        """Upload a file to the cloud storage bucket and add upload information to load."""
770        data = {}
771        if description is not None:
772            data["description"] = description
773        if filepath is not None:
774            data["filepath"] = str(filepath)
775        if table_reference is not None:
776            data["table_reference"] = table_reference
777        if source_file_id is not None:
778            data["source_file_id"] = source_file_id
779
780        with file.open("rb") as f:
781            return self._client.post(
782                ":uploadFile",
783                FileUploadResponse,
784                params=dict(id=load_id),
785                files=dict(file=(file.name, f, "application/octet-stream")),
786                data=data,
787            )
788
789    def download_file(self, file_id: str, buffer: BinaryIO):
790        """Download a previously uploaded file from a load."""
791        bytes_stream: Iterator[bytes] = self._client.get(":downloadFile", Iterator[bytes], dict(file_id=file_id))
792        buffer.writelines(bytes_stream)
793
794    def finalize(self, load_id: int) -> DataLoadResponse:
795        """Finalize the data load and start ingesting rows from the uploaded files.
796
797        The ingestion process will run in the background. The progress can be tracked by polling the data load
798        and waiting for the status to switch from `LOADING` to `COMPLETED` or `FAILED`.
799        """
800        return self._client.post(":finalize", DataLoadResponse, params=dict(id=load_id))
801
802    def delete(self, load_id: int) -> DataLoadResponse:
803        """Delete the data load if it has not been completed yet.
804
805        The deletion will happen asynchronously afterwards and is irreversible.
806        """
807        return self._client.post(":delete", DataLoadResponse, params=dict(id=load_id))
808
809    def undo(self, load_id: int) -> DataLoadResponse:
810        """Append an entry to the changelog undoing the load.
811
812        The rows from this data load will no longer show at subsequent data versions.
813        Previously undone loads can be redone to restore their data.
814        """
815        return self._client.post(":undo", DataLoadResponse, params=dict(id=load_id))
816
817    def redo(self, load_id: int) -> DataLoadResponse:
818        """Append an entry to the changelog redoing the load. The rows from this data load will be visible again at subsequent data versions."""
819        return self._client.post(":redo", DataLoadResponse, params=dict(id=load_id))
820
821    def add_table(self, load_id: int, request: AddTableRequest) -> DataLoadResponse:
822        """Register a new table in the data load configuration."""
823        return self._client.post(":addTable", DataLoadResponse, params=dict(id=load_id), json=request)

The data API provides functionality for managing and interacting with data in a workspace.

Data comes in the form of tables and artifacts. Tables are used to store row-based data, such as measurements, sequences, and other structured data. Artifacts are used to store the results of tasks, such as machine learning models, predictions, and other outputs.

While tables can be created and uploaded by users, artifacts are only produced by tasks and cannot be uploaded directly.

Artifacts and table rows (via their data load source) have a context specified. This context refers to the origin of the data and is used in other parts of the API to determine data visibility, e.g. to isolate data between projects.

See the Artifacts API endpoints for more details on artifact data.

See the Tables API endpoints for more details on tabular data.

DataLoadClient(client: HttpClient)
728    def __init__(self, client: HttpClient):
729        self._client = client.with_prefix("/load")
def list( self, project_id: int | None = None, round_id: int | None = None) -> Generator[cradle.sdk.types.data.DataLoadResponse, NoneType, NoneType]:
731    def list(
732        self, project_id: int | None = None, round_id: int | None = None
733    ) -> Generator[DataLoadResponse, None, None]:
734        """List all data loads in the workspace."""
735        params = {}
736        if project_id is not None:
737            params["project_id"] = project_id
738        if round_id is not None:
739            params["round_id"] = round_id
740
741        opts = ListOptions()
742        while True:
743            resp = self._client.get(":list", ListDataLoadResponse, params={**params, **opts.model_dump()})
744            yield from resp.items
745            if resp.cursor is None:
746                return
747            opts.cursor = resp.cursor

List all data loads in the workspace.

def get(self, load_id: int) -> cradle.sdk.types.data.DataLoadResponse:
749    def get(self, load_id: int) -> DataLoadResponse:
750        """Get a data load by ID."""
751        return self._client.get(":get", DataLoadResponse, dict(id=load_id))

Get a data load by ID.

753    def create(self, load: DataLoadCreate) -> DataLoadResponse:
754        """Create a new data load.
755        After creation, the data load will be empty. Files can subsequently be added using the `uploadFile` endpoint.
756        """
757        return self._client.post(":create", DataLoadResponse, json=load)

Create a new data load. After creation, the data load will be empty. Files can subsequently be added using the uploadFile endpoint.

def upload_file( self, load_id: int, file: pathlib.Path, *, description: str | None = None, filepath: str | pathlib.Path | None = None, table_reference: str | None = None, source_file_id: int | None = None) -> cradle.sdk.types.data.FileUploadResponse:
759    def upload_file(
760        self,
761        load_id: int,
762        file: Path,
763        *,
764        description: str | None = None,
765        filepath: str | Path | None = None,
766        table_reference: str | None = None,
767        source_file_id: int | None = None,
768    ) -> FileUploadResponse:
769        """Upload a file to the cloud storage bucket and add upload information to load."""
770        data = {}
771        if description is not None:
772            data["description"] = description
773        if filepath is not None:
774            data["filepath"] = str(filepath)
775        if table_reference is not None:
776            data["table_reference"] = table_reference
777        if source_file_id is not None:
778            data["source_file_id"] = source_file_id
779
780        with file.open("rb") as f:
781            return self._client.post(
782                ":uploadFile",
783                FileUploadResponse,
784                params=dict(id=load_id),
785                files=dict(file=(file.name, f, "application/octet-stream")),
786                data=data,
787            )

Upload a file to the cloud storage bucket and add upload information to load.

def download_file(self, file_id: str, buffer: <class 'BinaryIO'>):
789    def download_file(self, file_id: str, buffer: BinaryIO):
790        """Download a previously uploaded file from a load."""
791        bytes_stream: Iterator[bytes] = self._client.get(":downloadFile", Iterator[bytes], dict(file_id=file_id))
792        buffer.writelines(bytes_stream)

Download a previously uploaded file from a load.

def finalize(self, load_id: int) -> cradle.sdk.types.data.DataLoadResponse:
794    def finalize(self, load_id: int) -> DataLoadResponse:
795        """Finalize the data load and start ingesting rows from the uploaded files.
796
797        The ingestion process will run in the background. The progress can be tracked by polling the data load
798        and waiting for the status to switch from `LOADING` to `COMPLETED` or `FAILED`.
799        """
800        return self._client.post(":finalize", DataLoadResponse, params=dict(id=load_id))

Finalize the data load and start ingesting rows from the uploaded files.

The ingestion process will run in the background. The progress can be tracked by polling the data load and waiting for the status to switch from LOADING to COMPLETED or FAILED.

def delete(self, load_id: int) -> cradle.sdk.types.data.DataLoadResponse:
802    def delete(self, load_id: int) -> DataLoadResponse:
803        """Delete the data load if it has not been completed yet.
804
805        The deletion will happen asynchronously afterwards and is irreversible.
806        """
807        return self._client.post(":delete", DataLoadResponse, params=dict(id=load_id))

Delete the data load if it has not been completed yet.

The deletion will happen asynchronously afterwards and is irreversible.

def undo(self, load_id: int) -> cradle.sdk.types.data.DataLoadResponse:
809    def undo(self, load_id: int) -> DataLoadResponse:
810        """Append an entry to the changelog undoing the load.
811
812        The rows from this data load will no longer show at subsequent data versions.
813        Previously undone loads can be redone to restore their data.
814        """
815        return self._client.post(":undo", DataLoadResponse, params=dict(id=load_id))

Append an entry to the changelog undoing the load.

The rows from this data load will no longer show at subsequent data versions. Previously undone loads can be redone to restore their data.

def redo(self, load_id: int) -> cradle.sdk.types.data.DataLoadResponse:
817    def redo(self, load_id: int) -> DataLoadResponse:
818        """Append an entry to the changelog redoing the load. The rows from this data load will be visible again at subsequent data versions."""
819        return self._client.post(":redo", DataLoadResponse, params=dict(id=load_id))

Append an entry to the changelog redoing the load. The rows from this data load will be visible again at subsequent data versions.

def add_table( self, load_id: int, request: cradle.sdk.types.data.AddTableRequest) -> cradle.sdk.types.data.DataLoadResponse:
821    def add_table(self, load_id: int, request: AddTableRequest) -> DataLoadResponse:
822        """Register a new table in the data load configuration."""
823        return self._client.post(":addTable", DataLoadResponse, params=dict(id=load_id), json=request)

Register a new table in the data load configuration.

class TaskClient:
 826class TaskClient:
 827    """Create endpoints for all supported task types.
 828
 829    When creating a task, a context must be specified. The context determines data visibility: data can either be visible
 830    in the entire project, or only in a specific round within a project.
 831    Artifact and table data also has a context and a task can only access data with a compatible context. A data context
 832    is compatible when it a workspace context or, if it is a project or round context, belongs to the same project.
 833
 834    For example, suppose project `A` has rounds `abc` and `def` and project `B` has round `xyz`.
 835    A task with context `project=A` or `round=abc` can only see data with context `project=A` or `round=abc` or `round=def`.
 836    A task with context `project=B` can only see data with context `project=B` or `round=xyz`.
 837
 838    Any data a task creates will be saved with the same context as the task.
 839
 840    Tasks always use the latest version of the data in the workspace, unless a specific version is provided.
 841    """
 842
 843    def __init__(self, client: HttpClient):
 844        self._client = client.with_prefix("/task")
 845
 846    def list(self, project_id: int | None = None, round_id: int | None = None) -> Generator[TaskResponse, None, None]:
 847        """List all tasks in a workspace, project or round.
 848
 849        To list all tasks in a workspace, leave `project_id` and `round_id` unset.
 850        To list all tasks in a project, specify `project_id` and leave `round_id` unset.
 851        To list all tasks in a round, specify `round_id` and leave `project_id` unset.
 852
 853        Only one of `project_id` or `round_id` must be specified at once.
 854        """
 855        params = {}
 856        if project_id is not None:
 857            params["project_id"] = project_id
 858        if round_id is not None:
 859            params["round_id"] = round_id
 860
 861        opts = ListOptions()
 862        while True:
 863            resp = self._client.get(":list", ListTaskResponse, params={**params, **opts.model_dump()})
 864            yield from resp.items
 865            if resp.cursor is None:
 866                return
 867            opts.cursor = resp.cursor
 868
 869    def get(self, task_id: int) -> TaskResponse:
 870        """Obtain the current state and the result of a task.
 871
 872        This endpoint can be called periodically to monitor the status of a task and to detect
 873        when it finished executing.
 874
 875        If the `state` field of the response is `COMPLETED`, then the `result` field contains the
 876        task result (often containing IDs of artifacts and tables created by the task).
 877
 878        If the `state` field of the response is `FAILED`, then the `error` field contains
 879        a description explaining why the task has failed.
 880
 881        The states `COMPLETED`, `FAILED` and `CANCELLED` are terminal states. When the task is in
 882        any other state, it will eventually transition into one of these three.
 883        """
 884        return self._client.get(":get", TaskResponse, dict(id=task_id))
 885
 886    def get_by_name(
 887        self,
 888        name: str,
 889        project_id: int | None = None,
 890        round_id: int | None = None,
 891    ) -> TaskResponse:
 892        """Get a task by unique name within their workspace, project, or round context.
 893
 894        If a task with name "TaskA" exists in project with ID 123, the `project_id` must be specified.
 895        It is not sufficient to leave it unset as tasks with the same name may exist at the workspace or
 896        round context or in a different project.
 897        """
 898        tasks = self.list(project_id=project_id, round_id=round_id)
 899        # When listing for workspace or project we still need to filter it down since they'll
 900        # return tasks from their sub-contexts.
 901        if round_id is not None:
 902            scope_desc = f"round {round_id}"
 903            tasks = [task for task in tasks if task.context == ContextRound(round_id=round_id)]
 904        elif project_id is not None:
 905            scope_desc = f"project {project_id}"
 906            tasks = [task for task in tasks if task.context == ContextProject(project_id=project_id)]
 907        else:
 908            scope_desc = "workspace"
 909            tasks = [task for task in tasks if task.context == ContextWorkspace()]
 910
 911        for task in tasks:
 912            if task.name == name:
 913                return task
 914        raise ValueError(f"Task with name '{name}' not found in {scope_desc}")
 915
 916    def get_by_idempotency_key(
 917        self,
 918        idempotency_key: str,
 919        round_id: int | None = None,
 920        project_id: int | None = None,
 921    ) -> TaskResponse:
 922        """Get a task by its idempotency key within a specific round or project.
 923
 924        If a task with idempotency key "TaskA" exists in project with ID 123, the `project_id` must be specified.
 925        It is not sufficient to leave it unset as tasks with the same idempotency key may exist at the workspace or
 926        round context or in a different project.
 927        """
 928        params = {"idempotency_key": idempotency_key}
 929        if round_id is not None:
 930            params["round_id"] = str(round_id)
 931        if project_id is not None:
 932            params["project_id"] = str(project_id)
 933        return self._client.get(":getByIdempotencyKey", TaskResponse, params=params)
 934
 935    def create(self, task: TaskCreate) -> TaskResponse:
 936        return self._client.post(f"/{task.parameters.task_type}:create", TaskResponse, json=task)
 937
 938    def create_or_get(self, task: TaskCreate, skip_parameter_check: bool = False) -> TaskResponse:
 939        """Create a task or get existing one with matching (name or idempotency key) and parameters.
 940
 941        The task must have a name or an idempotency key set. If the parameter check is not skipped the existing task must have
 942        the same parameters as `task`. If an explicit `data_version_id` is specified, it must also match.
 943
 944        Raises ValueError if parameters don't match or if name is None.
 945        """
 946
 947        error_prefix: str = ""
 948
 949        # If using idempotency_key, try to create and handle 409 Conflict by fetching existing task
 950        if task.idempotency_key is not None:
 951            error_prefix = f"Task with idempotency key '{task.idempotency_key}'"
 952            try:
 953                return self.create(task)
 954            except ClientError as e:
 955                if e.status_code != 409:
 956                    raise
 957
 958                if isinstance(task.context, ContextRound):
 959                    existing_task = self.get_by_idempotency_key(task.idempotency_key, round_id=task.context.round_id)
 960                elif isinstance(task.context, ContextProject):
 961                    existing_task = self.get_by_idempotency_key(
 962                        task.idempotency_key, project_id=task.context.project_id
 963                    )
 964                else:
 965                    existing_task = self.get_by_idempotency_key(task.idempotency_key)
 966        elif name := task.name:
 967            error_prefix = f"Task with name '{name}'"
 968            try:
 969                if isinstance(task.context, ContextRound):
 970                    existing_task = self.get_by_name(name, round_id=task.context.round_id)
 971                elif isinstance(task.context, ContextProject):
 972                    existing_task = self.get_by_name(name, project_id=task.context.project_id)
 973                else:
 974                    existing_task = self.get_by_name(name)
 975            except ValueError:
 976                existing_task = None
 977        else:
 978            raise ValueError("Task must have `name` or `idempotency_key` set")
 979
 980        if existing_task is None:
 981            return self.create(task)
 982
 983        if skip_parameter_check:
 984            return existing_task
 985
 986        if existing_task.parameters != task.parameters:
 987            existing_params = existing_task.parameters.model_dump_json(indent=2, by_alias=True)
 988            new_params = task.parameters.model_dump_json(indent=2, by_alias=True)
 989            diff = "\n".join(
 990                difflib.unified_diff(
 991                    existing_params.splitlines(),
 992                    new_params.splitlines(),
 993                    fromfile="existing_parameters",
 994                    tofile="new_parameters",
 995                    lineterm="",
 996                )
 997            )
 998            raise ValueError(f"{error_prefix} exists but has different parameters:\n{diff}")
 999
1000        if task.data_version_id is not None and existing_task.data_version_id != task.data_version_id:
1001            raise ValueError(
1002                f"{error_prefix} exists but has different data_version_id (existing: {existing_task.data_version_id}, new: {task.data_version_id})"
1003            )
1004
1005        return existing_task
1006
1007    def cancel(self, task_id: int) -> TaskResponse:
1008        """Cancel the task if possible.
1009
1010        Only tasks that have not yet terminated may be canceled. There is no guarantee that cancellation succeeds
1011        before the task successfully completes.
1012        """
1013        return self._client.post(":cancel", TaskResponse, dict(id=task_id))
1014
1015    def recover(self, task_id: int) -> TaskResponse:
1016        """Recover a task that has failed or been cancelled by putting it into state EXECUTING.
1017
1018        Note that this is best-effort and is not guaranteed to fix any issues in the underlying
1019        backend execution, and may also cause unexpected side-effects such as duplicate data loads.
1020        Main use-case is tasks that failed due to intermittent issues in the backend which have been resolved.
1021        """
1022        return self._client.post(":recover", TaskResponse, dict(id=task_id))
1023
1024    def archive(self, task_id: int) -> TaskResponse:
1025        """Archive the task.
1026
1027        This will make the task disappear from regular task listings. It will not remove data that has been
1028        produced by the task. If data removal is required, use the `data_load_id` for the task's results
1029        and call the `/data/load:undo` endpoint separately.
1030        """
1031        return self._client.post(":archive", TaskResponse, dict(id=task_id))
1032
1033    def unarchive(self, task_id: int) -> TaskResponse:
1034        """Unarchive the task."""
1035        return self._client.post(":unarchive", TaskResponse, dict(id=task_id))
1036
1037    def wait(self, task_id: int, timeout: float = 60.0) -> TaskResponse:
1038        start = time.monotonic()
1039
1040        while time.monotonic() - start < timeout:
1041            try:
1042                task = self.get(task_id)
1043                if task.state == TaskState.FAILED:
1044                    raise ValueError(f"Task {task_id} failed: {task.errors}")
1045                if task.state == TaskState.CANCELLED:
1046                    raise ValueError(f"Task {task_id} was cancelled")
1047                if task.state == TaskState.COMPLETED:
1048                    break
1049            except httpx.TransportError:
1050                logger.warning("Transport error while waiting for task, retrying...")
1051            except ClientError as e:
1052                if 500 <= e.status_code < 600:
1053                    logger.warning("Internal server error while waiting for task, retrying...")
1054                else:
1055                    raise
1056
1057            time.sleep(3)
1058        print()
1059        return self.get(task_id)

Create endpoints for all supported task types.

When creating a task, a context must be specified. The context determines data visibility: data can either be visible in the entire project, or only in a specific round within a project. Artifact and table data also has a context and a task can only access data with a compatible context. A data context is compatible when it a workspace context or, if it is a project or round context, belongs to the same project.

For example, suppose project A has rounds abc and def and project B has round xyz. A task with context project=A or round=abc can only see data with context project=A or round=abc or round=def. A task with context project=B can only see data with context project=B or round=xyz.

Any data a task creates will be saved with the same context as the task.

Tasks always use the latest version of the data in the workspace, unless a specific version is provided.

TaskClient(client: HttpClient)
843    def __init__(self, client: HttpClient):
844        self._client = client.with_prefix("/task")
def list( self, project_id: int | None = None, round_id: int | None = None) -> Generator[cradle.sdk.types.task.TaskResponse, NoneType, NoneType]:
846    def list(self, project_id: int | None = None, round_id: int | None = None) -> Generator[TaskResponse, None, None]:
847        """List all tasks in a workspace, project or round.
848
849        To list all tasks in a workspace, leave `project_id` and `round_id` unset.
850        To list all tasks in a project, specify `project_id` and leave `round_id` unset.
851        To list all tasks in a round, specify `round_id` and leave `project_id` unset.
852
853        Only one of `project_id` or `round_id` must be specified at once.
854        """
855        params = {}
856        if project_id is not None:
857            params["project_id"] = project_id
858        if round_id is not None:
859            params["round_id"] = round_id
860
861        opts = ListOptions()
862        while True:
863            resp = self._client.get(":list", ListTaskResponse, params={**params, **opts.model_dump()})
864            yield from resp.items
865            if resp.cursor is None:
866                return
867            opts.cursor = resp.cursor

List all tasks in a workspace, project or round.

To list all tasks in a workspace, leave project_id and round_id unset. To list all tasks in a project, specify project_id and leave round_id unset. To list all tasks in a round, specify round_id and leave project_id unset.

Only one of project_id or round_id must be specified at once.

def get(self, task_id: int) -> cradle.sdk.types.task.TaskResponse:
869    def get(self, task_id: int) -> TaskResponse:
870        """Obtain the current state and the result of a task.
871
872        This endpoint can be called periodically to monitor the status of a task and to detect
873        when it finished executing.
874
875        If the `state` field of the response is `COMPLETED`, then the `result` field contains the
876        task result (often containing IDs of artifacts and tables created by the task).
877
878        If the `state` field of the response is `FAILED`, then the `error` field contains
879        a description explaining why the task has failed.
880
881        The states `COMPLETED`, `FAILED` and `CANCELLED` are terminal states. When the task is in
882        any other state, it will eventually transition into one of these three.
883        """
884        return self._client.get(":get", TaskResponse, dict(id=task_id))

Obtain the current state and the result of a task.

This endpoint can be called periodically to monitor the status of a task and to detect when it finished executing.

If the state field of the response is COMPLETED, then the result field contains the task result (often containing IDs of artifacts and tables created by the task).

If the state field of the response is FAILED, then the error field contains a description explaining why the task has failed.

The states COMPLETED, FAILED and CANCELLED are terminal states. When the task is in any other state, it will eventually transition into one of these three.

def get_by_name( self, name: str, project_id: int | None = None, round_id: int | None = None) -> cradle.sdk.types.task.TaskResponse:
886    def get_by_name(
887        self,
888        name: str,
889        project_id: int | None = None,
890        round_id: int | None = None,
891    ) -> TaskResponse:
892        """Get a task by unique name within their workspace, project, or round context.
893
894        If a task with name "TaskA" exists in project with ID 123, the `project_id` must be specified.
895        It is not sufficient to leave it unset as tasks with the same name may exist at the workspace or
896        round context or in a different project.
897        """
898        tasks = self.list(project_id=project_id, round_id=round_id)
899        # When listing for workspace or project we still need to filter it down since they'll
900        # return tasks from their sub-contexts.
901        if round_id is not None:
902            scope_desc = f"round {round_id}"
903            tasks = [task for task in tasks if task.context == ContextRound(round_id=round_id)]
904        elif project_id is not None:
905            scope_desc = f"project {project_id}"
906            tasks = [task for task in tasks if task.context == ContextProject(project_id=project_id)]
907        else:
908            scope_desc = "workspace"
909            tasks = [task for task in tasks if task.context == ContextWorkspace()]
910
911        for task in tasks:
912            if task.name == name:
913                return task
914        raise ValueError(f"Task with name '{name}' not found in {scope_desc}")

Get a task by unique name within their workspace, project, or round context.

If a task with name "TaskA" exists in project with ID 123, the project_id must be specified. It is not sufficient to leave it unset as tasks with the same name may exist at the workspace or round context or in a different project.

def get_by_idempotency_key( self, idempotency_key: str, round_id: int | None = None, project_id: int | None = None) -> cradle.sdk.types.task.TaskResponse:
916    def get_by_idempotency_key(
917        self,
918        idempotency_key: str,
919        round_id: int | None = None,
920        project_id: int | None = None,
921    ) -> TaskResponse:
922        """Get a task by its idempotency key within a specific round or project.
923
924        If a task with idempotency key "TaskA" exists in project with ID 123, the `project_id` must be specified.
925        It is not sufficient to leave it unset as tasks with the same idempotency key may exist at the workspace or
926        round context or in a different project.
927        """
928        params = {"idempotency_key": idempotency_key}
929        if round_id is not None:
930            params["round_id"] = str(round_id)
931        if project_id is not None:
932            params["project_id"] = str(project_id)
933        return self._client.get(":getByIdempotencyKey", TaskResponse, params=params)

Get a task by its idempotency key within a specific round or project.

If a task with idempotency key "TaskA" exists in project with ID 123, the project_id must be specified. It is not sufficient to leave it unset as tasks with the same idempotency key may exist at the workspace or round context or in a different project.

def create( self, task: cradle.sdk.types.task.TaskCreate) -> cradle.sdk.types.task.TaskResponse:
935    def create(self, task: TaskCreate) -> TaskResponse:
936        return self._client.post(f"/{task.parameters.task_type}:create", TaskResponse, json=task)
def create_or_get( self, task: cradle.sdk.types.task.TaskCreate, skip_parameter_check: bool = False) -> cradle.sdk.types.task.TaskResponse:
 938    def create_or_get(self, task: TaskCreate, skip_parameter_check: bool = False) -> TaskResponse:
 939        """Create a task or get existing one with matching (name or idempotency key) and parameters.
 940
 941        The task must have a name or an idempotency key set. If the parameter check is not skipped the existing task must have
 942        the same parameters as `task`. If an explicit `data_version_id` is specified, it must also match.
 943
 944        Raises ValueError if parameters don't match or if name is None.
 945        """
 946
 947        error_prefix: str = ""
 948
 949        # If using idempotency_key, try to create and handle 409 Conflict by fetching existing task
 950        if task.idempotency_key is not None:
 951            error_prefix = f"Task with idempotency key '{task.idempotency_key}'"
 952            try:
 953                return self.create(task)
 954            except ClientError as e:
 955                if e.status_code != 409:
 956                    raise
 957
 958                if isinstance(task.context, ContextRound):
 959                    existing_task = self.get_by_idempotency_key(task.idempotency_key, round_id=task.context.round_id)
 960                elif isinstance(task.context, ContextProject):
 961                    existing_task = self.get_by_idempotency_key(
 962                        task.idempotency_key, project_id=task.context.project_id
 963                    )
 964                else:
 965                    existing_task = self.get_by_idempotency_key(task.idempotency_key)
 966        elif name := task.name:
 967            error_prefix = f"Task with name '{name}'"
 968            try:
 969                if isinstance(task.context, ContextRound):
 970                    existing_task = self.get_by_name(name, round_id=task.context.round_id)
 971                elif isinstance(task.context, ContextProject):
 972                    existing_task = self.get_by_name(name, project_id=task.context.project_id)
 973                else:
 974                    existing_task = self.get_by_name(name)
 975            except ValueError:
 976                existing_task = None
 977        else:
 978            raise ValueError("Task must have `name` or `idempotency_key` set")
 979
 980        if existing_task is None:
 981            return self.create(task)
 982
 983        if skip_parameter_check:
 984            return existing_task
 985
 986        if existing_task.parameters != task.parameters:
 987            existing_params = existing_task.parameters.model_dump_json(indent=2, by_alias=True)
 988            new_params = task.parameters.model_dump_json(indent=2, by_alias=True)
 989            diff = "\n".join(
 990                difflib.unified_diff(
 991                    existing_params.splitlines(),
 992                    new_params.splitlines(),
 993                    fromfile="existing_parameters",
 994                    tofile="new_parameters",
 995                    lineterm="",
 996                )
 997            )
 998            raise ValueError(f"{error_prefix} exists but has different parameters:\n{diff}")
 999
1000        if task.data_version_id is not None and existing_task.data_version_id != task.data_version_id:
1001            raise ValueError(
1002                f"{error_prefix} exists but has different data_version_id (existing: {existing_task.data_version_id}, new: {task.data_version_id})"
1003            )
1004
1005        return existing_task

Create a task or get existing one with matching (name or idempotency key) and parameters.

The task must have a name or an idempotency key set. If the parameter check is not skipped the existing task must have the same parameters as task. If an explicit data_version_id is specified, it must also match.

Raises ValueError if parameters don't match or if name is None.

def cancel(self, task_id: int) -> cradle.sdk.types.task.TaskResponse:
1007    def cancel(self, task_id: int) -> TaskResponse:
1008        """Cancel the task if possible.
1009
1010        Only tasks that have not yet terminated may be canceled. There is no guarantee that cancellation succeeds
1011        before the task successfully completes.
1012        """
1013        return self._client.post(":cancel", TaskResponse, dict(id=task_id))

Cancel the task if possible.

Only tasks that have not yet terminated may be canceled. There is no guarantee that cancellation succeeds before the task successfully completes.

def recover(self, task_id: int) -> cradle.sdk.types.task.TaskResponse:
1015    def recover(self, task_id: int) -> TaskResponse:
1016        """Recover a task that has failed or been cancelled by putting it into state EXECUTING.
1017
1018        Note that this is best-effort and is not guaranteed to fix any issues in the underlying
1019        backend execution, and may also cause unexpected side-effects such as duplicate data loads.
1020        Main use-case is tasks that failed due to intermittent issues in the backend which have been resolved.
1021        """
1022        return self._client.post(":recover", TaskResponse, dict(id=task_id))

Recover a task that has failed or been cancelled by putting it into state EXECUTING.

Note that this is best-effort and is not guaranteed to fix any issues in the underlying backend execution, and may also cause unexpected side-effects such as duplicate data loads. Main use-case is tasks that failed due to intermittent issues in the backend which have been resolved.

def archive(self, task_id: int) -> cradle.sdk.types.task.TaskResponse:
1024    def archive(self, task_id: int) -> TaskResponse:
1025        """Archive the task.
1026
1027        This will make the task disappear from regular task listings. It will not remove data that has been
1028        produced by the task. If data removal is required, use the `data_load_id` for the task's results
1029        and call the `/data/load:undo` endpoint separately.
1030        """
1031        return self._client.post(":archive", TaskResponse, dict(id=task_id))

Archive the task.

This will make the task disappear from regular task listings. It will not remove data that has been produced by the task. If data removal is required, use the data_load_id for the task's results and call the /data/load:undo endpoint separately.

def unarchive(self, task_id: int) -> cradle.sdk.types.task.TaskResponse:
1033    def unarchive(self, task_id: int) -> TaskResponse:
1034        """Unarchive the task."""
1035        return self._client.post(":unarchive", TaskResponse, dict(id=task_id))

Unarchive the task.

def wait( self, task_id: int, timeout: float = 60.0) -> cradle.sdk.types.task.TaskResponse:
1037    def wait(self, task_id: int, timeout: float = 60.0) -> TaskResponse:
1038        start = time.monotonic()
1039
1040        while time.monotonic() - start < timeout:
1041            try:
1042                task = self.get(task_id)
1043                if task.state == TaskState.FAILED:
1044                    raise ValueError(f"Task {task_id} failed: {task.errors}")
1045                if task.state == TaskState.CANCELLED:
1046                    raise ValueError(f"Task {task_id} was cancelled")
1047                if task.state == TaskState.COMPLETED:
1048                    break
1049            except httpx.TransportError:
1050                logger.warning("Transport error while waiting for task, retrying...")
1051            except ClientError as e:
1052                if 500 <= e.status_code < 600:
1053                    logger.warning("Internal server error while waiting for task, retrying...")
1054                else:
1055                    raise
1056
1057            time.sleep(3)
1058        print()
1059        return self.get(task_id)
class CustomPredictorClient:
1062class CustomPredictorClient:
1063    """Custom predictors can be used instead of or in addition to predictors trained on the Cradle platform.
1064
1065    A custom predictor is a function that, given a sequence, provides one or more scores for that sequence.
1066    These APIs allow to upload a Docker image that contains a custom predictor to the Cradle platform.
1067
1068    If you would like to use custom predictors, please get in touch with your customer success representative.
1069    """
1070
1071    def __init__(self, client: HttpClient):
1072        self._client = client.with_prefix("/customPredictor")
1073
1074    def get_current(self, name: str) -> CustomPredictorResponse:
1075        """Return the custom predictor with the most recent version"""
1076        return self._client.get(":get", CustomPredictorResponse, params={"name": name})
1077
1078    def get_version(self, version_id: int) -> CustomPredictorResponse:
1079        """Return the custom predictor with the given version ID"""
1080        return self._client.get(":getVersion", CustomPredictorResponse, params={"version_id": version_id})
1081
1082    def list(self) -> Generator[CustomPredictorResponse, None, None]:
1083        """List the active and being-provisioned versions of every predictor in the workspace."""
1084        opts = ListOptions()
1085        while True:
1086            resp = self._client.get(":list", ListCustomPredictorResponse, params=opts.model_dump())
1087            yield from resp.items
1088            if resp.cursor is None:
1089                return
1090            opts.cursor = resp.cursor
1091
1092    def create(
1093        self, cp: CustomPredictorContainerCreate, container_image: Path, upload_timeout: float = 300
1094    ) -> CustomPredictorContainerCreateResponse:
1095        """Create a new custom predictor.
1096
1097        The newly created predictor will initially be in `PROVISIONING` state.
1098        This method provides the container image at the given path on the
1099        filesystem, which should be a `tar` file as created by `docker save`.
1100
1101        The returned custom predictor can be polled regularly (using the
1102        `get_version()` method) until it transitions to either `ACTIVE` or
1103        `FAILED` state. Once it is in `ACTIVE` state it can be used. When it
1104        is in `FAILED` state, it means the uploaded container image could not
1105        be used. In this case, the `error` field will have more information and
1106        a new upload can be attempted by using the `update()` method.
1107        """
1108        with container_image.open("rb") as f:
1109            client = self._client
1110            predictor = client.post(":create", CustomPredictorCreateResponse, json=cp)
1111
1112            # Upload to a signed URL - use a fresh client so the API client's auth isn't attached.
1113            response = httpx.put(
1114                predictor.root.upload_url,
1115                headers=predictor.root.upload_headers,
1116                content=f,
1117                timeout=upload_timeout,
1118                follow_redirects=True,
1119            )
1120        if not response.is_success:
1121            raise ClientError(response.status_code, response.text, [])
1122
1123        return predictor.root
1124
1125    def update(
1126        self, name: str, cp: CustomPredictorContainerUpdate, container_image: Path, upload_timeout: float = 300
1127    ) -> CustomPredictorContainerCreateResponse:
1128        """Create a new version for a custom predictor.
1129
1130        See the `create()` method for how to provide a new container image for
1131        the updated custom predictor.
1132        """
1133        with container_image.open("rb") as f:
1134            client = self._client
1135            predictor = client.post(":update", CustomPredictorCreateResponse, params={"name": name}, json=cp)
1136
1137            # Upload to a signed URL - use a fresh client so the API client's auth isn't attached.
1138            response = httpx.put(
1139                predictor.root.upload_url,
1140                headers=predictor.root.upload_headers,
1141                content=f,
1142                timeout=upload_timeout,
1143                follow_redirects=True,
1144            )
1145
1146        if not response.is_success:
1147            raise ClientError(response.status_code, response.text, [])
1148
1149        return predictor.root
1150
1151    def delete(self, name: str) -> None:
1152        """Delete the custom predictor with the given name
1153
1154        This transitions all versions of this custom predictor (the initial one
1155        created with the `:create` endpoint and all subsequent versions created with
1156        the `:update` endpoint) to the `INACTIVE` state, unless it is already in
1157        one of the `INACTIVE` or `FAILED` states.
1158        """
1159        self._client.post(":delete", response_type=None, params={"name": name})

Custom predictors can be used instead of or in addition to predictors trained on the Cradle platform.

A custom predictor is a function that, given a sequence, provides one or more scores for that sequence. These APIs allow to upload a Docker image that contains a custom predictor to the Cradle platform.

If you would like to use custom predictors, please get in touch with your customer success representative.

CustomPredictorClient(client: HttpClient)
1071    def __init__(self, client: HttpClient):
1072        self._client = client.with_prefix("/customPredictor")
def get_current(self, name: str) -> cradle.sdk.types.workspace.CustomPredictorResponse:
1074    def get_current(self, name: str) -> CustomPredictorResponse:
1075        """Return the custom predictor with the most recent version"""
1076        return self._client.get(":get", CustomPredictorResponse, params={"name": name})

Return the custom predictor with the most recent version

def get_version( self, version_id: int) -> cradle.sdk.types.workspace.CustomPredictorResponse:
1078    def get_version(self, version_id: int) -> CustomPredictorResponse:
1079        """Return the custom predictor with the given version ID"""
1080        return self._client.get(":getVersion", CustomPredictorResponse, params={"version_id": version_id})

Return the custom predictor with the given version ID

def list( self) -> Generator[cradle.sdk.types.workspace.CustomPredictorResponse, NoneType, NoneType]:
1082    def list(self) -> Generator[CustomPredictorResponse, None, None]:
1083        """List the active and being-provisioned versions of every predictor in the workspace."""
1084        opts = ListOptions()
1085        while True:
1086            resp = self._client.get(":list", ListCustomPredictorResponse, params=opts.model_dump())
1087            yield from resp.items
1088            if resp.cursor is None:
1089                return
1090            opts.cursor = resp.cursor

List the active and being-provisioned versions of every predictor in the workspace.

def create( self, cp: cradle.sdk.types.workspace.CustomPredictorContainerCreate, container_image: pathlib.Path, upload_timeout: float = 300) -> cradle.sdk.types.workspace.CustomPredictorContainerCreateResponse:
1092    def create(
1093        self, cp: CustomPredictorContainerCreate, container_image: Path, upload_timeout: float = 300
1094    ) -> CustomPredictorContainerCreateResponse:
1095        """Create a new custom predictor.
1096
1097        The newly created predictor will initially be in `PROVISIONING` state.
1098        This method provides the container image at the given path on the
1099        filesystem, which should be a `tar` file as created by `docker save`.
1100
1101        The returned custom predictor can be polled regularly (using the
1102        `get_version()` method) until it transitions to either `ACTIVE` or
1103        `FAILED` state. Once it is in `ACTIVE` state it can be used. When it
1104        is in `FAILED` state, it means the uploaded container image could not
1105        be used. In this case, the `error` field will have more information and
1106        a new upload can be attempted by using the `update()` method.
1107        """
1108        with container_image.open("rb") as f:
1109            client = self._client
1110            predictor = client.post(":create", CustomPredictorCreateResponse, json=cp)
1111
1112            # Upload to a signed URL - use a fresh client so the API client's auth isn't attached.
1113            response = httpx.put(
1114                predictor.root.upload_url,
1115                headers=predictor.root.upload_headers,
1116                content=f,
1117                timeout=upload_timeout,
1118                follow_redirects=True,
1119            )
1120        if not response.is_success:
1121            raise ClientError(response.status_code, response.text, [])
1122
1123        return predictor.root

Create a new custom predictor.

The newly created predictor will initially be in PROVISIONING state. This method provides the container image at the given path on the filesystem, which should be a tar file as created by docker save.

The returned custom predictor can be polled regularly (using the get_version() method) until it transitions to either ACTIVE or FAILED state. Once it is in ACTIVE state it can be used. When it is in FAILED state, it means the uploaded container image could not be used. In this case, the error field will have more information and a new upload can be attempted by using the update() method.

def update( self, name: str, cp: cradle.sdk.types.workspace.CustomPredictorContainerUpdate, container_image: pathlib.Path, upload_timeout: float = 300) -> cradle.sdk.types.workspace.CustomPredictorContainerCreateResponse:
1125    def update(
1126        self, name: str, cp: CustomPredictorContainerUpdate, container_image: Path, upload_timeout: float = 300
1127    ) -> CustomPredictorContainerCreateResponse:
1128        """Create a new version for a custom predictor.
1129
1130        See the `create()` method for how to provide a new container image for
1131        the updated custom predictor.
1132        """
1133        with container_image.open("rb") as f:
1134            client = self._client
1135            predictor = client.post(":update", CustomPredictorCreateResponse, params={"name": name}, json=cp)
1136
1137            # Upload to a signed URL - use a fresh client so the API client's auth isn't attached.
1138            response = httpx.put(
1139                predictor.root.upload_url,
1140                headers=predictor.root.upload_headers,
1141                content=f,
1142                timeout=upload_timeout,
1143                follow_redirects=True,
1144            )
1145
1146        if not response.is_success:
1147            raise ClientError(response.status_code, response.text, [])
1148
1149        return predictor.root

Create a new version for a custom predictor.

See the create() method for how to provide a new container image for the updated custom predictor.

def delete(self, name: str) -> None:
1151    def delete(self, name: str) -> None:
1152        """Delete the custom predictor with the given name
1153
1154        This transitions all versions of this custom predictor (the initial one
1155        created with the `:create` endpoint and all subsequent versions created with
1156        the `:update` endpoint) to the `INACTIVE` state, unless it is already in
1157        one of the `INACTIVE` or `FAILED` states.
1158        """
1159        self._client.post(":delete", response_type=None, params={"name": name})

Delete the custom predictor with the given name

This transitions all versions of this custom predictor (the initial one created with the :create endpoint and all subsequent versions created with the :update endpoint) to the INACTIVE state, unless it is already in one of the INACTIVE or FAILED states.