datalab-api 0.2.12__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.12
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(
@@ -254,19 +200,12 @@ class DatalabClient(BaseDatalabClient):
254
200
  raise ValueError("Must provide only one of `item_id` or `refcode`.")
255
201
 
256
202
  if refcode is not None:
257
- raise NotImplementedError("Searching by `refcode` is not yet implemented.")
258
-
259
- item_url = f"{self.datalab_api_url}/get-item-data/{item_id}"
260
- item_resp = self.session.get(item_url, follow_redirects=True)
203
+ item_url = f"{self.datalab_api_url}/items/{refcode}"
261
204
 
262
- if item_resp.status_code != 200:
263
- raise RuntimeError(
264
- f"Failed to find item {item_id=}, {refcode=} {item_url}: {item_resp.status_code=}. Check the item information is correct."
265
- )
205
+ else:
206
+ item_url = f"{self.datalab_api_url}/get-item-data/{item_id}"
266
207
 
267
- item = item_resp.json()
268
- if item["status"] != "success":
269
- raise RuntimeError(f"Failed to get item at {item_url}: {item['status']!r}.")
208
+ item = self._get(item_url)
270
209
 
271
210
  # Filter out any deleted blocks
272
211
  item["item_data"]["blocks_obj"] = {
@@ -326,15 +265,7 @@ class DatalabClient(BaseDatalabClient):
326
265
  "block_id": block_id,
327
266
  "save_to_db": False,
328
267
  }
329
- block_resp = self.session.post(block_url, json=block_request, follow_redirects=True)
330
- if block_resp.status_code != 200:
331
- raise RuntimeError(
332
- f"Failed to find block {block_id=} for item {item_id=} at {block_url}: {block_resp.status_code=}. Check the block information is correct."
333
- )
334
-
335
- block = block_resp.json()
336
- if block["status"] != "success":
337
- raise RuntimeError(f"Failed to get block at {block_url}: {block['status']!r}.")
268
+ block = self._post(block_url, json=block_request)
338
269
  return block["new_block_data"]
339
270
 
340
271
  def upload_file(self, item_id: str, file_path: Path | str) -> dict[str, Any]:
@@ -356,21 +287,12 @@ class DatalabClient(BaseDatalabClient):
356
287
  upload_url = f"{self.datalab_api_url}/upload-file/"
357
288
  with open(file_path, "rb") as file:
358
289
  files = {"file": (file_path.name, file)}
359
- upload_resp = self.session.post(
290
+ upload = self._post(
360
291
  upload_url,
361
292
  files=files,
362
293
  data={"item_id": item_id, "replace_file": None},
363
- follow_redirects=True,
364
- )
365
- if upload_resp.status_code != 201:
366
- raise RuntimeError(
367
- 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,
368
295
  )
369
-
370
- upload = upload_resp.json()
371
- if upload["status"] != "success":
372
- raise RuntimeError(f"Failed to upload file at {upload_url}: {upload['status']!r}.")
373
-
374
296
  return upload
375
297
 
376
298
  def create_data_block(
@@ -420,12 +342,7 @@ class DatalabClient(BaseDatalabClient):
420
342
  f"Not all IDs {file_ids=} are attached to item {item_id=}: {attached_file_ids=}"
421
343
  )
422
344
 
423
- block_resp = self.session.post(blocks_url, json=payload, follow_redirects=True)
424
- if block_resp.status_code != 200:
425
- raise RuntimeError(
426
- f"Failed to create block {block_type=} for item {item_id=}:\n{block_resp.text}"
427
- )
428
- block_data = block_resp.json()["new_block_obj"]
345
+ block_data = self._post(blocks_url, json=payload)["new_block_obj"]
429
346
 
430
347
  if file_ids:
431
348
  block_data = self._update_data_block(
@@ -482,11 +399,8 @@ class DatalabClient(BaseDatalabClient):
482
399
  "save_to_db": True,
483
400
  }
484
401
 
485
- resp = self.session.post(blocks_url, json=payload, follow_redirects=True)
486
- if resp.status_code != 200:
487
- raise RuntimeError(f"Failed to update block {block_type=}:\n{resp.text}")
488
-
489
- return resp.json()["new_block_data"]
402
+ resp = self._post(blocks_url, json=payload)
403
+ return resp["new_block_data"]
490
404
 
491
405
  def get_collection(self, collection_id: str) -> tuple[dict[str, Any], list[dict[str, Any]]]:
492
406
  """Get a collection with a given ID.
@@ -500,16 +414,7 @@ class DatalabClient(BaseDatalabClient):
500
414
 
501
415
  """
502
416
  collection_url = f"{self.datalab_api_url}/collections/{collection_id}"
503
- collection_resp = self.session.get(collection_url, follow_redirects=True)
504
- if collection_resp.status_code != 200:
505
- raise RuntimeError(
506
- f"Failed to find collection {collection_id=} at {collection_url}: {collection_resp.status_code=}. Check the collection ID is correct."
507
- )
508
- collection = collection_resp.json()
509
- if collection["status"] != "success":
510
- raise RuntimeError(
511
- f"Failed to get collection at {collection_url}: {collection['status']!r}."
512
- )
417
+ collection = self._get(collection_url)
513
418
  return collection["data"], collection["child_items"]
514
419
 
515
420
  def create_collection(
@@ -529,16 +434,9 @@ class DatalabClient(BaseDatalabClient):
529
434
  new_collection = collection_data
530
435
  new_collection.update({"collection_id": collection_id, "type": "collections"})
531
436
 
532
- collection_resp = self.session.put(
437
+ created_collection = self._put(
533
438
  collection_url,
534
439
  json={"data": new_collection},
535
- follow_redirects=True,
440
+ expected_status=201,
536
441
  )
537
-
538
- if collection_resp.status_code != 201 or collection_resp.json()["status"] != "success":
539
- raise RuntimeError(
540
- f"Failed to create collection {collection_id=} at {collection_url}: {collection_resp.status_code=}. Check the collection information is correct: {collection_resp.content}"
541
- )
542
-
543
- created_collection = collection_resp.json()["data"]
544
- 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.12
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