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.
- {datalab_api-0.2.12/src/datalab_api.egg-info → datalab_api-0.3.0}/PKG-INFO +3 -3
- {datalab_api-0.2.12 → datalab_api-0.3.0}/pyproject.toml +2 -2
- {datalab_api-0.2.12 → datalab_api-0.3.0}/src/datalab_api/__init__.py +32 -134
- {datalab_api-0.2.12 → datalab_api-0.3.0}/src/datalab_api/_base.py +144 -71
- {datalab_api-0.2.12 → datalab_api-0.3.0}/src/datalab_api/helpers.py +7 -0
- datalab_api-0.3.0/src/datalab_api/utils.py +79 -0
- {datalab_api-0.2.12 → datalab_api-0.3.0/src/datalab_api.egg-info}/PKG-INFO +3 -3
- {datalab_api-0.2.12 → datalab_api-0.3.0}/src/datalab_api.egg-info/SOURCES.txt +1 -0
- {datalab_api-0.2.12 → datalab_api-0.3.0}/src/datalab_api.egg-info/requires.txt +2 -2
- datalab_api-0.3.0/tests/test_client.py +60 -0
- datalab_api-0.2.12/tests/test_client.py +0 -81
- {datalab_api-0.2.12 → datalab_api-0.3.0}/LICENSE +0 -0
- {datalab_api-0.2.12 → datalab_api-0.3.0}/README.md +0 -0
- {datalab_api-0.2.12 → datalab_api-0.3.0}/setup.cfg +0 -0
- {datalab_api-0.2.12 → datalab_api-0.3.0}/src/datalab_api/cli.py +0 -0
- {datalab_api-0.2.12 → datalab_api-0.3.0}/src/datalab_api.egg-info/dependency_links.txt +0 -0
- {datalab_api-0.2.12 → datalab_api-0.3.0}/src/datalab_api.egg-info/entry_points.txt +0 -0
- {datalab_api-0.2.12 → datalab_api-0.3.0}/src/datalab_api.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: datalab-api
|
|
3
|
-
Version: 0.
|
|
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.
|
|
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.
|
|
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
|
+
"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.
|
|
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
|
-
|
|
42
|
-
self.info =
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
263
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
290
|
+
upload = self._post(
|
|
360
291
|
upload_url,
|
|
361
292
|
files=files,
|
|
362
293
|
data={"item_id": item_id, "replace_file": None},
|
|
363
|
-
|
|
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
|
-
|
|
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.
|
|
486
|
-
|
|
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
|
-
|
|
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
|
-
|
|
437
|
+
created_collection = self._put(
|
|
533
438
|
collection_url,
|
|
534
439
|
json={"data": new_collection},
|
|
535
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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"
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|