datalab-api 0.2.13__tar.gz → 0.3.1__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.13/src/datalab_api.egg-info → datalab_api-0.3.1}/PKG-INFO +5 -5
- {datalab_api-0.2.13 → datalab_api-0.3.1}/pyproject.toml +4 -4
- {datalab_api-0.2.13 → datalab_api-0.3.1}/src/datalab_api/__init__.py +30 -133
- {datalab_api-0.2.13 → datalab_api-0.3.1}/src/datalab_api/_base.py +144 -71
- {datalab_api-0.2.13 → datalab_api-0.3.1}/src/datalab_api/helpers.py +7 -0
- datalab_api-0.3.1/src/datalab_api/utils.py +79 -0
- {datalab_api-0.2.13 → datalab_api-0.3.1/src/datalab_api.egg-info}/PKG-INFO +5 -5
- {datalab_api-0.2.13 → datalab_api-0.3.1}/src/datalab_api.egg-info/SOURCES.txt +1 -0
- {datalab_api-0.2.13 → datalab_api-0.3.1}/src/datalab_api.egg-info/requires.txt +4 -4
- datalab_api-0.3.1/tests/test_client.py +60 -0
- datalab_api-0.2.13/tests/test_client.py +0 -81
- {datalab_api-0.2.13 → datalab_api-0.3.1}/LICENSE +0 -0
- {datalab_api-0.2.13 → datalab_api-0.3.1}/README.md +0 -0
- {datalab_api-0.2.13 → datalab_api-0.3.1}/setup.cfg +0 -0
- {datalab_api-0.2.13 → datalab_api-0.3.1}/src/datalab_api/cli.py +0 -0
- {datalab_api-0.2.13 → datalab_api-0.3.1}/src/datalab_api.egg-info/dependency_links.txt +0 -0
- {datalab_api-0.2.13 → datalab_api-0.3.1}/src/datalab_api.egg-info/entry_points.txt +0 -0
- {datalab_api-0.2.13 → datalab_api-0.3.1}/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.1
|
|
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,15 +20,15 @@ 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
|
-
Requires-Dist: rich
|
|
25
|
+
Requires-Dist: rich<15,>=13
|
|
26
26
|
Requires-Dist: numpy<2
|
|
27
27
|
Provides-Extra: dev
|
|
28
28
|
Requires-Dist: pre-commit<5,>=3; extra == "dev"
|
|
29
29
|
Requires-Dist: pytest~=8.0; extra == "dev"
|
|
30
|
-
Requires-Dist: pytest-cov<
|
|
31
|
-
Requires-Dist: respx~=0.
|
|
30
|
+
Requires-Dist: pytest-cov<8,>=5; 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,9 +24,9 @@ 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
|
-
"rich
|
|
29
|
+
"rich >= 13,< 15", # nicer terminal output
|
|
30
30
|
"numpy < 2", # upper pin for numpy due to bad pins in bokeh
|
|
31
31
|
]
|
|
32
32
|
|
|
@@ -35,8 +35,8 @@ dependencies = [
|
|
|
35
35
|
dev = [
|
|
36
36
|
"pre-commit >= 3,< 5",
|
|
37
37
|
"pytest ~= 8.0",
|
|
38
|
-
"pytest-cov >= 5,<
|
|
39
|
-
"respx ~= 0.
|
|
38
|
+
"pytest-cov >= 5,< 8",
|
|
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,12 @@ 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
|
-
|
|
160
|
+
expected_status=201,
|
|
184
161
|
)
|
|
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
|
|
162
|
+
return created_item["sample_list_entry"]
|
|
203
163
|
|
|
204
164
|
def update_item(self, item_id: str, item_data: dict[str, Any]) -> dict[str, Any]:
|
|
205
165
|
"""Update an item with the given item data.
|
|
@@ -214,20 +174,7 @@ class DatalabClient(BaseDatalabClient):
|
|
|
214
174
|
"""
|
|
215
175
|
update_item_data = {"item_id": item_id, "data": item_data}
|
|
216
176
|
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
|
-
)
|
|
177
|
+
updated_item = self._post(update_item_url, json=update_item_data)
|
|
231
178
|
return updated_item
|
|
232
179
|
|
|
233
180
|
def get_item(
|
|
@@ -259,16 +206,7 @@ class DatalabClient(BaseDatalabClient):
|
|
|
259
206
|
else:
|
|
260
207
|
item_url = f"{self.datalab_api_url}/get-item-data/{item_id}"
|
|
261
208
|
|
|
262
|
-
|
|
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}.")
|
|
209
|
+
item = self._get(item_url)
|
|
272
210
|
|
|
273
211
|
# Filter out any deleted blocks
|
|
274
212
|
item["item_data"]["blocks_obj"] = {
|
|
@@ -328,15 +266,7 @@ class DatalabClient(BaseDatalabClient):
|
|
|
328
266
|
"block_id": block_id,
|
|
329
267
|
"save_to_db": False,
|
|
330
268
|
}
|
|
331
|
-
|
|
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}.")
|
|
269
|
+
block = self._post(block_url, json=block_request)
|
|
340
270
|
return block["new_block_data"]
|
|
341
271
|
|
|
342
272
|
def upload_file(self, item_id: str, file_path: Path | str) -> dict[str, Any]:
|
|
@@ -358,21 +288,12 @@ class DatalabClient(BaseDatalabClient):
|
|
|
358
288
|
upload_url = f"{self.datalab_api_url}/upload-file/"
|
|
359
289
|
with open(file_path, "rb") as file:
|
|
360
290
|
files = {"file": (file_path.name, file)}
|
|
361
|
-
|
|
291
|
+
upload = self._post(
|
|
362
292
|
upload_url,
|
|
363
293
|
files=files,
|
|
364
294
|
data={"item_id": item_id, "replace_file": None},
|
|
365
|
-
|
|
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."
|
|
295
|
+
expected_status=201,
|
|
370
296
|
)
|
|
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
297
|
return upload
|
|
377
298
|
|
|
378
299
|
def create_data_block(
|
|
@@ -422,12 +343,7 @@ class DatalabClient(BaseDatalabClient):
|
|
|
422
343
|
f"Not all IDs {file_ids=} are attached to item {item_id=}: {attached_file_ids=}"
|
|
423
344
|
)
|
|
424
345
|
|
|
425
|
-
|
|
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"]
|
|
346
|
+
block_data = self._post(blocks_url, json=payload)["new_block_obj"]
|
|
431
347
|
|
|
432
348
|
if file_ids:
|
|
433
349
|
block_data = self._update_data_block(
|
|
@@ -484,11 +400,8 @@ class DatalabClient(BaseDatalabClient):
|
|
|
484
400
|
"save_to_db": True,
|
|
485
401
|
}
|
|
486
402
|
|
|
487
|
-
resp = self.
|
|
488
|
-
|
|
489
|
-
raise RuntimeError(f"Failed to update block {block_type=}:\n{resp.text}")
|
|
490
|
-
|
|
491
|
-
return resp.json()["new_block_data"]
|
|
403
|
+
resp = self._post(blocks_url, json=payload)
|
|
404
|
+
return resp["new_block_data"]
|
|
492
405
|
|
|
493
406
|
def get_collection(self, collection_id: str) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
494
407
|
"""Get a collection with a given ID.
|
|
@@ -502,16 +415,7 @@ class DatalabClient(BaseDatalabClient):
|
|
|
502
415
|
|
|
503
416
|
"""
|
|
504
417
|
collection_url = f"{self.datalab_api_url}/collections/{collection_id}"
|
|
505
|
-
|
|
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
|
-
)
|
|
418
|
+
collection = self._get(collection_url)
|
|
515
419
|
return collection["data"], collection["child_items"]
|
|
516
420
|
|
|
517
421
|
def create_collection(
|
|
@@ -531,16 +435,9 @@ class DatalabClient(BaseDatalabClient):
|
|
|
531
435
|
new_collection = collection_data
|
|
532
436
|
new_collection.update({"collection_id": collection_id, "type": "collections"})
|
|
533
437
|
|
|
534
|
-
|
|
438
|
+
created_collection = self._put(
|
|
535
439
|
collection_url,
|
|
536
440
|
json={"data": new_collection},
|
|
537
|
-
|
|
441
|
+
expected_status=201,
|
|
538
442
|
)
|
|
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
|
|
443
|
+
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.1
|
|
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,15 +20,15 @@ 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
|
-
Requires-Dist: rich
|
|
25
|
+
Requires-Dist: rich<15,>=13
|
|
26
26
|
Requires-Dist: numpy<2
|
|
27
27
|
Provides-Extra: dev
|
|
28
28
|
Requires-Dist: pre-commit<5,>=3; extra == "dev"
|
|
29
29
|
Requires-Dist: pytest~=8.0; extra == "dev"
|
|
30
|
-
Requires-Dist: pytest-cov<
|
|
31
|
-
Requires-Dist: respx~=0.
|
|
30
|
+
Requires-Dist: pytest-cov<8,>=5; 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"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
httpx~=0.
|
|
2
|
-
rich
|
|
1
|
+
httpx~=0.28
|
|
2
|
+
rich<15,>=13
|
|
3
3
|
numpy<2
|
|
4
4
|
|
|
5
5
|
[:platform_system != "Emscripten"]
|
|
@@ -19,8 +19,8 @@ click-shell~=2.1
|
|
|
19
19
|
[dev]
|
|
20
20
|
pre-commit<5,>=3
|
|
21
21
|
pytest~=8.0
|
|
22
|
-
pytest-cov<
|
|
23
|
-
respx~=0.
|
|
22
|
+
pytest-cov<8,>=5
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|