tamarind-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tamarind/config.py ADDED
@@ -0,0 +1,152 @@
1
+ """Configuration and credential resolution.
2
+
3
+ Settings are resolved in this order (first wins):
4
+
5
+ 1. an explicit value passed on the command line (``--api-key`` etc.)
6
+ 2. an environment variable (``TAMARIND_API_KEY`` / ``TAMARIND_API_BASE`` /
7
+ ``TAMARIND_CATALOG_BASE`` / ``TAMARIND_PROFILE``)
8
+ 3. the selected profile in ``~/.tamarind/config.json``
9
+ 4. a built-in default (base URLs only — there is no default API key)
10
+
11
+ This mirrors how the AWS CLI and similar tools layer flags > env > file, so it
12
+ is predictable for both humans and agents (an agent typically just exports
13
+ ``TAMARIND_API_KEY``).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+
23
+ DEFAULT_API_BASE = "https://app.tamarind.bio/api/"
24
+ # Discovery (tools/schema/modalities/functions) is served by the catalog
25
+ # service. It is a separate host from the job API because the catalog runs
26
+ # behind per-org visibility logic; see the package docstring.
27
+ DEFAULT_CATALOG_BASE = "https://mcp.tamarind.bio"
28
+
29
+ DEFAULT_PROFILE = "default"
30
+
31
+
32
+ def config_dir() -> Path:
33
+ """Resolved each call so TAMARIND_CONFIG_DIR can change between invocations."""
34
+ return Path(os.environ.get("TAMARIND_CONFIG_DIR", Path.home() / ".tamarind"))
35
+
36
+
37
+ def config_path() -> Path:
38
+ return config_dir() / "config.json"
39
+
40
+
41
+ @dataclass
42
+ class Config:
43
+ """Resolved settings for a single invocation."""
44
+
45
+ api_key: str | None
46
+ api_base: str
47
+ catalog_base: str
48
+ profile: str
49
+
50
+ @property
51
+ def has_key(self) -> bool:
52
+ return bool(self.api_key)
53
+
54
+
55
+ def _read_store() -> dict:
56
+ path = config_path()
57
+ if not path.exists():
58
+ return {}
59
+ try:
60
+ return json.loads(path.read_text())
61
+ except (json.JSONDecodeError, OSError):
62
+ return {}
63
+
64
+
65
+ def _write_store(store: dict) -> None:
66
+ path = config_path()
67
+ path.parent.mkdir(parents=True, exist_ok=True)
68
+ path.write_text(json.dumps(store, indent=2) + "\n")
69
+ # Credentials live here — keep the file private (best effort on POSIX).
70
+ try:
71
+ path.chmod(0o600)
72
+ except OSError:
73
+ pass
74
+
75
+
76
+ def _normalize_base(url: str) -> str:
77
+ """Trailing slash matters for httpx base_url + relative paths."""
78
+ return url if url.endswith("/") else url + "/"
79
+
80
+
81
+ def resolve_profile_name(profile: str | None) -> str:
82
+ if profile:
83
+ return profile
84
+ if os.environ.get("TAMARIND_PROFILE"):
85
+ return os.environ["TAMARIND_PROFILE"]
86
+ store = _read_store()
87
+ return store.get("current_profile", DEFAULT_PROFILE)
88
+
89
+
90
+ def load_config(
91
+ *,
92
+ api_key: str | None = None,
93
+ api_base: str | None = None,
94
+ catalog_base: str | None = None,
95
+ profile: str | None = None,
96
+ ) -> Config:
97
+ """Resolve effective settings (flags > env > profile > default)."""
98
+ profile_name = resolve_profile_name(profile)
99
+ store = _read_store()
100
+ prof = store.get("profiles", {}).get(profile_name, {})
101
+
102
+ resolved_key = api_key or os.environ.get("TAMARIND_API_KEY") or prof.get("api_key")
103
+ resolved_api_base = (
104
+ api_base
105
+ or os.environ.get("TAMARIND_API_BASE")
106
+ or prof.get("api_base")
107
+ or DEFAULT_API_BASE
108
+ )
109
+ resolved_catalog_base = (
110
+ catalog_base
111
+ or os.environ.get("TAMARIND_CATALOG_BASE")
112
+ or prof.get("catalog_base")
113
+ or DEFAULT_CATALOG_BASE
114
+ )
115
+
116
+ return Config(
117
+ api_key=resolved_key,
118
+ api_base=_normalize_base(resolved_api_base),
119
+ catalog_base=_normalize_base(resolved_catalog_base),
120
+ profile=profile_name,
121
+ )
122
+
123
+
124
+ def save_profile(
125
+ profile: str,
126
+ *,
127
+ api_key: str | None = None,
128
+ api_base: str | None = None,
129
+ catalog_base: str | None = None,
130
+ make_current: bool = True,
131
+ ) -> None:
132
+ """Persist credentials/endpoints for a profile to ``~/.tamarind/config.json``."""
133
+ store = _read_store()
134
+ profiles = store.setdefault("profiles", {})
135
+ prof = profiles.setdefault(profile, {})
136
+ if api_key is not None:
137
+ prof["api_key"] = api_key
138
+ if api_base is not None:
139
+ prof["api_base"] = api_base
140
+ if catalog_base is not None:
141
+ prof["catalog_base"] = catalog_base
142
+ if make_current:
143
+ store["current_profile"] = profile
144
+ _write_store(store)
145
+
146
+
147
+ def mask_key(key: str | None) -> str:
148
+ if not key:
149
+ return "<none>"
150
+ if len(key) <= 8:
151
+ return "*" * len(key)
152
+ return f"{key[:4]}…{key[-4:]}"
tamarind/errors.py ADDED
@@ -0,0 +1,59 @@
1
+ """Exception types and exit codes for the Tamarind client.
2
+
3
+ Exit codes are stable so agents and CI can branch on them:
4
+
5
+ 0 success
6
+ 1 generic / unexpected error
7
+ 2 usage error (bad arguments) — Typer's default
8
+ 3 authentication error (no key, or 401)
9
+ 4 not found (404)
10
+ 5 validation error (a job's settings failed validate-job, or a 400)
11
+ 6 rate limited (429)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+
17
+ class ExitCode:
18
+ OK = 0
19
+ ERROR = 1
20
+ USAGE = 2
21
+ AUTH = 3
22
+ NOT_FOUND = 4
23
+ VALIDATION = 5
24
+ RATE_LIMIT = 6
25
+
26
+
27
+ class TamarindError(Exception):
28
+ """Base class for all client errors. Carries a stable exit code."""
29
+
30
+ exit_code: int = ExitCode.ERROR
31
+
32
+ def __init__(self, message: str, *, detail: object | None = None):
33
+ super().__init__(message)
34
+ self.message = message
35
+ self.detail = detail
36
+
37
+
38
+ class AuthError(TamarindError):
39
+ exit_code = ExitCode.AUTH
40
+
41
+
42
+ class NotFoundError(TamarindError):
43
+ exit_code = ExitCode.NOT_FOUND
44
+
45
+
46
+ class ValidationError(TamarindError):
47
+ exit_code = ExitCode.VALIDATION
48
+
49
+
50
+ class RateLimitError(TamarindError):
51
+ exit_code = ExitCode.RATE_LIMIT
52
+
53
+
54
+ class APIError(TamarindError):
55
+ """A non-2xx response that doesn't map to a more specific error."""
56
+
57
+ def __init__(self, message: str, *, status_code: int, detail: object | None = None):
58
+ super().__init__(message, detail=detail)
59
+ self.status_code = status_code
tamarind/http.py ADDED
@@ -0,0 +1,160 @@
1
+ """Thin HTTP transport shared by the REST and catalog clients.
2
+
3
+ Wraps ``httpx.Client`` with the ``x-api-key`` header the Tamarind API expects,
4
+ and maps non-2xx responses onto the typed errors in :mod:`tamarind.errors` so
5
+ callers (and the CLI's exit codes) get consistent behaviour.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ import httpx
13
+
14
+ from .errors import (
15
+ APIError,
16
+ AuthError,
17
+ NotFoundError,
18
+ RateLimitError,
19
+ TamarindError,
20
+ ValidationError,
21
+ )
22
+
23
+ DEFAULT_TIMEOUT = 120.0
24
+ USER_AGENT = "tamarind-cli"
25
+
26
+
27
+ class HTTPClient:
28
+ """A small wrapper around ``httpx.Client`` keyed by base URL + API key."""
29
+
30
+ def __init__(
31
+ self,
32
+ base_url: str,
33
+ api_key: str | None,
34
+ *,
35
+ timeout: float = DEFAULT_TIMEOUT,
36
+ ):
37
+ self.base_url = base_url
38
+ self.api_key = api_key
39
+ headers = {
40
+ "Accept": "application/json",
41
+ # Brotli is still decoded transparently by httpx; we just don't want
42
+ # surprises from upstream content-encoding negotiation.
43
+ "Accept-Encoding": "identity",
44
+ "User-Agent": f"{USER_AGENT}/{_version()}",
45
+ }
46
+ if api_key:
47
+ headers["x-api-key"] = api_key
48
+ self._client = httpx.Client(base_url=base_url, headers=headers, timeout=timeout)
49
+
50
+ # -- lifecycle ---------------------------------------------------------
51
+ def close(self) -> None:
52
+ self._client.close()
53
+
54
+ def __enter__(self) -> "HTTPClient":
55
+ return self
56
+
57
+ def __exit__(self, *exc: object) -> None:
58
+ self.close()
59
+
60
+ # -- requests ----------------------------------------------------------
61
+ def request(
62
+ self,
63
+ method: str,
64
+ path: str,
65
+ *,
66
+ params: dict[str, Any] | None = None,
67
+ json: Any | None = None,
68
+ timeout: float | None = None,
69
+ ) -> httpx.Response:
70
+ if not self.api_key:
71
+ raise AuthError(
72
+ "No API key configured. Set TAMARIND_API_KEY, pass --api-key, "
73
+ "or run `tamarind auth login`."
74
+ )
75
+ # Drop None-valued query params so we don't send `?x=None`.
76
+ clean_params = (
77
+ {k: v for k, v in params.items() if v is not None} if params else None
78
+ )
79
+ try:
80
+ resp = self._client.request(
81
+ method,
82
+ path.lstrip("/"),
83
+ params=clean_params,
84
+ json=json,
85
+ timeout=timeout if timeout is not None else httpx.USE_CLIENT_DEFAULT,
86
+ )
87
+ except httpx.HTTPError as exc:
88
+ raise TamarindError(f"Network error talking to {self.base_url}: {exc}") from exc
89
+
90
+ if resp.is_success:
91
+ return resp
92
+ raise _map_error(resp)
93
+
94
+ def get_json(self, path: str, *, params: dict[str, Any] | None = None) -> Any:
95
+ return _parse_json(self.request("GET", path, params=params))
96
+
97
+ def post_json(self, path: str, *, json: Any | None = None) -> Any:
98
+ return _parse_json(self.request("POST", path, json=json))
99
+
100
+ def delete_json(
101
+ self, path: str, *, params: dict[str, Any] | None = None, json: Any | None = None
102
+ ) -> Any:
103
+ return _parse_json(self.request("DELETE", path, params=params, json=json))
104
+
105
+
106
+ def _parse_json(resp: httpx.Response) -> Any:
107
+ text = resp.text.strip()
108
+ if not text:
109
+ return None
110
+ try:
111
+ return resp.json()
112
+ except ValueError:
113
+ # Some endpoints (e.g. /result) return a bare presigned URL string.
114
+ return text
115
+
116
+
117
+ def _extract_message(resp: httpx.Response) -> str:
118
+ try:
119
+ body = resp.json()
120
+ except ValueError:
121
+ return resp.text.strip() or resp.reason_phrase or f"HTTP {resp.status_code}"
122
+ if isinstance(body, dict):
123
+ for key in ("error", "message", "detail"):
124
+ if body.get(key):
125
+ return str(body[key])
126
+ if isinstance(body, str):
127
+ return body
128
+ return resp.reason_phrase or f"HTTP {resp.status_code}"
129
+
130
+
131
+ def _map_error(resp: httpx.Response) -> TamarindError:
132
+ msg = _extract_message(resp)
133
+ code = resp.status_code
134
+ ml = msg.lower()
135
+ auth_ish = "api key" in ml or "api-key" in ml or "apikey" in ml or "unauthorized" in ml
136
+ notfound_ish = "not found" in ml or "does not exist" in ml or "no such" in ml
137
+ if code == 401:
138
+ return AuthError(f"Unauthorized: {msg}")
139
+ if code == 403:
140
+ return AuthError(f"Access denied: {msg}")
141
+ if code == 404:
142
+ return NotFoundError(msg)
143
+ if code == 400:
144
+ # The API uses 400 for several distinct failures; classify by message so
145
+ # exit codes are consistent: bad/missing key -> auth (3), missing job/file
146
+ # -> not-found (4), otherwise a genuine validation error (5).
147
+ if auth_ish:
148
+ return AuthError(f"Unauthorized: {msg}")
149
+ if notfound_ish:
150
+ return NotFoundError(msg)
151
+ return ValidationError(msg)
152
+ if code == 429:
153
+ return RateLimitError(f"Rate limited: {msg}")
154
+ return APIError(msg, status_code=code)
155
+
156
+
157
+ def _version() -> str:
158
+ from . import __version__
159
+
160
+ return __version__
tamarind/jobs.py ADDED
@@ -0,0 +1,106 @@
1
+ """Job-status helpers: normalization and polling.
2
+
3
+ The REST job objects use capitalized keys (``JobName``, ``JobStatus``, ...).
4
+ The status enum is {Complete, In Queue, Running, Stopped, Deleted}; we also
5
+ treat Failed/Cancelled/Error as terminal defensively in case the backend grows
6
+ new terminal states.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ from typing import Any, Callable
13
+
14
+ from . import rest
15
+ from .errors import NotFoundError
16
+ from .http import HTTPClient
17
+
18
+ # Compared case-insensitively.
19
+ TERMINAL_STATUSES = {"complete", "completed", "stopped", "deleted", "failed", "cancelled", "error"}
20
+ SUCCESS_STATUSES = {"complete", "completed"}
21
+
22
+
23
+ def job_status(job: dict[str, Any]) -> str | None:
24
+ """Read a job's status regardless of which casing the API used."""
25
+ for key in ("JobStatus", "status", "Status"):
26
+ if job.get(key):
27
+ return str(job[key])
28
+ return None
29
+
30
+
31
+ def job_name(job: dict[str, Any]) -> str | None:
32
+ for key in ("JobName", "jobName", "name"):
33
+ if job.get(key):
34
+ return str(job[key])
35
+ return None
36
+
37
+
38
+ def is_terminal(status: str | None) -> bool:
39
+ return bool(status) and status.lower() in TERMINAL_STATUSES
40
+
41
+
42
+ def is_success(status: str | None) -> bool:
43
+ return bool(status) and status.lower() in SUCCESS_STATUSES
44
+
45
+
46
+ def fetch_job(client: HTTPClient, name: str) -> dict[str, Any]:
47
+ """Fetch a single job by name. Raises NotFoundError if it doesn't exist."""
48
+ resp = rest.get_jobs(client, job_name=name)
49
+ job = _extract_single(resp, name)
50
+ if job is None:
51
+ raise NotFoundError(f"Job '{name}' not found")
52
+ return job
53
+
54
+
55
+ def _extract_single(resp: Any, name: str) -> dict[str, Any] | None:
56
+ if not isinstance(resp, dict):
57
+ return None
58
+
59
+ # Shape A: {"jobs": [...]}
60
+ if "jobs" in resp:
61
+ jobs = resp.get("jobs") or []
62
+ for j in jobs:
63
+ if job_name(j) == name:
64
+ return j
65
+ return jobs[0] if jobs else None
66
+
67
+ # Shape B: an index-keyed map {"0": {...}, "1": {...}, "statuses": {...}} —
68
+ # what the job API returns for a single-jobName query.
69
+ indexed = [v for k, v in resp.items() if k.isdigit() and isinstance(v, dict)]
70
+ if indexed:
71
+ for j in indexed:
72
+ if job_name(j) == name:
73
+ return j
74
+ return indexed[0]
75
+
76
+ # Shape C: a bare JobInfo object.
77
+ if any(k in resp for k in ("JobName", "JobStatus", "jobName", "status")):
78
+ return resp
79
+ return None
80
+
81
+
82
+ def wait_for_job(
83
+ client: HTTPClient,
84
+ name: str,
85
+ *,
86
+ poll_interval: float = 10.0,
87
+ timeout: float | None = None,
88
+ on_poll: Callable[[dict[str, Any]], None] | None = None,
89
+ ) -> dict[str, Any]:
90
+ """Block until ``name`` reaches a terminal status (or ``timeout`` elapses).
91
+
92
+ Returns the final job object. Raises TimeoutError if a timeout is set and
93
+ the job is still running when it elapses.
94
+ """
95
+ deadline = None if timeout is None else time.monotonic() + timeout
96
+ while True:
97
+ job = fetch_job(client, name)
98
+ if on_poll is not None:
99
+ on_poll(job)
100
+ if is_terminal(job_status(job)):
101
+ return job
102
+ if deadline is not None and time.monotonic() >= deadline:
103
+ raise TimeoutError(
104
+ f"Job '{name}' still {job_status(job)!r} after {timeout:.0f}s"
105
+ )
106
+ time.sleep(poll_interval)
tamarind/rest.py ADDED
@@ -0,0 +1,192 @@
1
+ """Typed wrappers over the Tamarind REST API (the job/file surface).
2
+
3
+ Every function here maps 1:1 onto an operation in ``openapi-mcp.yaml`` — the
4
+ same spec the Tamarind MCP server is built from. Keeping this a thin, literal
5
+ mapping (no business logic) is what keeps the CLI and the MCP from drifting on
6
+ the REST surface. Discovery/catalog calls live in :mod:`tamarind.catalog`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from .errors import APIError
14
+ from .http import HTTPClient
15
+
16
+ # Query params that the API expects as the literal string "true" rather than a
17
+ # JSON boolean.
18
+ _TRUE = "true"
19
+
20
+
21
+ def submit_job(
22
+ client: HTTPClient, *, job_name: str, job_type: str, settings: dict[str, Any]
23
+ ) -> Any:
24
+ """POST /submit-job — submit a single job. Body: {jobName, type, settings}."""
25
+ return client.post_json(
26
+ "submit-job",
27
+ json={"jobName": job_name, "type": job_type, "settings": settings},
28
+ )
29
+
30
+
31
+ def validate_job(
32
+ client: HTTPClient, *, job_name: str, job_type: str, settings: dict[str, Any]
33
+ ) -> dict:
34
+ """POST /validate-job — returns {valid, normalized?, error?} (HTTP 200 either way)."""
35
+ return client.post_json(
36
+ "validate-job",
37
+ json={"jobName": job_name, "type": job_type, "settings": settings},
38
+ )
39
+
40
+
41
+ def submit_batch(
42
+ client: HTTPClient,
43
+ *,
44
+ batch_name: str,
45
+ job_type: str,
46
+ settings: list[dict[str, Any]],
47
+ job_names: list[str] | None = None,
48
+ max_runtime_seconds: int | None = None,
49
+ ) -> Any:
50
+ """POST /submit-batch — submit many jobs as one batch."""
51
+ body: dict[str, Any] = {
52
+ "batchName": batch_name,
53
+ "type": job_type,
54
+ "settings": settings,
55
+ }
56
+ if job_names is not None:
57
+ body["jobNames"] = job_names
58
+ if max_runtime_seconds is not None:
59
+ body["maxRuntimeSeconds"] = max_runtime_seconds
60
+ return client.post_json("submit-batch", json=body)
61
+
62
+
63
+ def get_jobs(
64
+ client: HTTPClient,
65
+ *,
66
+ job_name: str | None = None,
67
+ batch: str | None = None,
68
+ start_key: str | None = None,
69
+ limit: int | None = None,
70
+ organization: bool = False,
71
+ include_subjobs: bool = False,
72
+ job_email: str | None = None,
73
+ ) -> Any:
74
+ """GET /jobs — list jobs, or fetch one when ``job_name`` is given."""
75
+ params = {
76
+ "jobName": job_name,
77
+ "batch": batch,
78
+ "startKey": start_key,
79
+ "limit": limit,
80
+ "organization": _TRUE if organization else None,
81
+ "includeSubjobs": _TRUE if include_subjobs else None,
82
+ "jobEmail": job_email,
83
+ }
84
+ return client.get_json("jobs", params=params)
85
+
86
+
87
+ def get_result(
88
+ client: HTTPClient,
89
+ *,
90
+ job_name: str,
91
+ job_email: str | None = None,
92
+ file_name: str | None = None,
93
+ pdbs_only: bool | None = None,
94
+ ) -> Any:
95
+ """POST /result — returns an S3 presigned URL (string) for the result bundle."""
96
+ body: dict[str, Any] = {"jobName": job_name}
97
+ if job_email is not None:
98
+ body["jobEmail"] = job_email
99
+ if file_name is not None:
100
+ body["fileName"] = file_name
101
+ if pdbs_only is not None:
102
+ body["pdbsOnly"] = pdbs_only
103
+ return client.post_json("result", json=body)
104
+
105
+
106
+ def upload_file_url(client: HTTPClient, *, filename: str) -> dict:
107
+ """POST /uploadFile — returns {signedUrl, filename}; PUT the bytes to signedUrl."""
108
+ return client.post_json("uploadFile", json={"filename": filename})
109
+
110
+
111
+ def cancel_job(
112
+ client: HTTPClient, *, job_name: str | None = None, job_id: str | None = None
113
+ ) -> dict:
114
+ """POST /cancelJob — soft-stop a queued/running job (preserves the row)."""
115
+ body: dict[str, Any] = {}
116
+ if job_name is not None:
117
+ body["jobName"] = job_name
118
+ if job_id is not None:
119
+ body["jobId"] = job_id
120
+ return client.post_json("cancelJob", json=body)
121
+
122
+
123
+ def cancel_batch(client: HTTPClient, *, batch_name: str) -> dict:
124
+ """POST /cancelBatch — soft-stop every job in a batch or pipeline."""
125
+ return client.post_json("cancelBatch", json={"batchName": batch_name})
126
+
127
+
128
+ def delete_job(client: HTTPClient, *, job_name: str) -> Any:
129
+ """DELETE /delete-job — permanently remove a job (and subjobs, for batches).
130
+
131
+ The endpoint may return a bare string (not JSON), so parse defensively.
132
+ """
133
+ return client.delete_json("delete-job", json={"jobName": job_name})
134
+
135
+
136
+ def delete_file(
137
+ client: HTTPClient, *, file_path: str | None = None, folder: str | None = None
138
+ ) -> Any:
139
+ """Delete a file, or every file under a folder.
140
+
141
+ The API expects DELETE (a GET returns 405 "Use DELETE or POST"); some older
142
+ deployments may still want GET, so fall back on a 405.
143
+ """
144
+ params = {"filePath": file_path, "folder": folder}
145
+ try:
146
+ return client.delete_json("delete-file", params=params)
147
+ except APIError as exc:
148
+ if getattr(exc, "status_code", None) == 405:
149
+ return client.get_json("delete-file", params=params)
150
+ raise
151
+
152
+
153
+ def get_files(
154
+ client: HTTPClient,
155
+ *,
156
+ limit: int | None = None,
157
+ offset: int | None = None,
158
+ types: str | None = None,
159
+ search: str | None = None,
160
+ folder: str | None = None,
161
+ include_folders: bool = False,
162
+ include_all: bool = False,
163
+ include_metadata: bool = False,
164
+ ) -> Any:
165
+ """GET /files — list files in the workspace, with filtering/pagination."""
166
+ params = {
167
+ "limit": limit,
168
+ "offset": offset,
169
+ "types": types,
170
+ "search": search,
171
+ "folder": folder,
172
+ "includeFolders": _TRUE if include_folders else None,
173
+ "includeAll": _TRUE if include_all else None,
174
+ "includeMetadata": _TRUE if include_metadata else None,
175
+ }
176
+ return client.get_json("files", params=params)
177
+
178
+
179
+ def get_folders(
180
+ client: HTTPClient,
181
+ *,
182
+ limit: int | None = None,
183
+ offset: int | None = None,
184
+ load_all: bool = False,
185
+ ) -> Any:
186
+ """GET /getFolders — list folders in the workspace."""
187
+ params = {
188
+ "limit": limit,
189
+ "offset": offset,
190
+ "loadAll": _TRUE if load_all else None,
191
+ }
192
+ return client.get_json("getFolders", params=params)