kalbio 0.2.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.
kalbio/client.py ADDED
@@ -0,0 +1,463 @@
1
+ """Kaleidoscope API Client Module.
2
+
3
+ This module provides the main client class for interacting with the Kaleidoscope API.
4
+
5
+ The KaleidoscopeClient provides access to various service endpoints including:
6
+
7
+ - activities: Manage activities
8
+ - imports: Import data into Kaleidoscope
9
+ - programs: Manage programs
10
+ - entity_types: Manage entity types
11
+ - records: Manage records
12
+ - fields: Manage fields
13
+ - experiments: Manage experiments
14
+ - record_views: Manage record views
15
+ - exports: Export data from Kaleidoscope
16
+
17
+ Attributes:
18
+ PROD_API_URL (str): The production URL for the Kaleidoscope API.
19
+ VALID_CONTENT_TYPES (list): List of acceptable content types for file downloads.
20
+ TIMEOUT_MAXIMUM (int): Maximum timeout for API requests in seconds.
21
+
22
+ Example:
23
+ ```python
24
+ # instantiate client object
25
+ client = KaleidoscopeClient(
26
+ client_id="your_client_id",
27
+ client_secret="your_client_secret"
28
+ )
29
+
30
+ # retrieve activities
31
+ programs = client.activities.get_activities()
32
+ ```
33
+ """
34
+
35
+ import os
36
+ from datetime import datetime, timedelta
37
+ import json
38
+ from json import JSONDecodeError
39
+ from pydantic import BaseModel
40
+ import requests
41
+ import urllib
42
+ from typing import Any, BinaryIO, Dict, Optional
43
+
44
+ PROD_API_URL = "https://api.kaleidoscope.bio"
45
+ """The production URL for the Kaleidoscope API.
46
+
47
+ This is the default url used for the `KaleidoscopeClient`, in the event
48
+ no url is provided in the `KaleidoscopeClient`'s initialization"""
49
+
50
+ VALID_CONTENT_TYPES = [
51
+ "text/csv",
52
+ "chemical/x-mdl-sdfile",
53
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
54
+ ]
55
+ """List of acceptable content types for file downloads.
56
+
57
+ Any file retrieved by the `KaleidoscopeClient` must be of one the above types"""
58
+
59
+ TIMEOUT_MAXIMUM = 10
60
+ """Maximum timeout for API requests in seconds."""
61
+
62
+ _api_key = os.getenv("KALEIDOSCOPE_API_CLIENT_ID")
63
+ _api_secret = os.getenv("KALEIDOSCOPE_API_CLIENT_SECRET")
64
+
65
+
66
+ class _TokenResponse(BaseModel):
67
+ """OAuth token response payload returned by Kaleidoscope auth endpoints.
68
+
69
+ Attributes:
70
+ access_token (str): Bearer token used for authenticated API calls.
71
+ refresh_token (str): Token used to obtain a new access token when the current one expires.
72
+ expires_in (int): Lifetime of the access token in seconds.
73
+ """
74
+
75
+ access_token: str
76
+ refresh_token: str
77
+ expires_in: int
78
+
79
+
80
+ class KaleidoscopeClient:
81
+ """A client for interacting with the Kaleidoscope API.
82
+
83
+ This client provides a high-level interface to various Kaleidoscope services including
84
+ imports, programs, entity types, records, fields, tasks, experiments, record views, and exports.
85
+ It handles authentication using API key credentials and provides methods for making HTTP requests
86
+ (GET, POST, PUT) to the API endpoints.
87
+
88
+ Attributes:
89
+ activities (ActivitiesService): Service for managing activities.
90
+ dashboards (DashboardsService): Service for managing dashboards.
91
+ workspace (WorkspaceService): Service for workspace-related operations.
92
+ programs (ProgramsService): Service for managing programs.
93
+ labels (LabelsService): Service for managing labels.
94
+ entity_types (EntityTypesService): Service for managing entity types.
95
+ entity_fields (EntityFieldsService): Service for managing entity fields.
96
+ records (RecordsService): Service for managing records.
97
+ record_views (RecordViewsService): Service for managing record views.
98
+ imports (ImportsService): Service for managing data imports.
99
+ exports (ExportsService): Service for managing data exports.
100
+ property_fields (PropertyFieldsService): Service for managing property fields.
101
+
102
+ Example:
103
+ ```python
104
+ client = KaleidoscopeClient(
105
+ client_id="your_api_client_id",
106
+ client_secret="your_api_client_secret"
107
+ )
108
+ # Use the client to interact with various services
109
+ programs = client.activities.get_activities()
110
+ ```
111
+ """
112
+
113
+ _client_id: str
114
+ _client_secret: str
115
+
116
+ _refresh_token: str
117
+ _access_token: str
118
+ _refresh_before: datetime
119
+
120
+ def __init__(
121
+ self,
122
+ client_id: Optional[str] = _api_key,
123
+ client_secret: Optional[str] = _api_secret,
124
+ url: str = PROD_API_URL,
125
+ ):
126
+ """Initialize the Kaleidoscope API client.
127
+
128
+ Sets up the client with API credentials and optional API URL, and initializes
129
+ service interfaces for interacting with different API endpoints.
130
+
131
+ Args:
132
+ client_id (str): The API client ID for authentication.
133
+ client_secret (str): The API client secret for authentication.
134
+ url (Optional[str]): The base URL for the API. Defaults to the production
135
+ API URL if not provided.
136
+
137
+ Example:
138
+ ```python
139
+ # Using explicit credentials
140
+ client = KaleidoscopeClient(client_id="id", client_secret="secret")
141
+
142
+ # Or rely on environment variables KALEIDOSCOPE_API_CLIENT_ID/SECRET
143
+ client = KaleidoscopeClient()
144
+ ```
145
+ """
146
+ if client_id is None:
147
+ raise ValueError(
148
+ 'No client_id provided and "KALEIDOSCOPE_API_CLIENT_ID" was not found in the environment.'
149
+ )
150
+
151
+ if client_secret is None:
152
+ raise ValueError(
153
+ 'No client_secret provided and "KALEIDOSCOPE_API_CLIENT_SECRET" was not found in the environment.'
154
+ )
155
+
156
+ from kalbio.activities import ActivitiesService
157
+ from kalbio.dashboards import DashboardsService
158
+ from kalbio.entity_fields import EntityFieldsService
159
+ from kalbio.entity_types import EntityTypesService
160
+ from kalbio.exports import ExportsService
161
+ from kalbio.imports import ImportsService
162
+ from kalbio.labels import LabelsService
163
+ from kalbio.programs import ProgramsService
164
+ from kalbio.property_fields import PropertyFieldsService
165
+ from kalbio.record_views import RecordViewsService
166
+ from kalbio.records import RecordsService
167
+ from kalbio.workspace import WorkspaceService
168
+
169
+ self._api_url = url
170
+
171
+ self.activities = ActivitiesService(self)
172
+ self.dashboards = DashboardsService(self)
173
+ self.entity_fields = EntityFieldsService(self)
174
+ self.entity_types = EntityTypesService(self)
175
+ self.exports = ExportsService(self)
176
+ self.imports = ImportsService(self)
177
+ self.labels = LabelsService(self)
178
+ self.property_fields = PropertyFieldsService(self)
179
+ self.programs = ProgramsService(self)
180
+ self.record_views = RecordViewsService(self)
181
+ self.records = RecordsService(self)
182
+ self.workspace = WorkspaceService(self)
183
+
184
+ self._client_id = client_id
185
+ self._client_secret = client_secret
186
+ self._get_auth_token()
187
+
188
+ def _update_tokens(self, resp: _TokenResponse):
189
+ """Persist access and refresh tokens and compute the next refresh time.
190
+
191
+ Args:
192
+ resp: Token payload returned from the auth endpoint.
193
+ """
194
+ self._access_token = resp.access_token
195
+ self._refresh_token = resp.refresh_token
196
+ self._last_refreshed_at = datetime.now() + timedelta(
197
+ seconds=resp.expires_in - (60 * 10) # add a 10 minute buffer
198
+ )
199
+
200
+ def _get_auth_token(self):
201
+ """Fetch an access token using client credentials.
202
+
203
+ Raises:
204
+ RuntimeError: If the auth endpoint responds with an error status code.
205
+ """
206
+ auth_resp = requests.post(
207
+ self._api_url + "/auth/oauth/token",
208
+ data={
209
+ "grant_type": "client_credentials",
210
+ "client_id": self._client_id,
211
+ "client_secret": self._client_secret,
212
+ },
213
+ timeout=TIMEOUT_MAXIMUM,
214
+ )
215
+ if auth_resp.status_code >= 400:
216
+ raise RuntimeError(
217
+ f"Could not connect to server with client_id {self._client_id}: {auth_resp.content}"
218
+ )
219
+
220
+ self._update_tokens(_TokenResponse.model_validate(auth_resp.json()))
221
+
222
+ def _refresh_auth_token(self):
223
+ """Refresh the access token using the stored refresh token.
224
+
225
+ Raises:
226
+ RuntimeError: If the auth endpoint responds with an error status code.
227
+ """
228
+ if self._refresh_token is None:
229
+ return self._get_auth_token()
230
+
231
+ auth_resp = requests.post(
232
+ self._api_url + "/auth/oauth/token",
233
+ data={
234
+ "grant_type": "refresh_token",
235
+ "refresh_token": self._refresh_token,
236
+ },
237
+ timeout=TIMEOUT_MAXIMUM,
238
+ )
239
+ if auth_resp.status_code >= 400:
240
+ raise RuntimeError(f"Could not refresh access token: {auth_resp.content}")
241
+
242
+ self._update_tokens(_TokenResponse.model_validate(auth_resp.json()))
243
+
244
+ def _get_headers(self) -> dict:
245
+ """Build authorization headers, refreshing tokens if needed.
246
+
247
+ Returns:
248
+ dict: HTTP headers including `Authorization` and `Content-Type`.
249
+ """
250
+ if datetime.now() > self._last_refreshed_at:
251
+ self._refresh_auth_token()
252
+
253
+ return {
254
+ "Content-Type": "application/json",
255
+ "Authorization": f"Bearer {self._access_token}",
256
+ }
257
+
258
+ def _post(self, url: str, payload: dict) -> Any:
259
+ """Send a POST request to the specified URL with the given payload.
260
+
261
+ Args:
262
+ url (str): The endpoint URL (relative to the API base URL) to send the
263
+ POST request to.
264
+ payload (dict): The data to be sent in the body of the POST request.
265
+ Should be serializable to JSON.
266
+
267
+ Returns:
268
+ Any: The JSON response from the server if the request is successful
269
+ and the response is valid JSON. Returns None if the response cannot be decoded.
270
+
271
+ Raises:
272
+ Exception: Any exception that may be raised `requests.post`
273
+ """
274
+
275
+ resp = requests.post(
276
+ self._api_url + url,
277
+ data=json.dumps(payload),
278
+ headers=self._get_headers(),
279
+ timeout=TIMEOUT_MAXIMUM,
280
+ )
281
+ if resp.status_code >= 400:
282
+ print(f"POST {url} received {resp.status_code}: ", resp.content)
283
+ return None
284
+ try:
285
+ return resp.json()
286
+ except JSONDecodeError:
287
+ return None
288
+
289
+ def _post_file(
290
+ self, url: str, file_data: tuple[str, BinaryIO, str], body: Any = None
291
+ ) -> Any:
292
+ """Send a POST request with a file and optional JSON body.
293
+
294
+ Args:
295
+ url (str): The endpoint URL (relative to the API base URL).
296
+ file_data (tuple[str, BinaryIO, str]): A tuple containing the file name,
297
+ file object, and MIME type.
298
+ body (Any): Optional data to be sent as JSON in the
299
+ form data. Defaults to None.
300
+
301
+ Returns:
302
+ Any: The JSON response from the server if the request is successful.
303
+ Returns None if the response cannot be decoded.
304
+
305
+ Raises:
306
+ Exception: Any exception that may be raised `requests.post`
307
+ """
308
+ files = {"file": file_data}
309
+
310
+ form_data = {}
311
+ if body:
312
+ form_data["body"] = json.dumps(body)
313
+
314
+ resp = requests.post(
315
+ self._api_url + url,
316
+ files=files,
317
+ data=form_data,
318
+ headers=self._get_headers(),
319
+ timeout=TIMEOUT_MAXIMUM,
320
+ )
321
+ if resp.status_code >= 400:
322
+ print(f"POST {url} received {resp.status_code}: ", resp.content)
323
+ return None
324
+ try:
325
+ return resp.json()
326
+ except JSONDecodeError:
327
+ return None
328
+
329
+ def _put(self, url: str, payload: dict) -> Any:
330
+ """Send a PUT request to the specified URL with the provided payload.
331
+
332
+ Args:
333
+ url (str): The endpoint URL (relative to the base API URL).
334
+ payload (dict): The data to be sent in the PUT request body.
335
+
336
+ Returns:
337
+ Any: The JSON response from the server if the request is successful.
338
+ Returns None if the response cannot be decoded.
339
+
340
+ Raises:
341
+ Exception: Any exception that may be raised `requests.put`
342
+ """
343
+
344
+ resp = requests.put(
345
+ self._api_url + url,
346
+ data=json.dumps(payload),
347
+ headers=self._get_headers(),
348
+ timeout=TIMEOUT_MAXIMUM,
349
+ )
350
+ if resp.status_code >= 400:
351
+ print(f"PUT {url} received {resp.status_code}: ", resp.content)
352
+ return None
353
+ try:
354
+ return resp.json()
355
+ except JSONDecodeError:
356
+ return None
357
+
358
+ def _get(self, url: str, params: Optional[Dict[str, Any]] = None) -> Any:
359
+ """Send a GET request to the specified API endpoint with optional query parameters.
360
+
361
+ Args:
362
+ url (str): The API endpoint path to append to the base URL.
363
+ params (Optional[Dict[str, Any]]): Dictionary of query parameters to
364
+ include in the request. Defaults to None.
365
+
366
+ Returns:
367
+ Any: The JSON response from the server if the request is successful.
368
+ Returns None if the response cannot be decoded.
369
+
370
+ Raises:
371
+ Exception: Any exception that may be raised `requests.get`
372
+ """
373
+ url = self._api_url + url
374
+ if params:
375
+ url += "?" + urllib.parse.urlencode(params)
376
+
377
+ resp = requests.get(url, headers=self._get_headers(), timeout=TIMEOUT_MAXIMUM)
378
+ if resp.status_code >= 400:
379
+ print(f"GET {url} received {resp.status_code}", resp.content)
380
+ return None
381
+ try:
382
+ return resp.json()
383
+ except JSONDecodeError:
384
+ return None
385
+
386
+ def _get_file(
387
+ self, url: str, download_path: str, params: Optional[Dict[str, Any]] = None
388
+ ) -> str | None:
389
+ """Download a file from the specified URL and save it to the given path.
390
+
391
+ Args:
392
+ url (str): The endpoint URL (relative to the API base URL) to download
393
+ the file from.
394
+ download_path (str): The local file path where the downloaded file
395
+ will be saved.
396
+ params (Optional[Dict[str, Any]]): Dictionary of query parameters to
397
+ include in the request. Defaults to None.
398
+
399
+ Returns:
400
+ (str | None): The path to the downloaded file if successful. Returns None
401
+ if the request fails or the response does not contain valid file data.
402
+
403
+ Raises:
404
+ Exception: Any exception that may be raised `requests.get`
405
+
406
+ Note:
407
+ Only responses with valid content types (as defined in VALID_CONTENT_TYPES)
408
+ are saved.
409
+ """
410
+ url = self._api_url + url
411
+ if params:
412
+ url += "?" + urllib.parse.urlencode(params)
413
+
414
+ resp = requests.get(
415
+ url, headers=self._get_headers(), stream=True, timeout=TIMEOUT_MAXIMUM
416
+ )
417
+ if resp.status_code >= 400:
418
+ print(f"GET {url} received {resp.status_code}", resp.content)
419
+ return None
420
+
421
+ content_type = resp.headers.get("Content-Type", "")
422
+ if content_type not in VALID_CONTENT_TYPES:
423
+ print(
424
+ f"Invalid Content-Type: {content_type}. Response does not contain valid file data."
425
+ )
426
+ return None
427
+
428
+ with open(download_path, "wb") as f_download:
429
+ for chunk in resp.iter_content(chunk_size=8192):
430
+ if chunk:
431
+ f_download.write(chunk)
432
+
433
+ return download_path
434
+
435
+ def _delete(self, url: str, params: Optional[Dict[str, Any]] = None) -> Any:
436
+ """Send a DELETE request to the specified API endpoint with optional query parameters.
437
+
438
+ Args:
439
+ url (str): The API endpoint path to append to the base URL.
440
+ params (Optional[Dict[str, Any]]): Dictionary of query parameters to
441
+ include in the request. Defaults to None.
442
+
443
+ Returns:
444
+ Any: The JSON response from the server if the request is successful.
445
+ Returns None if the response cannot be decoded.
446
+
447
+ Raises:
448
+ Exception: Any exception that may be raised `requests.delete`
449
+ """
450
+ url = self._api_url + url
451
+ if params:
452
+ url += "?" + urllib.parse.urlencode(params)
453
+
454
+ resp = requests.delete(
455
+ url, headers=self._get_headers(), timeout=TIMEOUT_MAXIMUM
456
+ )
457
+ if resp.status_code >= 400:
458
+ print(f"DELETE {url} received {resp.status_code}", resp.content)
459
+ return None
460
+ try:
461
+ return resp.json()
462
+ except JSONDecodeError:
463
+ return None