datalab-api 0.2.13__tar.gz → 0.3.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datalab-api
3
- Version: 0.2.13
3
+ Version: 0.3.0
4
4
  Summary: A Python API for instances of the datalab data management platform (https://github.com/datalab-org/datalab).
5
5
  Author-email: Matthew Evans <matthew@ml-evs.science>
6
6
  License-Expression: MIT
@@ -20,7 +20,7 @@ Classifier: Topic :: Scientific/Engineering
20
20
  Requires-Python: >=3.9
21
21
  Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
- Requires-Dist: httpx~=0.27
23
+ Requires-Dist: httpx~=0.28
24
24
  Requires-Dist: bokeh~=2.4; platform_system != "Emscripten"
25
25
  Requires-Dist: rich~=13.0
26
26
  Requires-Dist: numpy<2
@@ -28,7 +28,7 @@ Provides-Extra: dev
28
28
  Requires-Dist: pre-commit<5,>=3; extra == "dev"
29
29
  Requires-Dist: pytest~=8.0; extra == "dev"
30
30
  Requires-Dist: pytest-cov<7,>=5; extra == "dev"
31
- Requires-Dist: respx~=0.21; extra == "dev"
31
+ Requires-Dist: respx~=0.22; extra == "dev"
32
32
  Provides-Extra: docs
33
33
  Requires-Dist: mkdocs; extra == "docs"
34
34
  Requires-Dist: mkdocs-material; extra == "docs"
@@ -24,7 +24,7 @@ classifiers = [
24
24
 
25
25
  requires-python = ">=3.9"
26
26
  dependencies = [
27
- "httpx ~= 0.27", # better HTTP requests
27
+ "httpx ~= 0.28", # better HTTP requests
28
28
  'bokeh ~= 2.4; platform_system != "Emscripten"', # interactive plots from datalab directly
29
29
  "rich ~= 13.0", # nicer terminal output
30
30
  "numpy < 2", # upper pin for numpy due to bad pins in bokeh
@@ -36,7 +36,7 @@ dev = [
36
36
  "pre-commit >= 3,< 5",
37
37
  "pytest ~= 8.0",
38
38
  "pytest-cov >= 5,< 7",
39
- "respx ~= 0.21",
39
+ "respx ~= 0.22",
40
40
  ]
41
41
 
42
42
  docs = [
@@ -5,13 +5,9 @@ from collections.abc import Iterable
5
5
  from pathlib import Path
6
6
  from typing import Any, Optional, Union
7
7
 
8
- from ._base import BaseDatalabClient, __version__
8
+ from ._base import BaseDatalabClient, DuplicateItemError, __version__
9
9
 
10
- __all__ = ("DatalabClient", "__version__")
11
-
12
-
13
- class DuplicateItemError(ValueError):
14
- """Raised when the API operation would create a duplicate item."""
10
+ __all__ = ("DatalabClient", "DuplicateItemError", "__version__")
15
11
 
16
12
 
17
13
  class DatalabClient(BaseDatalabClient):
@@ -38,8 +34,8 @@ class DatalabClient(BaseDatalabClient):
38
34
 
39
35
  """
40
36
  info_url = f"{self.datalab_api_url}/info"
41
- info_resp = self.session.get(info_url, follow_redirects=True)
42
- self.info = info_resp.json()["data"]
37
+ info_data = self._get(info_url)
38
+ self.info = info_data["data"]
43
39
  return self.info
44
40
 
45
41
  def authenticate(self):
@@ -64,12 +60,8 @@ class DatalabClient(BaseDatalabClient):
64
60
 
65
61
  """
66
62
  block_info_url = f"{self.datalab_api_url}/info/blocks"
67
- block_info_resp = self.session.get(block_info_url, follow_redirects=True)
68
- if block_info_resp.status_code != 200:
69
- raise RuntimeError(
70
- f"Failed to list block information at {block_info_url}: {block_info_resp.status_code=}."
71
- )
72
- self.block_info = block_info_resp.json()["data"]
63
+ block_info_data = self._get(block_info_url)
64
+ self.block_info = block_info_data["data"]
73
65
  return self.block_info
74
66
 
75
67
  def get_items(self, item_type: str | None = "samples") -> list[dict[str, Any]]:
@@ -94,23 +86,16 @@ class DatalabClient(BaseDatalabClient):
94
86
  item_type = "samples"
95
87
 
96
88
  items_url = f"{self.datalab_api_url}/{endpoint_type_map.get(item_type, item_type.replace('_', '-'))}"
97
- items_resp = self.session.get(items_url, follow_redirects=True)
98
- if items_resp.status_code != 200:
99
- raise RuntimeError(
100
- f"Failed to list items with {item_type=} at {items_url}: {items_resp.status_code=}. Check the item type is correct."
101
- )
102
- items = items_resp.json()
103
- if items["status"] != "success":
104
- raise RuntimeError(f"Failed to list items at {items_url}: {items['status']!r}.")
89
+ items = self._get(items_url)
105
90
 
106
- if item_type in items:
107
- # Old approach
108
- return items[item_type]
109
- if "items" in items:
110
- return items["items"]
91
+ if isinstance(items, dict):
92
+ if item_type in items:
93
+ # Old approach
94
+ return items[item_type]
95
+ if "items" in items:
96
+ return items["items"]
111
97
 
112
- else:
113
- return items
98
+ return items # type: ignore
114
99
 
115
100
  def search_items(
116
101
  self, query: str, item_types: Iterable[str] | str = ("samples", "cells")
@@ -131,15 +116,7 @@ class DatalabClient(BaseDatalabClient):
131
116
  search_items_url = (
132
117
  f"{self.datalab_api_url}/search-items?query={query}&types={','.join(item_types)}"
133
118
  )
134
- items_resp = self.session.get(search_items_url, follow_redirects=True)
135
- if items_resp.status_code != 200:
136
- raise RuntimeError(
137
- f"Failed to search items with {item_types=} at {search_items_url}: {items_resp.status_code=}"
138
- )
139
- items = items_resp.json()
140
- if items["status"] != "success":
141
- raise RuntimeError(f"Failed to list items at {search_items_url}: {items['status']!r}.")
142
-
119
+ items = self._get(search_items_url)
143
120
  return items["items"]
144
121
 
145
122
  def create_item(
@@ -177,29 +154,11 @@ class DatalabClient(BaseDatalabClient):
177
154
  new_item["collections"].append({"immutable_id": collection_immutable_id})
178
155
 
179
156
  create_item_url = f"{self.datalab_api_url}/new-sample/"
180
- create_item_resp = self.session.post(
157
+ created_item = self._post(
181
158
  create_item_url,
182
159
  json={"new_sample_data": new_item, "generate_id_automatically": item_id is None},
183
- follow_redirects=True,
184
160
  )
185
- try:
186
- created_item = create_item_resp.json()
187
- if create_item_resp.status_code == 409:
188
- raise DuplicateItemError(
189
- f"Item {item_id=} already exists at {create_item_url}: {created_item['status']!r}."
190
- )
191
- if created_item["status"] != "success":
192
- if "DuplicateKeyError" in created_item["message"]:
193
- raise DuplicateItemError(
194
- f"Item {item_id=} already exists at {create_item_url}: {created_item['status']!r}."
195
- )
196
- raise RuntimeError(f"Failed to create item at {create_item_url}: {created_item}.")
197
- return created_item["sample_list_entry"]
198
-
199
- except Exception as exc:
200
- raise exc.__class__(
201
- f"Failed to create item {item_id=} with data {item_data=} at {create_item_url}: {create_item_resp.status_code=}, {create_item_resp.content}. Check the item information is correct."
202
- ) from exc
161
+ return created_item["sample_list_entry"]
203
162
 
204
163
  def update_item(self, item_id: str, item_data: dict[str, Any]) -> dict[str, Any]:
205
164
  """Update an item with the given item data.
@@ -214,20 +173,7 @@ class DatalabClient(BaseDatalabClient):
214
173
  """
215
174
  update_item_data = {"item_id": item_id, "data": item_data}
216
175
  update_item_url = f"{self.datalab_api_url}/save-item/"
217
- update_item_resp = self.session.post(
218
- update_item_url,
219
- json=update_item_data,
220
- follow_redirects=True,
221
- )
222
- if update_item_resp.status_code != 200:
223
- raise RuntimeError(
224
- f"Failed to update item {item_id=} at {update_item_url}: {update_item_resp.status_code=}. Check the item information is correct."
225
- )
226
- updated_item = update_item_resp.json()
227
- if updated_item["status"] != "success":
228
- raise RuntimeError(
229
- f"Failed to update item at {update_item_url}: {updated_item['status']!r}."
230
- )
176
+ updated_item = self._post(update_item_url, json=update_item_data)
231
177
  return updated_item
232
178
 
233
179
  def get_item(
@@ -259,16 +205,7 @@ class DatalabClient(BaseDatalabClient):
259
205
  else:
260
206
  item_url = f"{self.datalab_api_url}/get-item-data/{item_id}"
261
207
 
262
- item_resp = self.session.get(item_url, follow_redirects=True)
263
-
264
- if item_resp.status_code != 200:
265
- raise RuntimeError(
266
- f"Failed to find item {item_id=}, {refcode=} {item_url}: {item_resp.status_code=}. Check the item information is correct."
267
- )
268
-
269
- item = item_resp.json()
270
- if item["status"] != "success":
271
- raise RuntimeError(f"Failed to get item at {item_url}: {item['status']!r}.")
208
+ item = self._get(item_url)
272
209
 
273
210
  # Filter out any deleted blocks
274
211
  item["item_data"]["blocks_obj"] = {
@@ -328,15 +265,7 @@ class DatalabClient(BaseDatalabClient):
328
265
  "block_id": block_id,
329
266
  "save_to_db": False,
330
267
  }
331
- block_resp = self.session.post(block_url, json=block_request, follow_redirects=True)
332
- if block_resp.status_code != 200:
333
- raise RuntimeError(
334
- f"Failed to find block {block_id=} for item {item_id=} at {block_url}: {block_resp.status_code=}. Check the block information is correct."
335
- )
336
-
337
- block = block_resp.json()
338
- if block["status"] != "success":
339
- raise RuntimeError(f"Failed to get block at {block_url}: {block['status']!r}.")
268
+ block = self._post(block_url, json=block_request)
340
269
  return block["new_block_data"]
341
270
 
342
271
  def upload_file(self, item_id: str, file_path: Path | str) -> dict[str, Any]:
@@ -358,21 +287,12 @@ class DatalabClient(BaseDatalabClient):
358
287
  upload_url = f"{self.datalab_api_url}/upload-file/"
359
288
  with open(file_path, "rb") as file:
360
289
  files = {"file": (file_path.name, file)}
361
- upload_resp = self.session.post(
290
+ upload = self._post(
362
291
  upload_url,
363
292
  files=files,
364
293
  data={"item_id": item_id, "replace_file": None},
365
- follow_redirects=True,
366
- )
367
- if upload_resp.status_code != 201:
368
- raise RuntimeError(
369
- f"Failed to upload file {file_path=} to item {item_id=} at {upload_url}: {upload_resp.status_code=}. Check the file information is correct."
294
+ expected_status=201,
370
295
  )
371
-
372
- upload = upload_resp.json()
373
- if upload["status"] != "success":
374
- raise RuntimeError(f"Failed to upload file at {upload_url}: {upload['status']!r}.")
375
-
376
296
  return upload
377
297
 
378
298
  def create_data_block(
@@ -422,12 +342,7 @@ class DatalabClient(BaseDatalabClient):
422
342
  f"Not all IDs {file_ids=} are attached to item {item_id=}: {attached_file_ids=}"
423
343
  )
424
344
 
425
- block_resp = self.session.post(blocks_url, json=payload, follow_redirects=True)
426
- if block_resp.status_code != 200:
427
- raise RuntimeError(
428
- f"Failed to create block {block_type=} for item {item_id=}:\n{block_resp.text}"
429
- )
430
- block_data = block_resp.json()["new_block_obj"]
345
+ block_data = self._post(blocks_url, json=payload)["new_block_obj"]
431
346
 
432
347
  if file_ids:
433
348
  block_data = self._update_data_block(
@@ -484,11 +399,8 @@ class DatalabClient(BaseDatalabClient):
484
399
  "save_to_db": True,
485
400
  }
486
401
 
487
- resp = self.session.post(blocks_url, json=payload, follow_redirects=True)
488
- if resp.status_code != 200:
489
- raise RuntimeError(f"Failed to update block {block_type=}:\n{resp.text}")
490
-
491
- return resp.json()["new_block_data"]
402
+ resp = self._post(blocks_url, json=payload)
403
+ return resp["new_block_data"]
492
404
 
493
405
  def get_collection(self, collection_id: str) -> tuple[dict[str, Any], list[dict[str, Any]]]:
494
406
  """Get a collection with a given ID.
@@ -502,16 +414,7 @@ class DatalabClient(BaseDatalabClient):
502
414
 
503
415
  """
504
416
  collection_url = f"{self.datalab_api_url}/collections/{collection_id}"
505
- collection_resp = self.session.get(collection_url, follow_redirects=True)
506
- if collection_resp.status_code != 200:
507
- raise RuntimeError(
508
- f"Failed to find collection {collection_id=} at {collection_url}: {collection_resp.status_code=}. Check the collection ID is correct."
509
- )
510
- collection = collection_resp.json()
511
- if collection["status"] != "success":
512
- raise RuntimeError(
513
- f"Failed to get collection at {collection_url}: {collection['status']!r}."
514
- )
417
+ collection = self._get(collection_url)
515
418
  return collection["data"], collection["child_items"]
516
419
 
517
420
  def create_collection(
@@ -531,16 +434,9 @@ class DatalabClient(BaseDatalabClient):
531
434
  new_collection = collection_data
532
435
  new_collection.update({"collection_id": collection_id, "type": "collections"})
533
436
 
534
- collection_resp = self.session.put(
437
+ created_collection = self._put(
535
438
  collection_url,
536
439
  json={"data": new_collection},
537
- follow_redirects=True,
440
+ expected_status=201,
538
441
  )
539
-
540
- if collection_resp.status_code != 201 or collection_resp.json()["status"] != "success":
541
- raise RuntimeError(
542
- f"Failed to create collection {collection_id=} at {collection_url}: {collection_resp.status_code=}. Check the collection information is correct: {collection_resp.content}"
543
- )
544
-
545
- created_collection = collection_resp.json()["data"]
546
- return created_collection
442
+ return created_collection["data"]
@@ -1,4 +1,3 @@
1
- import functools
2
1
  import logging
3
2
  import os
4
3
  import re
@@ -7,84 +6,22 @@ from importlib.metadata import version
7
6
  from typing import Any, Optional
8
7
 
9
8
  import httpx
10
- from rich.console import Console
11
9
  from rich.logging import RichHandler
12
- from rich.pretty import pprint
13
- from rich.table import Table
14
10
 
15
- __version__ = version("datalab-api")
16
-
17
- __all__ = ("BaseDatalabClient", "__version__")
18
-
19
-
20
- def pretty_displayer(method):
21
- """A decorator which wraps a method with a 'display' kwarg, which will
22
- either pretty print a JSON response, or display a Rich table.
23
-
24
- """
25
-
26
- @functools.wraps(method)
27
- def rich_wrapper(self, *args, **kwargs):
28
- display = kwargs.pop("display", False)
29
- page_limit = kwargs.pop("page_limit", 10)
30
- result = method(self, *args, **kwargs)
31
- if display:
32
- blocks = None
33
- if isinstance(result, dict):
34
- if "blocks_obj" in result:
35
- blocks = result["blocks_obj"]
36
- elif "blocktype" in result:
37
- blocks = {result["block_id"]: result}
38
- result = None
39
- if blocks:
40
- for block in blocks.values():
41
- if "bokeh_plot_data" in block:
42
- bokeh_from_json(block)
43
- if result:
44
- pprint(result, max_length=None, max_string=100, max_depth=3)
45
- elif isinstance(result, list):
46
- try:
47
- table = Table(show_lines=True)
48
- table.add_column("type", overflow="crop", justify="center")
49
- table.add_column("ID", style="cyan", no_wrap=True)
50
- table.add_column("refcode", style="magenta", no_wrap=True)
51
- table.add_column("name", width=30, overflow="ellipsis", no_wrap=True)
52
- for item in result[:page_limit]:
53
- table.add_row(
54
- item["type"][0].upper(),
55
- item["item_id"],
56
- item["refcode"],
57
- item["name"],
58
- end_section=True,
59
- )
60
- console = Console()
61
- console.print(table)
62
- except Exception:
63
- pprint(result, max_length=None, max_string=100, max_depth=3)
64
-
65
- return result
11
+ from .utils import AutoPrettyPrint
66
12
 
67
- return rich_wrapper
68
13
 
14
+ class DatalabAPIError(Exception):
15
+ """Base exception for Datalab API errors."""
69
16
 
70
- class AutoPrettyPrint(type):
71
- def __new__(cls, name, bases, dct):
72
- for attr, value in dct.items():
73
- if callable(value) and not attr.startswith("__"):
74
- dct[attr] = pretty_displayer(value)
75
- return super().__new__(cls, name, bases, dct)
76
17
 
18
+ class DuplicateItemError(DatalabAPIError):
19
+ """Raised when the API operation would create a duplicate item."""
77
20
 
78
- def bokeh_from_json(block_data, show=True):
79
- from bokeh.io import curdoc
80
- from bokeh.plotting import show as bokeh_show
81
21
 
82
- bokeh_plot_data = block_data.get("bokeh_plot_data", block_data)
83
- curdoc().replace_with_json(bokeh_plot_data["doc"])
84
- if show:
85
- bokeh_show(curdoc().roots[0])
22
+ __version__ = version("datalab-api")
86
23
 
87
- return curdoc()
24
+ __all__ = ("BaseDatalabClient", "__version__")
88
25
 
89
26
 
90
27
  class BaseDatalabClient(metaclass=AutoPrettyPrint):
@@ -122,7 +59,7 @@ class BaseDatalabClient(metaclass=AutoPrettyPrint):
122
59
 
123
60
  Parameters:
124
61
  datalab_api_url: The URL of the Datalab API.
125
- TODO: If the URL of a datalab *UI* is provided, a request will be made to attempt
62
+ If the URL of a datalab *UI* is provided, a request will be made to attempt
126
63
  to resolve the underlying API URL (e.g., `https://demo-api.datalab-org.io`
127
64
  will 'redirect' to `https://demo-api.datalab-org.io`).
128
65
  log_level: The logging level to use for the client. Defaults to "WARNING".
@@ -276,3 +213,139 @@ class BaseDatalabClient(metaclass=AutoPrettyPrint):
276
213
  def __exit__(self, *_):
277
214
  if self._session is not None:
278
215
  self._session.close()
216
+
217
+ def _handle_response(
218
+ self, response: httpx.Response, url: str, expected_status: int = 200
219
+ ) -> dict[str, Any]:
220
+ """Handle HTTP response with consistent error handling.
221
+
222
+ Args:
223
+ response: The HTTP response object
224
+ url: The URL that was requested
225
+ expected_status: The expected HTTP status code (default: 200)
226
+
227
+ Returns:
228
+ The JSON response data
229
+
230
+ Raises:
231
+ DuplicateItemError: For 409 conflicts or duplicate key errors
232
+ DatalabAPIError: For other API errors
233
+ """
234
+ # Handle authentication errors first
235
+ if response.status_code in (401, 403):
236
+ auth_help = (
237
+ "Authentication failed. Please check your API key and ensure it's set correctly. "
238
+ "You can set your API key using the DATALAB_API_KEY environment variable or "
239
+ "the instance-specific <PREFIX>_DATALAB_API_KEY variable."
240
+ )
241
+ try:
242
+ error_data = response.json()
243
+ error_message = error_data.get("message", str(response.content))
244
+ except ValueError:
245
+ error_message = str(response.content)
246
+ raise DatalabAPIError(
247
+ f"HTTP {response.status_code} error at {url}: {error_message}. {auth_help}"
248
+ )
249
+
250
+ # Handle other HTTP status code errors
251
+ if response.status_code != expected_status:
252
+ try:
253
+ error_data = response.json()
254
+ error_message = error_data.get("message", str(response.content))
255
+ error_message += f"\nFull JSON: {error_data}"
256
+ except ValueError:
257
+ error_message = str(response.content)
258
+
259
+ # Handle specific error cases with helpful messages
260
+ if response.status_code == 404:
261
+ raise DatalabAPIError(
262
+ f"HTTP 404 Not Found at {url}: {error_message}. "
263
+ "Please check the URL/endpoint is correct and the resource exists."
264
+ )
265
+ elif response.status_code == 409:
266
+ raise DuplicateItemError(f"Duplicate item error at {url}: {error_message}")
267
+ elif response.status_code == 429:
268
+ raise DatalabAPIError(
269
+ f"HTTP 429 Too Many Requests at {url}: {error_message}. "
270
+ "You are being rate limited. Please wait before making more requests."
271
+ )
272
+ elif response.status_code == 500:
273
+ server_info = ""
274
+ if hasattr(self, "_datalab_server_version"):
275
+ server_info = f" (Server version: {self._datalab_server_version})"
276
+ raise DatalabAPIError(
277
+ f"""HTTP 500 Internal Server Error at {url} {server_info}.
278
+ This is likely a server-side bug. Please report this issue to the datalab developers at https://github.com/datalab-org/datalab/issues with the full error message below:
279
+
280
+ \n{error_message}"""
281
+ )
282
+ elif response.status_code == 502:
283
+ raise DatalabAPIError(
284
+ f"HTTP 502 Bad Gateway at {url}: {error_message}. "
285
+ "The server is having connectivity issues. Please try again later."
286
+ )
287
+ elif response.status_code == 503:
288
+ raise DatalabAPIError(
289
+ f"HTTP 503 Service Unavailable at {url}: {error_message}. "
290
+ "The datalab service is temporarily unavailable. Please report deployment "
291
+ "issues to your datalab instance maintainer."
292
+ )
293
+ elif response.status_code == 504:
294
+ raise DatalabAPIError(
295
+ f"HTTP 504 Gateway Timeout at {url}: {error_message}. "
296
+ "The request timed out. Please try again or check if the operation is taking too long."
297
+ )
298
+ else:
299
+ raise DatalabAPIError(
300
+ f"HTTP {response.status_code} error at {url}: {error_message}"
301
+ )
302
+
303
+ # Parse JSON response
304
+ try:
305
+ data = response.json()
306
+ except ValueError as e:
307
+ raise DatalabAPIError(
308
+ f"Invalid JSON response from {url}: {e}. Response content: {response.content}"
309
+ )
310
+
311
+ return data
312
+
313
+ def _request(
314
+ self, method: str, url: str, expected_status: int = 200, **kwargs
315
+ ) -> dict[str, Any]:
316
+ """Make an HTTP request with consistent error handling.
317
+
318
+ Args:
319
+ method: HTTP method (GET, POST, PUT, etc.)
320
+ url: The URL to request
321
+ expected_status: The expected HTTP status code (default: 200)
322
+ **kwargs: Additional arguments to pass to the request
323
+
324
+ Returns:
325
+ The JSON response data
326
+ """
327
+ try:
328
+ response = self.session.request(method, url, follow_redirects=True, **kwargs)
329
+ return self._handle_response(response, url, expected_status)
330
+ except (httpx.RequestError, httpx.HTTPStatusError) as e:
331
+ raise DatalabAPIError(f"Request failed for {url}: {e}")
332
+
333
+ def _get(self, url: str, **kwargs) -> dict[str, Any]:
334
+ """Make a GET request with error handling."""
335
+ return self._request("GET", url, **kwargs)
336
+
337
+ def _post(self, url: str, expected_status: int = 200, **kwargs) -> dict[str, Any]:
338
+ """Make a POST request with error handling."""
339
+ return self._request("POST", url, expected_status, **kwargs)
340
+
341
+ def _put(self, url: str, expected_status: int = 201, **kwargs) -> dict[str, Any]:
342
+ """Make a PUT request with error handling."""
343
+ return self._request("PUT", url, expected_status, **kwargs)
344
+
345
+ def _patch(self, url: str, **kwargs) -> dict[str, Any]:
346
+ """Make a PATCH request with error handling."""
347
+ return self._request("PATCH", url, **kwargs)
348
+
349
+ def _delete(self, url: str, expected_status: int = 200, **kwargs) -> dict[str, Any]:
350
+ """Make a DELETE request with error handling."""
351
+ return self._request("DELETE", url, expected_status, **kwargs)
@@ -1,4 +1,5 @@
1
1
  import random
2
+ import warnings
2
3
  from pathlib import Path
3
4
 
4
5
  from datalab_api import DatalabClient, DuplicateItemError
@@ -13,6 +14,12 @@ def import_cheminventory(filename: Path, client: DatalabClient):
13
14
 
14
15
  """
15
16
 
17
+ warnings.warn(
18
+ DeprecationWarning(
19
+ "This helper is deprecated in favour of the full-fledged plugin at: https://github.com/datalab-industries/datalab-cheminventory-plugin"
20
+ )
21
+ )
22
+
16
23
  def _generate_random_startingmaterial_id():
17
24
  """Generate 'XX' + a random 15-length string for use as an id for starting materials
18
25
  that don't have a barcode.
@@ -0,0 +1,79 @@
1
+ import functools
2
+
3
+ from rich.console import Console
4
+ from rich.pretty import pprint
5
+ from rich.table import Table
6
+
7
+ __all__ = ("AutoPrettyPrint", "bokeh_from_json", "pretty_displayer")
8
+
9
+
10
+ def pretty_displayer(method):
11
+ """A decorator which wraps a method with a 'display' kwarg, which will
12
+ either pretty print a JSON response, display a Rich table, or render
13
+ a bokeh plot from its JSON representation.
14
+ """
15
+
16
+ @functools.wraps(method)
17
+ def rich_wrapper(self, *args, **kwargs):
18
+ display = kwargs.pop("display", False)
19
+ page_limit = kwargs.pop("page_limit", 10)
20
+ result = method(self, *args, **kwargs)
21
+ if display:
22
+ blocks = None
23
+ if isinstance(result, dict):
24
+ if "blocks_obj" in result:
25
+ blocks = result["blocks_obj"]
26
+ elif "blocktype" in result:
27
+ blocks = {result["block_id"]: result}
28
+ result = None
29
+ if blocks:
30
+ for block in blocks.values():
31
+ if "bokeh_plot_data" in block:
32
+ bokeh_from_json(block)
33
+ if result:
34
+ pprint(result, max_length=None, max_string=100, max_depth=3)
35
+ elif isinstance(result, list):
36
+ try:
37
+ table = Table(show_lines=True)
38
+ table.add_column("type", overflow="crop", justify="center")
39
+ table.add_column("ID", style="cyan", no_wrap=True)
40
+ table.add_column("refcode", style="magenta", no_wrap=True)
41
+ table.add_column("name", width=30, overflow="ellipsis", no_wrap=True)
42
+ for item in result[:page_limit]:
43
+ table.add_row(
44
+ item["type"][0].upper(),
45
+ item["item_id"],
46
+ item["refcode"],
47
+ item["name"],
48
+ end_section=True,
49
+ )
50
+ console = Console()
51
+ console.print(table)
52
+ except Exception:
53
+ pprint(result, max_length=None, max_string=100, max_depth=3)
54
+
55
+ return result
56
+
57
+ return rich_wrapper
58
+
59
+
60
+ class AutoPrettyPrint(type):
61
+ """A metaclass that automatically applies the pretty_displayer decorator."""
62
+
63
+ def __new__(cls, name, bases, dct):
64
+ for attr, value in dct.items():
65
+ if callable(value) and not attr.startswith("__"):
66
+ dct[attr] = pretty_displayer(value)
67
+ return super().__new__(cls, name, bases, dct)
68
+
69
+
70
+ def bokeh_from_json(block_data, show=True):
71
+ from bokeh.io import curdoc
72
+ from bokeh.plotting import show as bokeh_show
73
+
74
+ bokeh_plot_data = block_data.get("bokeh_plot_data", block_data)
75
+ curdoc().replace_with_json(bokeh_plot_data["doc"])
76
+ if show:
77
+ bokeh_show(curdoc().roots[0])
78
+
79
+ return curdoc()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datalab-api
3
- Version: 0.2.13
3
+ Version: 0.3.0
4
4
  Summary: A Python API for instances of the datalab data management platform (https://github.com/datalab-org/datalab).
5
5
  Author-email: Matthew Evans <matthew@ml-evs.science>
6
6
  License-Expression: MIT
@@ -20,7 +20,7 @@ Classifier: Topic :: Scientific/Engineering
20
20
  Requires-Python: >=3.9
21
21
  Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
- Requires-Dist: httpx~=0.27
23
+ Requires-Dist: httpx~=0.28
24
24
  Requires-Dist: bokeh~=2.4; platform_system != "Emscripten"
25
25
  Requires-Dist: rich~=13.0
26
26
  Requires-Dist: numpy<2
@@ -28,7 +28,7 @@ Provides-Extra: dev
28
28
  Requires-Dist: pre-commit<5,>=3; extra == "dev"
29
29
  Requires-Dist: pytest~=8.0; extra == "dev"
30
30
  Requires-Dist: pytest-cov<7,>=5; extra == "dev"
31
- Requires-Dist: respx~=0.21; extra == "dev"
31
+ Requires-Dist: respx~=0.22; extra == "dev"
32
32
  Provides-Extra: docs
33
33
  Requires-Dist: mkdocs; extra == "docs"
34
34
  Requires-Dist: mkdocs-material; extra == "docs"
@@ -5,6 +5,7 @@ src/datalab_api/__init__.py
5
5
  src/datalab_api/_base.py
6
6
  src/datalab_api/cli.py
7
7
  src/datalab_api/helpers.py
8
+ src/datalab_api/utils.py
8
9
  src/datalab_api.egg-info/PKG-INFO
9
10
  src/datalab_api.egg-info/SOURCES.txt
10
11
  src/datalab_api.egg-info/dependency_links.txt
@@ -1,4 +1,4 @@
1
- httpx~=0.27
1
+ httpx~=0.28
2
2
  rich~=13.0
3
3
  numpy<2
4
4
 
@@ -20,7 +20,7 @@ click-shell~=2.1
20
20
  pre-commit<5,>=3
21
21
  pytest~=8.0
22
22
  pytest-cov<7,>=5
23
- respx~=0.21
23
+ respx~=0.22
24
24
 
25
25
  [docs]
26
26
  mkdocs
@@ -0,0 +1,60 @@
1
+ import pytest
2
+ import respx
3
+
4
+ from datalab_api import DatalabClient
5
+ from datalab_api._base import DatalabAPIError
6
+
7
+
8
+ def test_redirect_url(mocked_api, mocked_ui, fake_api_url, fake_ui_url):
9
+ with pytest.warns(
10
+ UserWarning,
11
+ match="^Found API URL https://api.datalab.industries in HTML meta tag. Creating client with this URL instead.$",
12
+ ):
13
+ client = DatalabClient(fake_ui_url)
14
+
15
+ assert mocked_ui["ui-redirect"].called
16
+ assert mocked_api["info"].called
17
+ assert mocked_api["info-blocks"].called
18
+ assert client.datalab_api_url == fake_api_url
19
+
20
+
21
+ def test_sample_operations(mocked_api, fake_api_url):
22
+ with DatalabClient(fake_api_url) as client:
23
+ assert mocked_api["api"].called
24
+ assert mocked_api["info"].called
25
+ assert mocked_api["info-blocks"].called
26
+
27
+ client.authenticate()
28
+ assert mocked_api["current_user"].called
29
+
30
+ samples = client.get_items(display=True)
31
+ assert mocked_api["samples"].called
32
+ assert len(samples)
33
+ assert samples[0]["item_id"] == "test"
34
+
35
+ sample = client.get_item("KUVEKJ", display=True)
36
+ assert mocked_api["sample-KUVEKJ"].called
37
+ assert sample["item_id"] == "KUVEKJ"
38
+
39
+ # Check that the full server error message is shown for a missing item
40
+ with pytest.raises(
41
+ DatalabAPIError,
42
+ match="No matching items for match={'item_id': 'TEST'} with current authorization",
43
+ ):
44
+ client.get_item("TEST", display=True)
45
+ assert mocked_api["sample-missing-TEST"].called
46
+
47
+ with pytest.raises(DatalabAPIError, match="KeyError"):
48
+ client.update_item(item_id="TEST", item_data={"name": "test"})
49
+ assert mocked_api["bad-save"].called
50
+
51
+
52
+ @respx.mock
53
+ def test_collection_get(fake_api_url, mocked_api):
54
+ with DatalabClient("https://api.datalab.industries") as client:
55
+ assert mocked_api["api"].called
56
+ assert mocked_api["info"].called
57
+ assert mocked_api["info-blocks"].called
58
+
59
+ collection, children = client.get_collection("test_collection", display=True)
60
+ assert mocked_api["collection-test_collection"].called
@@ -1,81 +0,0 @@
1
- import pytest
2
- import respx
3
- from httpx import Response
4
-
5
- from datalab_api import DatalabClient
6
-
7
-
8
- @respx.mock
9
- def test_redirect_url(fake_ui_html, fake_info_json, fake_block_info_json):
10
- fake_ui = respx.get("https://ui.datalab.industries").mock(
11
- return_value=Response(200, content=fake_ui_html)
12
- )
13
- fake_info_api = respx.get("https://api.datalab.industries/info").mock(
14
- return_value=Response(200, json=fake_info_json)
15
- )
16
- fake_block_info_api = respx.get("https://api.datalab.industries/info/blocks").mock(
17
- return_value=Response(200, json=fake_block_info_json)
18
- )
19
- with pytest.warns(
20
- UserWarning,
21
- match="^Found API URL https://api.datalab.industries in HTML meta tag. Creating client with this URL instead.$",
22
- ):
23
- client = DatalabClient("https://ui.datalab.industries")
24
- assert fake_ui.called
25
- assert fake_info_api.called
26
- assert fake_block_info_api.called
27
- assert client.datalab_api_url == "https://api.datalab.industries"
28
-
29
-
30
- @respx.mock
31
- def test_sample_list(fake_samples_json, fake_info_json, fake_sample_json, fake_block_info_json):
32
- fake_api = respx.get("https://api.datalab.industries/").mock(
33
- return_value=Response(200, content="<!doctype html></html>")
34
- )
35
- fake_samples_api = respx.get("https://api.datalab.industries/samples").mock(
36
- return_value=Response(200, json=fake_samples_json)
37
- )
38
- fake_info_api = respx.get("https://api.datalab.industries/info").mock(
39
- return_value=Response(200, json=fake_info_json)
40
- )
41
- fake_block_info_api = respx.get("https://api.datalab.industries/info/blocks").mock(
42
- return_value=Response(200, json=fake_block_info_json)
43
- )
44
- fake_item_api = respx.get("https://api.datalab.industries/get-item-data/KUVEKJ").mock(
45
- return_value=Response(200, json=fake_sample_json)
46
- )
47
- with DatalabClient("https://api.datalab.industries") as client:
48
- samples = client.get_items(display=True)
49
- assert fake_samples_api.called
50
- assert fake_api.called
51
- assert fake_info_api.called
52
- assert fake_block_info_api.called
53
- assert len(samples)
54
- assert samples[0]["item_id"] == "test"
55
- sample = client.get_item("KUVEKJ", display=True)
56
- assert fake_item_api.called
57
- assert sample["item_id"] == "KUVEKJ"
58
-
59
-
60
- @respx.mock
61
- def test_collection_get(fake_info_json, fake_block_info_json, fake_collection_json):
62
- fake_api = respx.get("https://api.datalab.industries/").mock(
63
- return_value=Response(200, content="<!doctype html></html>")
64
- )
65
- fake_info_api = respx.get("https://api.datalab.industries/info").mock(
66
- return_value=Response(200, json=fake_info_json)
67
- )
68
- fake_block_info_api = respx.get("https://api.datalab.industries/info/blocks").mock(
69
- return_value=Response(200, json=fake_block_info_json)
70
- )
71
-
72
- fake_collection_api = respx.get(
73
- "https://api.datalab.industries/collections/test_collection"
74
- ).mock(return_value=Response(200, json=fake_collection_json))
75
-
76
- with DatalabClient("https://api.datalab.industries") as client:
77
- collection, children = client.get_collection("test_collection")
78
- assert fake_api.called
79
- assert fake_info_api.called
80
- assert fake_block_info_api.called
81
- assert fake_collection_api.called
File without changes
File without changes
File without changes