datalab-api 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- datalab_api/__init__.py +371 -0
- datalab_api/_base.py +200 -0
- datalab_api/cli.py +195 -0
- datalab_api-0.2.0.dist-info/LICENSE +21 -0
- datalab_api-0.2.0.dist-info/METADATA +162 -0
- datalab_api-0.2.0.dist-info/RECORD +9 -0
- datalab_api-0.2.0.dist-info/WHEEL +5 -0
- datalab_api-0.2.0.dist-info/entry_points.txt +2 -0
- datalab_api-0.2.0.dist-info/top_level.txt +1 -0
datalab_api/__init__.py
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Any, Optional, Union
|
|
3
|
+
|
|
4
|
+
from ._base import BaseDatalabClient, __version__
|
|
5
|
+
|
|
6
|
+
__all__ = ("__version__", "DatalabClient")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DatalabClient(BaseDatalabClient):
|
|
10
|
+
"""A client for the Datalab API.
|
|
11
|
+
|
|
12
|
+
The client will keep an HTTP session open for the duration of its life and thus is
|
|
13
|
+
amenable for use as a context manager, e.g.,
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
with DatalabClient("https://public.api.odbx.science") as client:
|
|
17
|
+
client.get_items()
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def get_info(self) -> dict[str, Any]:
|
|
23
|
+
"""Fetch metadata associated with this datalab instance.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
dict: The JSON response from the `/info` endpoint of the Datalab API.
|
|
27
|
+
|
|
28
|
+
"""
|
|
29
|
+
info_url = f"{self.datalab_api_url}/info"
|
|
30
|
+
info_resp = self.session.get(info_url, follow_redirects=True)
|
|
31
|
+
return info_resp.json()
|
|
32
|
+
|
|
33
|
+
def authenticate(self):
|
|
34
|
+
"""Tests authentication of the client with the Datalab API."""
|
|
35
|
+
user_url = f"{self.datalab_api_url}/get-current-user"
|
|
36
|
+
user_resp = self.session.get(user_url, follow_redirects=True)
|
|
37
|
+
if user_resp.status_code != 200:
|
|
38
|
+
raise RuntimeError(
|
|
39
|
+
f"Failed to authenticate to {self.datalab_api_url!r}: {user_resp.status_code=} from {self._headers}. Please check your API key."
|
|
40
|
+
)
|
|
41
|
+
return user_resp.json()
|
|
42
|
+
|
|
43
|
+
def get_items(self, item_type: Optional[str] = "samples") -> list[dict[str, Any]]:
|
|
44
|
+
"""List items of the given type available to the authenticated user.
|
|
45
|
+
|
|
46
|
+
Parameters:
|
|
47
|
+
item_type: The type of item to list. Defaults to "samples". Other
|
|
48
|
+
choices will trigger a call to the `/<item_type>` endpoint of the API.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
A list of items of the given type.
|
|
52
|
+
|
|
53
|
+
"""
|
|
54
|
+
if item_type is None:
|
|
55
|
+
item_type = "samples"
|
|
56
|
+
items_url = f"{self.datalab_api_url}/{item_type}"
|
|
57
|
+
items_resp = self.session.get(items_url, follow_redirects=True)
|
|
58
|
+
if items_resp.status_code != 200:
|
|
59
|
+
raise RuntimeError(
|
|
60
|
+
f"Failed to list items with {item_type=} at {items_url}: {items_resp.status_code=}. Check the item type is correct."
|
|
61
|
+
)
|
|
62
|
+
items = items_resp.json()
|
|
63
|
+
if items["status"] != "success":
|
|
64
|
+
raise RuntimeError(f"Failed to list items at {items_url}: {items['status']!r}.")
|
|
65
|
+
return items[item_type]
|
|
66
|
+
|
|
67
|
+
def search_items(
|
|
68
|
+
self, query: str, item_types: Union[list[str], str] = ["samples", "cells"]
|
|
69
|
+
) -> list[dict[str, Any]]:
|
|
70
|
+
"""Search for items of the given types that match the query.
|
|
71
|
+
|
|
72
|
+
Parameters:
|
|
73
|
+
query: Free-text query to search for.
|
|
74
|
+
item_types: The types of items to search for. Defaults to ["samples", "cells"].
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
An ordered list of items of the given types that match the query.
|
|
78
|
+
|
|
79
|
+
"""
|
|
80
|
+
if isinstance(item_types, str):
|
|
81
|
+
item_types = [item_types]
|
|
82
|
+
|
|
83
|
+
search_items_url = (
|
|
84
|
+
f"{self.datalab_api_url}/search-items?query={query}&types={','.join(item_types)}"
|
|
85
|
+
)
|
|
86
|
+
items_resp = self.session.get(search_items_url, follow_redirects=True)
|
|
87
|
+
if items_resp.status_code != 200:
|
|
88
|
+
raise RuntimeError(
|
|
89
|
+
f"Failed to search items with {item_types=} at {search_items_url}: {items_resp.status_code=}"
|
|
90
|
+
)
|
|
91
|
+
items = items_resp.json()
|
|
92
|
+
if items["status"] != "success":
|
|
93
|
+
raise RuntimeError(f"Failed to list items at {search_items_url}: {items['status']!r}.")
|
|
94
|
+
|
|
95
|
+
return items["items"]
|
|
96
|
+
|
|
97
|
+
def create_item(
|
|
98
|
+
self, item_id: str, item_type: str, item_data: Optional[dict[str, Any]] = None
|
|
99
|
+
) -> dict[str, Any]:
|
|
100
|
+
"""Create an item with a given ID and item data.
|
|
101
|
+
|
|
102
|
+
Parameters:
|
|
103
|
+
item_id: The ID of the item to create.
|
|
104
|
+
item_type: The type of item to create, e.g., 'samples', 'cells'.
|
|
105
|
+
item_data: The data for the item.
|
|
106
|
+
|
|
107
|
+
"""
|
|
108
|
+
new_item = {}
|
|
109
|
+
if item_data is not None:
|
|
110
|
+
new_item = item_data
|
|
111
|
+
new_item.update(**{"item_id": item_id, "type": item_type})
|
|
112
|
+
|
|
113
|
+
create_item_url = f"{self.datalab_api_url}/new-sample/"
|
|
114
|
+
create_item_resp = self.session.post(
|
|
115
|
+
create_item_url,
|
|
116
|
+
json=new_item,
|
|
117
|
+
follow_redirects=True,
|
|
118
|
+
)
|
|
119
|
+
try:
|
|
120
|
+
created_item = create_item_resp.json()
|
|
121
|
+
if created_item["status"] != "success":
|
|
122
|
+
raise RuntimeError(
|
|
123
|
+
f"Failed to create item at {create_item_url}: {created_item['status']!r}."
|
|
124
|
+
)
|
|
125
|
+
return created_item["sample_list_entry"]
|
|
126
|
+
|
|
127
|
+
except Exception:
|
|
128
|
+
raise RuntimeError(
|
|
129
|
+
f"Failed to create item {item_id=} with data {item_data=} at {create_item_url}: {create_item_resp.status_code=}. Check the item information is correct."
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def update_item(self, item_id: str, item_data: dict[str, Any]) -> dict[str, Any]:
|
|
133
|
+
"""Update an item with the given item data.
|
|
134
|
+
|
|
135
|
+
Parameters:
|
|
136
|
+
item_id: The ID of the item to update.
|
|
137
|
+
item_data: The new data for the item.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
A dictionary of the updated item data.
|
|
141
|
+
|
|
142
|
+
"""
|
|
143
|
+
update_item_data = {"item_id": item_id, "data": item_data}
|
|
144
|
+
update_item_url = f"{self.datalab_api_url}/save-item/"
|
|
145
|
+
update_item_resp = self.session.post(
|
|
146
|
+
update_item_url,
|
|
147
|
+
json=update_item_data,
|
|
148
|
+
follow_redirects=True,
|
|
149
|
+
)
|
|
150
|
+
if update_item_resp.status_code != 200:
|
|
151
|
+
raise RuntimeError(
|
|
152
|
+
f"Failed to update item {item_id=} at {update_item_url}: {update_item_resp.status_code=}. Check the item information is correct."
|
|
153
|
+
)
|
|
154
|
+
updated_item = update_item_resp.json()
|
|
155
|
+
if updated_item["status"] != "success":
|
|
156
|
+
raise RuntimeError(
|
|
157
|
+
f"Failed to update item at {update_item_url}: {updated_item['status']!r}."
|
|
158
|
+
)
|
|
159
|
+
return updated_item
|
|
160
|
+
|
|
161
|
+
def get_item(
|
|
162
|
+
self,
|
|
163
|
+
item_id: Optional[str] = None,
|
|
164
|
+
refcode: Optional[str] = None,
|
|
165
|
+
load_blocks: bool = False,
|
|
166
|
+
) -> dict[str, Any]:
|
|
167
|
+
"""Get an item with a given ID or refcode.
|
|
168
|
+
|
|
169
|
+
Parameters:
|
|
170
|
+
item_id: The ID of the item to search for.
|
|
171
|
+
refcode: The refcode of the item to search fork
|
|
172
|
+
load_blocks: Whether to load the blocks associated with the item.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
A dictionary of item data for the item with the given ID or refcode.
|
|
176
|
+
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
if item_id is None and refcode is None:
|
|
180
|
+
raise ValueError("Must provide one of `item_id` or `refcode`.")
|
|
181
|
+
if item_id is not None and refcode is not None:
|
|
182
|
+
raise ValueError("Must provide only one of `item_id` or `refcode`.")
|
|
183
|
+
|
|
184
|
+
if refcode is not None:
|
|
185
|
+
raise NotImplementedError("Searching by `refcode` is not yet implemented.")
|
|
186
|
+
|
|
187
|
+
item_url = f"{self.datalab_api_url}/get-item-data/{item_id}"
|
|
188
|
+
item_resp = self.session.get(item_url, follow_redirects=True)
|
|
189
|
+
|
|
190
|
+
if item_resp.status_code != 200:
|
|
191
|
+
raise RuntimeError(
|
|
192
|
+
f"Failed to find item {item_id=}, {refcode=} {item_url}: {item_resp.status_code=}. Check the item information is correct."
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
item = item_resp.json()
|
|
196
|
+
if item["status"] != "success":
|
|
197
|
+
raise RuntimeError(f"Failed to get item at {item_url}: {item['status']!r}.")
|
|
198
|
+
|
|
199
|
+
# Filter out any deleted blocks
|
|
200
|
+
item["item_data"]["blocks_obj"] = {
|
|
201
|
+
block_id: block
|
|
202
|
+
for block_id, block in item["item_data"]["blocks_obj"].items()
|
|
203
|
+
if block_id in item["item_data"]["display_order"]
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
# Make a call to `/update-block` which will parse/create plots and return as JSON
|
|
207
|
+
if load_blocks:
|
|
208
|
+
ret_item_id = item["item_data"]["item_id"]
|
|
209
|
+
for block_id, block in item["item_data"]["blocks_obj"].items():
|
|
210
|
+
block_data = self.get_block(
|
|
211
|
+
item_id=ret_item_id, block_id=block_id, block_data=block
|
|
212
|
+
)
|
|
213
|
+
item["item_data"]["blocks_obj"][block_id] = block_data
|
|
214
|
+
|
|
215
|
+
return item["item_data"]
|
|
216
|
+
|
|
217
|
+
def get_block(self, item_id: str, block_id: str, block_data: dict[str, Any]) -> dict[str, Any]:
|
|
218
|
+
"""Get a block with a given ID and block data.
|
|
219
|
+
Should be used in conjunction with `get_item` to load an existing block.
|
|
220
|
+
|
|
221
|
+
Parameters:
|
|
222
|
+
item_id: The ID of the item to search for.
|
|
223
|
+
block_id: The ID of the block to search for.
|
|
224
|
+
block_data: Any other block data required by the request.
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
A dictionary of block data for the block with the given ID.
|
|
228
|
+
|
|
229
|
+
"""
|
|
230
|
+
block_url = f"{self.datalab_api_url}/update-block/"
|
|
231
|
+
block_request = {
|
|
232
|
+
"block_data": block_data,
|
|
233
|
+
"item_id": item_id,
|
|
234
|
+
"block_id": block_id,
|
|
235
|
+
"save_to_db": False,
|
|
236
|
+
}
|
|
237
|
+
block_resp = self.session.post(block_url, json=block_request, follow_redirects=True)
|
|
238
|
+
if block_resp.status_code != 200:
|
|
239
|
+
raise RuntimeError(
|
|
240
|
+
f"Failed to find block {block_id=} for item {item_id=} at {block_url}: {block_resp.status_code=}. Check the block information is correct."
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
block = block_resp.json()
|
|
244
|
+
if block["status"] != "success":
|
|
245
|
+
raise RuntimeError(f"Failed to get block at {block_url}: {block['status']!r}.")
|
|
246
|
+
return block["new_block_data"]
|
|
247
|
+
|
|
248
|
+
def upload_file(self, item_id: str, file_path: Union[Path, str]) -> dict[str, Any]:
|
|
249
|
+
"""Upload a file to an item with a given ID.
|
|
250
|
+
|
|
251
|
+
Parameters:
|
|
252
|
+
item_id: The ID of the item to upload the file to.
|
|
253
|
+
file_path: The path to the file to upload.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
A dictionary of the uploaded file data.
|
|
257
|
+
|
|
258
|
+
"""
|
|
259
|
+
if isinstance(file_path, str):
|
|
260
|
+
file_path = Path(file_path)
|
|
261
|
+
if not file_path.exists():
|
|
262
|
+
raise FileNotFoundError(f"File {file_path=} does not exist.")
|
|
263
|
+
|
|
264
|
+
upload_url = f"{self.datalab_api_url}/upload-file/"
|
|
265
|
+
with open(file_path, "rb") as file:
|
|
266
|
+
files = {"file": (file_path.name, file)}
|
|
267
|
+
upload_resp = self.session.post(
|
|
268
|
+
upload_url,
|
|
269
|
+
files=files,
|
|
270
|
+
data={"item_id": item_id, "replace_file": None},
|
|
271
|
+
follow_redirects=True,
|
|
272
|
+
)
|
|
273
|
+
if upload_resp.status_code != 201:
|
|
274
|
+
raise RuntimeError(
|
|
275
|
+
f"Failed to upload file {file_path=} to item {item_id=} at {upload_url}: {upload_resp.status_code=}. Check the file information is correct."
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
upload = upload_resp.json()
|
|
279
|
+
if upload["status"] != "success":
|
|
280
|
+
raise RuntimeError(f"Failed to upload file at {upload_url}: {upload['status']!r}.")
|
|
281
|
+
|
|
282
|
+
return upload
|
|
283
|
+
|
|
284
|
+
def create_data_block(
|
|
285
|
+
self,
|
|
286
|
+
item_id: str,
|
|
287
|
+
block_type: str,
|
|
288
|
+
file_ids: Optional[Union[str, list[str]]] = None,
|
|
289
|
+
file_paths: Optional[Union[Path, list[Path]]] = None,
|
|
290
|
+
) -> dict[str, Any]:
|
|
291
|
+
"""Creates a data block for an item with the given block type and file ID.
|
|
292
|
+
|
|
293
|
+
Parameters:
|
|
294
|
+
item_id: The ID of the item to create the block for.
|
|
295
|
+
block_type: The type of block to create.
|
|
296
|
+
file_ids: The ID of the file to attach to the block, must already
|
|
297
|
+
be uploaded to the item.
|
|
298
|
+
file_paths: A path, or set of paths, to files to upload and attach to the
|
|
299
|
+
item. Conflicts with `file_id`, only one can be provided.
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
The created block data.
|
|
303
|
+
|
|
304
|
+
"""
|
|
305
|
+
|
|
306
|
+
blocks_url = f"{self.datalab_api_url}/add-data-block/"
|
|
307
|
+
|
|
308
|
+
# TODO: future validation of block types based on server response
|
|
309
|
+
payload = {
|
|
310
|
+
"item_id": item_id,
|
|
311
|
+
"block_type": block_type,
|
|
312
|
+
"index": None,
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if file_paths:
|
|
316
|
+
raise NotImplementedError(
|
|
317
|
+
"Simultaneously uploading files and creating blocks is not yet supported."
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
if file_ids:
|
|
321
|
+
if isinstance(file_ids, str):
|
|
322
|
+
file_ids = [file_ids]
|
|
323
|
+
# check that the file is attached to the item
|
|
324
|
+
item = self.get_item(item_id=item_id, load_blocks=False)
|
|
325
|
+
attached_file_ids = set(item["file_ObjectIds"])
|
|
326
|
+
if not set(file_ids).issubset(attached_file_ids):
|
|
327
|
+
raise RuntimeError(
|
|
328
|
+
f"Not all IDs {file_ids=} are attached to item {item_id=}: {attached_file_ids=}"
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
block_resp = self.session.post(blocks_url, json=payload, follow_redirects=True)
|
|
332
|
+
if block_resp.status_code != 200:
|
|
333
|
+
raise RuntimeError(
|
|
334
|
+
f"Failed to create block {block_type=} for item {item_id=}:\n{block_resp.text}"
|
|
335
|
+
)
|
|
336
|
+
block_data = block_resp.json()["new_block_obj"]
|
|
337
|
+
|
|
338
|
+
if file_ids:
|
|
339
|
+
block_data = self._update_data_block(
|
|
340
|
+
block_type=block_type, block_data=block_data, file_ids=file_ids
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
return block_data
|
|
344
|
+
|
|
345
|
+
def _update_data_block(
|
|
346
|
+
self, block_type: str, block_data: dict, file_ids: Optional[Union[str, list[str]]] = None
|
|
347
|
+
) -> dict[str, Any]:
|
|
348
|
+
"""Attaches files to blocks: should only be used via wrapper methods."""
|
|
349
|
+
if file_ids and isinstance(file_ids, str):
|
|
350
|
+
file_ids = [file_ids]
|
|
351
|
+
|
|
352
|
+
if file_ids:
|
|
353
|
+
if len(file_ids) > 1:
|
|
354
|
+
raise RuntimeError(
|
|
355
|
+
"API does not currently support attaching multiple files in a block."
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
block_data["file_id"] = file_ids[0]
|
|
359
|
+
|
|
360
|
+
blocks_url = f"{self.datalab_api_url}/update-block/"
|
|
361
|
+
payload = {
|
|
362
|
+
"block_data": block_data,
|
|
363
|
+
"block_type": block_type,
|
|
364
|
+
"save_to_db": True,
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
resp = self.session.post(blocks_url, json=payload, follow_redirects=True)
|
|
368
|
+
if resp.status_code != 200:
|
|
369
|
+
raise RuntimeError(f"Failed to update block {block_type=}:\n{resp.text}")
|
|
370
|
+
|
|
371
|
+
return resp.json()["new_block_data"]
|
datalab_api/_base.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
from importlib.metadata import version
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from rich.logging import RichHandler
|
|
9
|
+
from rich.pretty import pprint
|
|
10
|
+
|
|
11
|
+
__version__ = version("datalab-api")
|
|
12
|
+
|
|
13
|
+
__all__ = ("__version__", "BaseDatalabClient")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def pretty_displayer(method):
|
|
17
|
+
@functools.wraps(method)
|
|
18
|
+
def rich_wrapper(self, *args, **kwargs):
|
|
19
|
+
display = kwargs.pop("display", False)
|
|
20
|
+
result = method(self, *args, **kwargs)
|
|
21
|
+
if display:
|
|
22
|
+
if isinstance(result, dict) and "blocks_obj" in result:
|
|
23
|
+
blocks: dict[str, dict] = result["blocks_obj"]
|
|
24
|
+
for block in blocks.values():
|
|
25
|
+
if "bokeh_plot_data" in block:
|
|
26
|
+
bokeh_from_json(block)
|
|
27
|
+
pprint(result, max_length=None, max_string=100, max_depth=3)
|
|
28
|
+
|
|
29
|
+
return result
|
|
30
|
+
|
|
31
|
+
return rich_wrapper
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AutoPrettyPrint(type):
|
|
35
|
+
def __new__(cls, name, bases, dct):
|
|
36
|
+
for attr, value in dct.items():
|
|
37
|
+
if callable(value) and not attr.startswith("__"):
|
|
38
|
+
dct[attr] = pretty_displayer(value)
|
|
39
|
+
return super().__new__(cls, name, bases, dct)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def bokeh_from_json(block_data):
|
|
43
|
+
from bokeh.io import curdoc
|
|
44
|
+
from bokeh.plotting import show
|
|
45
|
+
|
|
46
|
+
if "bokeh_plot_data" in block_data:
|
|
47
|
+
bokeh_plot_data = block_data["bokeh_plot_data"]
|
|
48
|
+
else:
|
|
49
|
+
bokeh_plot_data = block_data
|
|
50
|
+
curdoc().replace_with_json(bokeh_plot_data["doc"])
|
|
51
|
+
show(curdoc().roots[0])
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class BaseDatalabClient(metaclass=AutoPrettyPrint):
|
|
55
|
+
"""A base class that implements some of the shared/logistical functionality
|
|
56
|
+
(hopefully) common to all Datalab clients.
|
|
57
|
+
|
|
58
|
+
Mainly used to keep the namespace of the 'real' client classes clean and
|
|
59
|
+
readable by users.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
_api_key: Optional[str] = None
|
|
63
|
+
_session: Optional[httpx.Client] = None
|
|
64
|
+
_headers: dict[str, str] = {}
|
|
65
|
+
|
|
66
|
+
bad_server_versions: Optional[tuple[tuple[int, int, int]]] = ((0, 2, 0),)
|
|
67
|
+
"""Any known server versions that are not supported by this client."""
|
|
68
|
+
|
|
69
|
+
min_server_version: tuple[int, int, int] = (0, 1, 0)
|
|
70
|
+
"""The minimum supported server version that this client supports."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, datalab_api_url: str, log_level: str = "WARNING"):
|
|
73
|
+
"""Creates an authenticated client.
|
|
74
|
+
|
|
75
|
+
An API key is required to authenticate requests. The client will attempt to load it from a
|
|
76
|
+
series of environment variables, `DATALAB_API_KEY` and prefixed versions for the given
|
|
77
|
+
requested instance (e.g., `PUBLIC_DATALAB_API_KEY` for the public deployment
|
|
78
|
+
which has prefix `public`).
|
|
79
|
+
|
|
80
|
+
Parameters:
|
|
81
|
+
datalab_api_url: The URL of the Datalab API.
|
|
82
|
+
TODO: If the URL of a datalab *UI* is provided, a request will be made to attempt
|
|
83
|
+
to resolve the underlying API URL (e.g., `https://public.datalab.odbx.science`
|
|
84
|
+
will 'redirect' to `https://public.api.odbx.science`).
|
|
85
|
+
log_level: The logging level to use for the client. Defaults to "WARNING".
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
self.datalab_api_url = datalab_api_url
|
|
91
|
+
if not self.datalab_api_url:
|
|
92
|
+
raise ValueError("No Datalab API URL provided.")
|
|
93
|
+
if not self.datalab_api_url.startswith("http"):
|
|
94
|
+
self.datalab_api_url = f"https://{self.datalab_api_url}"
|
|
95
|
+
logging.basicConfig(level=log_level, handlers=[RichHandler()])
|
|
96
|
+
self.log = logging.getLogger(__name__)
|
|
97
|
+
|
|
98
|
+
self._http_client = httpx.Client
|
|
99
|
+
self._headers["User-Agent"] = f"Datalab Python API/{__version__}"
|
|
100
|
+
|
|
101
|
+
info_json = self.get_info()
|
|
102
|
+
|
|
103
|
+
self._datalab_api_versions: list[str] = info_json["data"]["attributes"][
|
|
104
|
+
"available_api_versions"
|
|
105
|
+
]
|
|
106
|
+
self._datalab_server_version: str = info_json["data"]["attributes"]["server_version"]
|
|
107
|
+
self._datalab_instance_prefix: Optional[str] = info_json["data"]["attributes"].get(
|
|
108
|
+
"identifier_prefix"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
self._find_api_key()
|
|
112
|
+
|
|
113
|
+
def get_info(self) -> dict[str, Any]:
|
|
114
|
+
raise NotImplementedError
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def session(self) -> httpx.Client:
|
|
118
|
+
if self._session is None:
|
|
119
|
+
return self._http_client(headers=self.headers)
|
|
120
|
+
return self._session
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def headers(self):
|
|
124
|
+
return self._headers
|
|
125
|
+
|
|
126
|
+
def _version_negotiation(self):
|
|
127
|
+
"""Check whether this client is expected to work with this instance.
|
|
128
|
+
|
|
129
|
+
Raises:
|
|
130
|
+
RuntimeError: If the server version is not supported or if no supported API versions are found.
|
|
131
|
+
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
for available_api_version in sorted(self._datalab_api_versions):
|
|
135
|
+
major, minor, _ = (int(_) for _ in available_api_version.split("."))
|
|
136
|
+
if major == self.min_api_version[0] and minor == self.min_api_version[1]:
|
|
137
|
+
self._selected_api_version = available_api_version
|
|
138
|
+
break
|
|
139
|
+
else:
|
|
140
|
+
raise RuntimeError(f"No supported API versions found in {self._datalab_api_versions=}")
|
|
141
|
+
|
|
142
|
+
if self._datalab_server_version in self.bad_server_versions:
|
|
143
|
+
raise RuntimeError(
|
|
144
|
+
f"Server version {self._datalab_server_version} is not supported by this client."
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def api_key(self) -> str:
|
|
149
|
+
"""The API key used to authenticate requests to the Datalab API, passed
|
|
150
|
+
as the `DATALAB-API-KEY` HTTP header.
|
|
151
|
+
|
|
152
|
+
This can be retrieved by an authenticated user with the `/get-api-key`
|
|
153
|
+
endpoint of a Datalab API.
|
|
154
|
+
|
|
155
|
+
"""
|
|
156
|
+
if self._api_key is not None:
|
|
157
|
+
return self._api_key
|
|
158
|
+
return self._find_api_key()
|
|
159
|
+
|
|
160
|
+
def _find_api_key(self) -> str:
|
|
161
|
+
"""Checks various environment variables for an API key and sets the value in the
|
|
162
|
+
session headers.
|
|
163
|
+
"""
|
|
164
|
+
if self._api_key is None:
|
|
165
|
+
key_env_var = "DATALAB_API_KEY"
|
|
166
|
+
|
|
167
|
+
api_key: Optional[str] = None
|
|
168
|
+
|
|
169
|
+
# probe the prefixed environment variable first
|
|
170
|
+
if self._datalab_instance_prefix is not None:
|
|
171
|
+
api_key = os.getenv(f"{self._datalab_instance_prefix.upper()}_{key_env_var}")
|
|
172
|
+
|
|
173
|
+
if api_key is None:
|
|
174
|
+
api_key = os.getenv("DATALAB_API_KEY")
|
|
175
|
+
|
|
176
|
+
if api_key is None:
|
|
177
|
+
raise ValueError(
|
|
178
|
+
f"No API key found in environment variables {key_env_var}/<prefix>_{key_env_var}."
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
self._api_key = api_key
|
|
182
|
+
|
|
183
|
+
# Reset session as we are now updating the headers
|
|
184
|
+
if self._session is not None:
|
|
185
|
+
try:
|
|
186
|
+
self._session.close()
|
|
187
|
+
except Exception:
|
|
188
|
+
pass
|
|
189
|
+
finally:
|
|
190
|
+
self._session = None
|
|
191
|
+
self._headers["DATALAB-API-KEY"] = self.api_key
|
|
192
|
+
|
|
193
|
+
return self.api_key
|
|
194
|
+
|
|
195
|
+
def __enter__(self) -> "BaseDatalabClient":
|
|
196
|
+
return self
|
|
197
|
+
|
|
198
|
+
def __exit__(self, *_):
|
|
199
|
+
if self._session is not None:
|
|
200
|
+
self._session.close()
|
datalab_api/cli.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from typing import Annotated, Optional
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
from click_shell import make_click_shell
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.live import Live
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.pretty import pprint
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from datalab_api import DatalabClient
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(
|
|
15
|
+
name="datalab",
|
|
16
|
+
help="A command-line interface for the Datalab API.",
|
|
17
|
+
epilog="Copyright (c) 2020-2024 Matthew Evans, Joshua Bocarsly & the Datalab Development Team.",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
console = Console()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.callback(invoke_without_command=True)
|
|
24
|
+
def launch(
|
|
25
|
+
ctx: typer.Context,
|
|
26
|
+
instance_url: Annotated[Optional[str], typer.Argument()] = None,
|
|
27
|
+
animate_intro: bool = True,
|
|
28
|
+
):
|
|
29
|
+
"""Makes an interactive REPL-style interface using the subcommands below."""
|
|
30
|
+
shell = make_click_shell(
|
|
31
|
+
ctx,
|
|
32
|
+
prompt="datalab > ",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
animation_intro = _make_fancy_intro()
|
|
36
|
+
if animate_intro:
|
|
37
|
+
with Live(console=console, auto_refresh=False) as live:
|
|
38
|
+
for frame in animation_intro:
|
|
39
|
+
live._live_render.set_renderable(
|
|
40
|
+
Panel(
|
|
41
|
+
frame,
|
|
42
|
+
subtitle=app.info.epilog,
|
|
43
|
+
width=len(app.info.epilog) + 6,
|
|
44
|
+
highlight=False,
|
|
45
|
+
)
|
|
46
|
+
) # type: ignore
|
|
47
|
+
console.print(live._live_render.position_cursor())
|
|
48
|
+
time.sleep(0.05)
|
|
49
|
+
|
|
50
|
+
console.print(
|
|
51
|
+
Panel(animation_intro[-1], subtitle=app.info.epilog, width=len(app.info.epilog) + 6),
|
|
52
|
+
highlight=False,
|
|
53
|
+
) # type: ignore
|
|
54
|
+
console.print()
|
|
55
|
+
console.print(
|
|
56
|
+
Panel(
|
|
57
|
+
"This CLI is an experimental work in progress and does not expose the full functionality of the underlying DatalabClient.",
|
|
58
|
+
title="[red]WARNING![/red]",
|
|
59
|
+
width=len(app.info.epilog) + 6,
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
console.print()
|
|
63
|
+
|
|
64
|
+
if instance_url:
|
|
65
|
+
authenticate(ctx, instance_url)
|
|
66
|
+
shell.cmdloop()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _get_client(
|
|
70
|
+
ctx: typer.Context,
|
|
71
|
+
instance_url: Optional[str] = None,
|
|
72
|
+
log_level: str = "WARNING",
|
|
73
|
+
):
|
|
74
|
+
client = getattr(ctx, "client", None)
|
|
75
|
+
if instance_url is None:
|
|
76
|
+
instance_url = getattr(ctx, "instance_url", None) # type: ignore
|
|
77
|
+
if client is None:
|
|
78
|
+
client = DatalabClient(datalab_api_url=instance_url, log_level=log_level) # type: ignore
|
|
79
|
+
ctx.client = client
|
|
80
|
+
ctx.instance_url = client.datalab_api_url
|
|
81
|
+
return ctx.client
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _get_instance_url(ctx: typer.Context):
|
|
85
|
+
instance_url = getattr(ctx, "instance_url", None)
|
|
86
|
+
if instance_url is None:
|
|
87
|
+
raise ValueError("No Datalab API URL provided.")
|
|
88
|
+
return instance_url
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@app.command()
|
|
92
|
+
def authenticate(
|
|
93
|
+
ctx: typer.Context,
|
|
94
|
+
instance_url: Annotated[Optional[str], typer.Argument()] = None,
|
|
95
|
+
log_level: str = "WARNING",
|
|
96
|
+
):
|
|
97
|
+
client = _get_client(ctx, instance_url, log_level)
|
|
98
|
+
user = client.authenticate()
|
|
99
|
+
console.print(
|
|
100
|
+
f"Welcome [red]{user['display_name']}[/red]!\nSuccessfully authenticated at [blue]{client.datalab_api_url}[/blue]."
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@app.command()
|
|
105
|
+
def get(
|
|
106
|
+
ctx: typer.Context,
|
|
107
|
+
item_type: str,
|
|
108
|
+
instance_url: Annotated[Optional[str], typer.Argument()] = None,
|
|
109
|
+
page_limit: int = 10,
|
|
110
|
+
log_level: str = "WARNING",
|
|
111
|
+
):
|
|
112
|
+
"""Get a table of items of the given type."""
|
|
113
|
+
client = _get_client(ctx, instance_url, log_level)
|
|
114
|
+
items = client.get_items(item_type)
|
|
115
|
+
table = Table(title=f"/{item_type}/", show_lines=True)
|
|
116
|
+
table.add_column("type", overflow="crop", justify="center")
|
|
117
|
+
table.add_column("ID", style="cyan", no_wrap=True)
|
|
118
|
+
table.add_column("refcode", style="magenta", no_wrap=True)
|
|
119
|
+
table.add_column("name", width=30, overflow="ellipsis", no_wrap=True)
|
|
120
|
+
table.add_column("nblocks", justify="right")
|
|
121
|
+
table.add_column("collections")
|
|
122
|
+
table.add_column("creators")
|
|
123
|
+
for item in items[:page_limit]:
|
|
124
|
+
table.add_row(
|
|
125
|
+
item["type"][0].upper(),
|
|
126
|
+
item["item_id"],
|
|
127
|
+
item["refcode"],
|
|
128
|
+
item["name"],
|
|
129
|
+
str(item["nblocks"]),
|
|
130
|
+
", ".join(d["collection_id"] for d in item["collections"]),
|
|
131
|
+
", ".join(d["display_name"] for d in item["creators"]),
|
|
132
|
+
end_section=True,
|
|
133
|
+
)
|
|
134
|
+
console.print(table)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@app.command()
|
|
138
|
+
def info(ctx: typer.Context, instance_url: str, log_level: str = "WARNING"):
|
|
139
|
+
"""Print the server info."""
|
|
140
|
+
client = _get_client(ctx, instance_url, log_level)
|
|
141
|
+
pprint(client.get_info())
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _make_fancy_intro(animate=True):
|
|
145
|
+
"""Bit of fun, make an animated datalab logo intro to the CLI."""
|
|
146
|
+
import random
|
|
147
|
+
|
|
148
|
+
intro_ascii = """
|
|
149
|
+
oooo o8 o888 oooo
|
|
150
|
+
ooooo888 ooooooo o888oo ooooooo 888 ooooooo 888ooooo
|
|
151
|
+
888 888 ooooo888 888 ooooo888 888 ooooo888 888 888
|
|
152
|
+
888 888 888 888 888 888 888 888 888 888 888 888
|
|
153
|
+
88ooo888o 88ooo88 8o 888o 88ooo88 8o o888o 88ooo88 8o o888ooo88
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
# Colour themes that should work in both light and dark terminals
|
|
157
|
+
themes = [
|
|
158
|
+
["dark_orange", "salmon1", "light_coral", "pale_violet_red1", "orchid2", "orchid1"],
|
|
159
|
+
["dark_orange3", "indian_red", "hot_pink3", "hot_pink2", "orchid"],
|
|
160
|
+
["chartreuse4", "pale_turqoise4", "steel_blue", "steel_blue3", "cornflower_blue"],
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
colours = random.choice(themes)
|
|
164
|
+
random.shuffle(colours)
|
|
165
|
+
colours.append("black")
|
|
166
|
+
num_colours = len(colours)
|
|
167
|
+
|
|
168
|
+
animation: list[str] = []
|
|
169
|
+
colours_by_index: list[int] = [num_colours - 1] * len(intro_ascii)
|
|
170
|
+
beta_1: float = 0.2
|
|
171
|
+
beta_2: float = 0.6
|
|
172
|
+
steps: int = 0
|
|
173
|
+
max_steps: int = 50
|
|
174
|
+
|
|
175
|
+
while max(colours_by_index) != 0:
|
|
176
|
+
intro_fancy = ""
|
|
177
|
+
for ind, char in enumerate(intro_ascii):
|
|
178
|
+
if char == " ":
|
|
179
|
+
colours_by_index[ind] = 0
|
|
180
|
+
intro_fancy += " "
|
|
181
|
+
else:
|
|
182
|
+
if colours_by_index[ind] != 0 and random.random() < (steps / max_steps) * beta_1:
|
|
183
|
+
colours_by_index[ind] = 0
|
|
184
|
+
elif colours_by_index[ind] != 0 and random.random() < (steps / max_steps) * beta_2:
|
|
185
|
+
colours_by_index[ind] = random.randint(1, num_colours - 2)
|
|
186
|
+
intro_fancy += f"[{colours[colours_by_index[ind]]}]{char}[/]"
|
|
187
|
+
|
|
188
|
+
animation.append(intro_fancy)
|
|
189
|
+
steps += 1
|
|
190
|
+
|
|
191
|
+
return animation
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
if __name__ == "__main__":
|
|
195
|
+
app()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Matthew Evans, Joshua Bocarsly and the Datalab Development Team
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: datalab-api
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Author-email: Matthew Evans <matthew@ml-evs.science>
|
|
5
|
+
License: MIT
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Intended Audience :: Information Technology
|
|
14
|
+
Classifier: Topic :: Other/Nonlisted Topic
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: httpx ~=0.27
|
|
20
|
+
Requires-Dist: bokeh ~=2.4
|
|
21
|
+
Requires-Dist: rich ~=13.0
|
|
22
|
+
Provides-Extra: cli
|
|
23
|
+
Requires-Dist: typer ~=0.9 ; extra == 'cli'
|
|
24
|
+
Requires-Dist: click-shell ~=2.1 ; extra == 'cli'
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pre-commit ~=3.0 ; extra == 'dev'
|
|
27
|
+
Provides-Extra: docs
|
|
28
|
+
Requires-Dist: mkdocs ; extra == 'docs'
|
|
29
|
+
Requires-Dist: mkdocs-material ; extra == 'docs'
|
|
30
|
+
Requires-Dist: mkdocstrings[python-legacy] ; extra == 'docs'
|
|
31
|
+
Requires-Dist: mkdocs-awesome-pages-plugin ; extra == 'docs'
|
|
32
|
+
Requires-Dist: mkdocs-jupyter ; extra == 'docs'
|
|
33
|
+
|
|
34
|
+
# <div align="center"><i>datalab</i> Python API</div>
|
|
35
|
+
|
|
36
|
+
<div align="center">
|
|
37
|
+
<a href="https://datalab-python-api.readthedocs.io/en/stable/?badge=stable">
|
|
38
|
+
<img src="https://readthedocs.org/projects/datalab-python-api/badge/?version=stable"></img>
|
|
39
|
+
</a>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
A simple Python API that can interact with [*datalab*](https://github.com/the-grey-group/datalab) instances.
|
|
43
|
+
|
|
44
|
+
The idea here is to provide a set of utility functions and models for manipulating samples, cells, inventory, files, users and metadata associated with *datalab* entries in an automated way.
|
|
45
|
+
|
|
46
|
+
This API may not expose all the functionality available in a given *datalab* instance, and the idea would be that this package can support multiple versions of the underlying [*datalab* REST API](https://the-datalab.readthedocs.io/en/latest/rest_api/).
|
|
47
|
+
This means that the API is primarily *functional* in nature, as opposed to object-oriented, since *datalab* instances are free to use their own custom data models.
|
|
48
|
+
The available schemas are reported as instance metadata and in the future object-oriented models may be able to be genereated directly in the client (so e.g., the returned data would be Python objects like `Sample` rather than JSON data).
|
|
49
|
+
|
|
50
|
+
The issue tracker here will be used to track development of features, as well as bug reports.
|
|
51
|
+
If you have any suggestions or feedback, please post it there.
|
|
52
|
+
|
|
53
|
+
## Installation
|
|
54
|
+
|
|
55
|
+
The API can be used by installing this repository with `pip`, ideally in a fresh Python 3.9+ environment (created using e.g., conda, virtualenv or other related tools -- if you're not sure about this, ask).
|
|
56
|
+
|
|
57
|
+
Either from PyPI, for the latest released version:
|
|
58
|
+
|
|
59
|
+
```shell
|
|
60
|
+
pip install datalab-api
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
or for the latest development version from GitHub:
|
|
64
|
+
|
|
65
|
+
```shell
|
|
66
|
+
git clone git@github.com:datalab-org/datalab-api
|
|
67
|
+
cd datalab-api
|
|
68
|
+
pip install .
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Usage
|
|
72
|
+
|
|
73
|
+
Example usage as a Jupyter notebook can be found in the `examples` directory or
|
|
74
|
+
in the [online documentation](https://datalab-api.readthedocs.io/), as
|
|
75
|
+
well as the full [API
|
|
76
|
+
documentation](https://datalab-api.readthedocs.io/en/latest/reference/).
|
|
77
|
+
|
|
78
|
+
### Authentication
|
|
79
|
+
|
|
80
|
+
Currently the only supported authentication method is via an API key.
|
|
81
|
+
You can generate one for your account for a given *datalab* instance by visiting the `/get-api-key` endpoint of your chosen instance, or, if using a recent version of *datalab*, by visiting your account settings in the browser.
|
|
82
|
+
|
|
83
|
+
This API key can be set via the environment variable `DATALAB_API_KEY`.
|
|
84
|
+
To suport the use case of needing to interact with multiple datalab instances, the client will also check prefixed environment variables that use the [`IDENTIFIER_PREFIX` of the chosen datalab instance](https://the-datalab.readthedocs.io/en/latest/config/#mandatory-settings), e.g., `GREY_DATALAB_API_KEY` or `PUBLIC_DATALAB_API_KEY`.
|
|
85
|
+
Only keys that match will be read (e.g., other environment variables starting with `PUBLIC_` will be ignored, when connecting to the [public demo datalab](https://public.datalab.odbx.science)).
|
|
86
|
+
|
|
87
|
+
### Python API
|
|
88
|
+
|
|
89
|
+
This package implements basic functionality for displaying and manipulating entries:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from datalab_api import DatalabClient
|
|
93
|
+
|
|
94
|
+
with DatalabClient("https://api.public.odbx.science") as client:
|
|
95
|
+
|
|
96
|
+
# List all items of a given type
|
|
97
|
+
items = client.get_items()
|
|
98
|
+
|
|
99
|
+
# Get more info on a particular item
|
|
100
|
+
item = client.get_item(item_id="test")
|
|
101
|
+
|
|
102
|
+
# Upload a file to an item
|
|
103
|
+
file_response = client.upload_file(filepath="my_echem_data.mpr", item_id="test")
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Command-line interface (CLI)
|
|
108
|
+
|
|
109
|
+
There is also an EXPERIMENTAL CLI, exposed via the `datalab` command.
|
|
110
|
+
This CLI has many subcommands, which can be listed with `datalab help` or by entering the shell with just `datalab`.
|
|
111
|
+
For now, the CLI does not expose all of the functionality of the underlying API, and is mostly oriented on GET-focused operations.
|
|
112
|
+
|
|
113
|
+
```shell
|
|
114
|
+
$ datalab
|
|
115
|
+
|
|
116
|
+
╭──────────────────────────────────────────────────────────────────────────────────────────╮
|
|
117
|
+
│ │
|
|
118
|
+
│ oooo o8 o888 oooo │
|
|
119
|
+
│ ooooo888 ooooooo o888oo ooooooo 888 ooooooo 888ooooo │
|
|
120
|
+
│ 888 888 ooooo888 888 ooooo888 888 ooooo888 888 888 │
|
|
121
|
+
│ 888 888 888 888 888 888 888 888 888 888 888 888 │
|
|
122
|
+
│ 88ooo888o 88ooo88 8o 888o 88ooo88 8o o888o 88ooo88 8o o888ooo88 │
|
|
123
|
+
│ │
|
|
124
|
+
╰─ Copyright (c) 2020-2024 Matthew Evans, Joshua Bocarsly & the Datalab Development Team. ─╯
|
|
125
|
+
|
|
126
|
+
datalab > help
|
|
127
|
+
|
|
128
|
+
Documented commands (type help <topic>):
|
|
129
|
+
========================================
|
|
130
|
+
authenticate get info
|
|
131
|
+
|
|
132
|
+
Undocumented commands:
|
|
133
|
+
======================
|
|
134
|
+
exit help quit
|
|
135
|
+
|
|
136
|
+
datalab > help get
|
|
137
|
+
|
|
138
|
+
Usage: [INSTANCE_URL] get [OPTIONS] ITEM_TYPE [INSTANCE_URL]
|
|
139
|
+
|
|
140
|
+
Get a table of items of the given type.
|
|
141
|
+
|
|
142
|
+
╭─ Arguments ─────────────────────────────────────────────────────────────────────────────────────────╮
|
|
143
|
+
│ * item_type TEXT [default: None] [required] │
|
|
144
|
+
│ instance_url [INSTANCE_URL] [default: None] │
|
|
145
|
+
╰─────────────────────────────────────────────────────────────────────────────────────────────────────╯
|
|
146
|
+
╭─ Options ───────────────────────────────────────────────────────────────────────────────────────────╮
|
|
147
|
+
│ --page-limit INTEGER [default: 10] │
|
|
148
|
+
│ --api-key TEXT [default: None] │
|
|
149
|
+
│ --log-level TEXT [default: WARNING] │
|
|
150
|
+
│ --help Show this message and exit. │
|
|
151
|
+
╰─────────────────────────────────────────────────────────────────────────────────────────────────────╯
|
|
152
|
+
|
|
153
|
+
datalab > get samples api.odbx.science --page-limit 2
|
|
154
|
+
/samples/
|
|
155
|
+
┏━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
|
|
156
|
+
┃ type ┃ ID ┃ refcode ┃ name ┃ nblocks ┃ collections ┃ creators ┃
|
|
157
|
+
┡━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
|
|
158
|
+
│ C │ test_api │ grey:WHQFXG │ │ 0 │ │ Matthew Evans │
|
|
159
|
+
├──────┼─────────────────┼─────────────┼────────────────────────────────┼─────────┼─────────────┼───────────────┤
|
|
160
|
+
│ S │ test12331231312 │ grey:GFSUQM │ │ 1 │ │ Matthew Evans │
|
|
161
|
+
└──────┴─────────────────┴─────────────┴────────────────────────────────┴─────────┴─────────────┴───────────────┘
|
|
162
|
+
```
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
datalab_api/__init__.py,sha256=Dyu1SFjboTSiuovspMcFrQK5AMUT5OOYLhXXrw4AgOE,14227
|
|
2
|
+
datalab_api/_base.py,sha256=hZw8JiovHmCyVPErYg5cXdI8Ra6_q8eoMtC4AkJ8JjU,7003
|
|
3
|
+
datalab_api/cli.py,sha256=sC3fPMQPGnZK4Uz3hHtnk_GjSTLkTS8fydGSILAG8lM,6451
|
|
4
|
+
datalab_api-0.2.0.dist-info/LICENSE,sha256=lSeXwvkn4v5VW4QJ5JSeoxIqUtngjE6Yo6i-iYJQj3M,1120
|
|
5
|
+
datalab_api-0.2.0.dist-info/METADATA,sha256=jP8VrUeCGChg6MxRKK7kQA35Rq1ddFv5zPVaa1wFywU,10211
|
|
6
|
+
datalab_api-0.2.0.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
|
|
7
|
+
datalab_api-0.2.0.dist-info/entry_points.txt,sha256=oP1qs074fbtdexaK705O96H3ZXQFpNha_UB-1CbWe64,48
|
|
8
|
+
datalab_api-0.2.0.dist-info/top_level.txt,sha256=IvPpoa6z7jfGDXBZrDcC5cNEwDl3_L0u-mYrPh327UQ,12
|
|
9
|
+
datalab_api-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
datalab_api
|