datalab-api 0.3.2__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datalab-api
3
- Version: 0.3.2
3
+ Version: 0.5.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
@@ -13,6 +13,7 @@ Classifier: Programming Language :: Python :: 3.9
13
13
  Classifier: Programming Language :: Python :: 3.10
14
14
  Classifier: Programming Language :: Python :: 3.11
15
15
  Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
16
17
  Classifier: Intended Audience :: Science/Research
17
18
  Classifier: Intended Audience :: Information Technology
18
19
  Classifier: Topic :: Other/Nonlisted Topic
@@ -38,9 +39,6 @@ Requires-Dist: mkdocs-jupyter; extra == "docs"
38
39
  Provides-Extra: cli
39
40
  Requires-Dist: typer~=0.9; extra == "cli"
40
41
  Requires-Dist: click-shell~=2.1; extra == "cli"
41
- Provides-Extra: cheminventory-helper
42
- Requires-Dist: pandas~=2.2; extra == "cheminventory-helper"
43
- Requires-Dist: openpyxl~=3.0; extra == "cheminventory-helper"
44
42
  Provides-Extra: all
45
43
  Requires-Dist: datalab-api[cli,dev,docs]; extra == "all"
46
44
  Dynamic: license-file
@@ -16,6 +16,7 @@ classifiers = [
16
16
  "Programming Language :: Python :: 3.10",
17
17
  "Programming Language :: Python :: 3.11",
18
18
  "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
19
20
  "Intended Audience :: Science/Research",
20
21
  "Intended Audience :: Information Technology",
21
22
  "Topic :: Other/Nonlisted Topic",
@@ -52,8 +53,6 @@ cli = [
52
53
  "click-shell ~= 2.1", # REPL-like interface
53
54
  ]
54
55
 
55
- cheminventory-helper = ["pandas ~= 2.2", "openpyxl ~= 3.0"]
56
-
57
56
  all = ["datalab-api[dev,docs,cli]"]
58
57
 
59
58
  [project.scripts]
@@ -5,6 +5,8 @@ from collections.abc import Iterable
5
5
  from pathlib import Path
6
6
  from typing import Any, Optional, Union
7
7
 
8
+ import httpx
9
+
8
10
  from ._base import BaseDatalabClient, DatalabAPIError, DuplicateItemError, __version__
9
11
 
10
12
  __all__ = ("DatalabClient", "DuplicateItemError", "__version__")
@@ -125,6 +127,8 @@ class DatalabClient(BaseDatalabClient):
125
127
  item_type: str = "samples",
126
128
  item_data: dict[str, Any] | None = None,
127
129
  collection_id: str | None = None,
130
+ collection_ids: str | Iterable[str] | None = None, # alias for collection_id
131
+ group_ids: str | Iterable[str] | None = None,
128
132
  ) -> dict[str, Any]:
129
133
  """Create an item with a given ID and item data.
130
134
 
@@ -133,7 +137,9 @@ class DatalabClient(BaseDatalabClient):
133
137
  item_type: The type of item to create, e.g., 'samples', 'cells'.
134
138
  item_data: The data for the item.
135
139
  collection_id: The ID of the collection to add the item to (optional).
136
- If such a collection does not exist, one will be made.
140
+ If such a collection does not exist, one will be made. Deprecated in favor of `collection_ids`.
141
+ collection_ids: The ID or IDs of the collection(s) to add the item to (optional).
142
+ group_ids: The ID or IDs of the group(s) to share the item with (optional).
137
143
 
138
144
  """
139
145
  new_item = {}
@@ -145,16 +151,34 @@ class DatalabClient(BaseDatalabClient):
145
151
  new_item.pop("item_id")
146
152
 
147
153
  if collection_id is not None:
148
- try:
149
- collection_immutable_id = self.get_collection(collection_id)[0]["immutable_id"]
150
- except RuntimeError:
151
- self.create_collection(collection_id)
152
- collection_immutable_id = self.get_collection(collection_id)[0]["immutable_id"]
153
- except DatalabAPIError:
154
- self.create_collection(collection_id)
155
- collection_immutable_id = self.get_collection(collection_id)[0]["immutable_id"]
154
+ warnings.warn(
155
+ DeprecationWarning(
156
+ "`collection_id` is deprecated, please use `collection_ids` instead."
157
+ )
158
+ )
159
+ if collection_ids is None:
160
+ collection_ids = [collection_id]
161
+
162
+ if collection_ids:
156
163
  new_item["collections"] = new_item.get("collections", [])
157
- new_item["collections"].append({"immutable_id": collection_immutable_id})
164
+ for collection_id in collection_ids:
165
+ try:
166
+ collection_immutable_id = self.get_collection(collection_id)[0]["immutable_id"]
167
+ except (RuntimeError, DatalabAPIError):
168
+ self.create_collection(collection_id)
169
+ collection_immutable_id = self.get_collection(collection_id)[0]["immutable_id"]
170
+ new_item["collections"].append({"immutable_id": collection_immutable_id})
171
+
172
+ if group_ids:
173
+ # For now, we have to use the search groups endpoint
174
+ # until we add a get group by id endpoint that is locked down per user
175
+ new_item["groups"] = new_item.get("groups", [])
176
+ for group_id in group_ids:
177
+ try:
178
+ group_immutable_id = self.get_group(group_id)["immutable_id"]
179
+ new_item["groups"].append({"immutable_id": group_immutable_id})
180
+ except (RuntimeError, DatalabAPIError):
181
+ pass
158
182
 
159
183
  create_item_url = f"{self.datalab_api_url}/new-sample/"
160
184
  created_item = self._post(
@@ -272,12 +296,16 @@ class DatalabClient(BaseDatalabClient):
272
296
  block = self._post(block_url, json=block_request)
273
297
  return block["new_block_data"]
274
298
 
275
- def upload_file(self, item_id: str, file_path: Path | str) -> dict[str, Any]:
299
+ def upload_file(
300
+ self, item_id: str, file_path: Path | str, replace_file_id: str | None = None
301
+ ) -> dict[str, Any]:
276
302
  """Upload a file to an item with a given ID.
277
303
 
278
304
  Parameters:
279
305
  item_id: The ID of the item to upload the file to.
280
306
  file_path: The path to the file to upload.
307
+ replace_file_id: The ID of an existing file to replace with the uploaded file, if any.
308
+ If provided, the new file will take the place of the old file in any blocks it was attached to.
281
309
 
282
310
  Returns:
283
311
  A dictionary of the uploaded file data.
@@ -288,14 +316,19 @@ class DatalabClient(BaseDatalabClient):
288
316
  if not file_path.exists():
289
317
  raise FileNotFoundError(f"File {file_path=} does not exist.")
290
318
 
319
+ # Use a longer read timeout for uploads, as the server may do
320
+ # significant processing before responding
321
+ upload_timeout = httpx.Timeout(self._timeout.connect, read=600.0, pool=self._timeout.pool)
322
+
291
323
  upload_url = f"{self.datalab_api_url}/upload-file/"
292
324
  with open(file_path, "rb") as file:
293
325
  files = {"file": (file_path.name, file)}
294
326
  upload = self._post(
295
327
  upload_url,
296
328
  files=files,
297
- data={"item_id": item_id, "replace_file": None},
329
+ data={"item_id": item_id, "replace_file": replace_file_id},
298
330
  expected_status=201,
331
+ timeout=upload_timeout,
299
332
  )
300
333
  return upload
301
334
 
@@ -403,8 +436,18 @@ class DatalabClient(BaseDatalabClient):
403
436
  "save_to_db": True,
404
437
  }
405
438
 
406
- resp = self._post(blocks_url, json=payload)
407
- return resp["new_block_data"]
439
+ resp = self._post(blocks_url, expected_status=[200, 202], json=payload)
440
+
441
+ # If block is created synchronously, return the block data
442
+ if "new_block_data" in resp:
443
+ return resp["new_block_data"]
444
+
445
+ # Otherwise, we return the response which may contain information about the asynchronous block creation process
446
+ task_id = resp.get("task_id")
447
+ if task_id:
448
+ self.triggered_block_task_ids.add(task_id)
449
+
450
+ return resp
408
451
 
409
452
  def get_collection(self, collection_id: str) -> tuple[dict[str, Any], list[dict[str, Any]]]:
410
453
  """Get a collection with a given ID.
@@ -421,6 +464,32 @@ class DatalabClient(BaseDatalabClient):
421
464
  collection = self._get(collection_url)
422
465
  return collection["data"], collection["child_items"]
423
466
 
467
+ def get_group(self, group_id: str) -> dict[str, Any]:
468
+ """Get a group with a given ID.
469
+
470
+ Uses the search groups endpoint under the hood,
471
+ so will only find groups that the user has access
472
+ to and that match the query.
473
+
474
+ Parameters:
475
+ group_id: The human-readable ID of the group to search for.
476
+
477
+ Returns:
478
+ A dictionary of group data for the group with the given ID.
479
+
480
+ Raises:
481
+ DatalabAPIError: If no group with the given ID is found.
482
+
483
+ """
484
+ group_url = f"{self.datalab_api_url}/search/groups?query={group_id}"
485
+ group_resp = self._get(group_url)
486
+ matching_groups = group_resp["data"]
487
+ if matching_groups:
488
+ matching_groups = [g for g in matching_groups if g.get("group_id") == group_id]
489
+ return matching_groups[0]
490
+
491
+ raise DatalabAPIError(f"No group found with ID {group_id!r}")
492
+
424
493
  def create_collection(
425
494
  self, collection_id: str, collection_data: dict | None = None
426
495
  ) -> dict[str, Any]:
@@ -4,7 +4,7 @@ import re
4
4
  import warnings
5
5
  from getpass import getpass
6
6
  from importlib.metadata import version
7
- from typing import Any, Optional
7
+ from typing import Any, Optional, Union
8
8
 
9
9
  import httpx
10
10
  from rich.logging import RichHandler
@@ -35,7 +35,7 @@ class BaseDatalabClient(metaclass=AutoPrettyPrint):
35
35
 
36
36
  _api_key: Optional[str] = None
37
37
  _session: Optional[httpx.Client] = None
38
- _headers: dict[str, str] = {}
38
+ _headers: dict[str, str]
39
39
  _timeout: httpx.Timeout = httpx.Timeout(10.0, read=60.0)
40
40
 
41
41
  interactive: bool = True
@@ -43,6 +43,9 @@ class BaseDatalabClient(metaclass=AutoPrettyPrint):
43
43
  Set to false if you want the script to error if no API key is found in the environment variables,
44
44
  rather than prompting for input."""
45
45
 
46
+ triggered_block_task_ids: set[str] = set()
47
+ """A set of block task IDs that have been triggered during the lifetime of this client instance."""
48
+
46
49
  info: dict[str, Any] = {}
47
50
  """The `data` response from the `/info` endpoint of the Datalab API."""
48
51
 
@@ -82,7 +85,7 @@ class BaseDatalabClient(metaclass=AutoPrettyPrint):
82
85
  self.log = logging.getLogger(__name__)
83
86
 
84
87
  self._http_client = httpx.Client
85
- self._headers["User-Agent"] = f"Datalab Python API/{__version__}"
88
+ self._headers = {"User-Agent": f"Datalab Python API/{__version__}"}
86
89
 
87
90
  self._detect_api_url()
88
91
 
@@ -226,14 +229,14 @@ class BaseDatalabClient(metaclass=AutoPrettyPrint):
226
229
  self._session.close()
227
230
 
228
231
  def _handle_response(
229
- self, response: httpx.Response, url: str, expected_status: int = 200
232
+ self, response: httpx.Response, url: str, expected_status: Union[int, list[int]] = 200
230
233
  ) -> dict[str, Any]:
231
234
  """Handle HTTP response with consistent error handling.
232
235
 
233
236
  Args:
234
237
  response: The HTTP response object
235
238
  url: The URL that was requested
236
- expected_status: The expected HTTP status code (default: 200)
239
+ expected_status: The expected HTTP status code(s) (default: 200)
237
240
 
238
241
  Returns:
239
242
  The JSON response data
@@ -259,7 +262,10 @@ class BaseDatalabClient(metaclass=AutoPrettyPrint):
259
262
  )
260
263
 
261
264
  # Handle other HTTP status code errors
262
- if response.status_code != expected_status:
265
+ if isinstance(expected_status, int):
266
+ expected_status = [expected_status]
267
+
268
+ if response.status_code not in expected_status:
263
269
  try:
264
270
  error_data = response.json()
265
271
  error_message = error_data.get("message", str(response.content))
@@ -321,8 +327,27 @@ This is likely a server-side bug. Please report this issue to the datalab develo
321
327
 
322
328
  return data
323
329
 
330
+ def check_tasks(self) -> tuple[set[str], set[str]]:
331
+ """Check the status of any triggered tasks, returning any that have completed."""
332
+ completed_tasks = set()
333
+ error_tasks = set()
334
+
335
+ for task_id in self.triggered_block_task_ids:
336
+ try:
337
+ response = self._get(f"{self.datalab_api_url}/blocks/{task_id}/status")
338
+ task_status = response["status"]
339
+ if task_status == "ready":
340
+ completed_tasks.add(task_id)
341
+ except DatalabAPIError:
342
+ error_tasks.add(task_id)
343
+
344
+ self.triggered_block_task_ids -= completed_tasks
345
+ self.triggered_block_task_ids -= error_tasks
346
+
347
+ return completed_tasks, error_tasks
348
+
324
349
  def _request(
325
- self, method: str, url: str, expected_status: int = 200, **kwargs
350
+ self, method: str, url: str, expected_status: Union[int, list[int]] = 200, **kwargs
326
351
  ) -> dict[str, Any]:
327
352
  """Make an HTTP request with consistent error handling.
328
353
 
@@ -335,8 +360,11 @@ This is likely a server-side bug. Please report this issue to the datalab develo
335
360
  Returns:
336
361
  The JSON response data
337
362
  """
363
+ timeout = kwargs.pop("timeout", None)
338
364
  try:
339
- response = self.session.request(method, url, follow_redirects=True, **kwargs)
365
+ response = self.session.request(
366
+ method, url, follow_redirects=True, timeout=timeout, **kwargs
367
+ )
340
368
  return self._handle_response(response, url, expected_status)
341
369
  except (httpx.RequestError, httpx.HTTPStatusError) as e:
342
370
  raise DatalabAPIError(f"Request failed for {url}: {e}")
@@ -345,7 +373,9 @@ This is likely a server-side bug. Please report this issue to the datalab develo
345
373
  """Make a GET request with error handling."""
346
374
  return self._request("GET", url, **kwargs)
347
375
 
348
- def _post(self, url: str, expected_status: int = 200, **kwargs) -> dict[str, Any]:
376
+ def _post(
377
+ self, url: str, expected_status: Union[int, list[int]] = 200, **kwargs
378
+ ) -> dict[str, Any]:
349
379
  """Make a POST request with error handling."""
350
380
  return self._request("POST", url, expected_status, **kwargs)
351
381
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datalab-api
3
- Version: 0.3.2
3
+ Version: 0.5.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
@@ -13,6 +13,7 @@ Classifier: Programming Language :: Python :: 3.9
13
13
  Classifier: Programming Language :: Python :: 3.10
14
14
  Classifier: Programming Language :: Python :: 3.11
15
15
  Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
16
17
  Classifier: Intended Audience :: Science/Research
17
18
  Classifier: Intended Audience :: Information Technology
18
19
  Classifier: Topic :: Other/Nonlisted Topic
@@ -38,9 +39,6 @@ Requires-Dist: mkdocs-jupyter; extra == "docs"
38
39
  Provides-Extra: cli
39
40
  Requires-Dist: typer~=0.9; extra == "cli"
40
41
  Requires-Dist: click-shell~=2.1; extra == "cli"
41
- Provides-Extra: cheminventory-helper
42
- Requires-Dist: pandas~=2.2; extra == "cheminventory-helper"
43
- Requires-Dist: openpyxl~=3.0; extra == "cheminventory-helper"
44
42
  Provides-Extra: all
45
43
  Requires-Dist: datalab-api[cli,dev,docs]; extra == "all"
46
44
  Dynamic: license-file
@@ -4,7 +4,6 @@ pyproject.toml
4
4
  src/datalab_api/__init__.py
5
5
  src/datalab_api/_base.py
6
6
  src/datalab_api/cli.py
7
- src/datalab_api/helpers.py
8
7
  src/datalab_api/utils.py
9
8
  src/datalab_api.egg-info/PKG-INFO
10
9
  src/datalab_api.egg-info/SOURCES.txt
@@ -8,10 +8,6 @@ bokeh~=2.4
8
8
  [all]
9
9
  datalab-api[cli,dev,docs]
10
10
 
11
- [cheminventory-helper]
12
- pandas~=2.2
13
- openpyxl~=3.0
14
-
15
11
  [cli]
16
12
  typer~=0.9
17
13
  click-shell~=2.1
@@ -58,3 +58,16 @@ def test_collection_get(fake_api_url, mocked_api):
58
58
 
59
59
  collection, children = client.get_collection("test_collection", display=True)
60
60
  assert mocked_api["collection-test_collection"].called
61
+
62
+
63
+ @respx.mock
64
+ def test_group_get(fake_api_url, mocked_api):
65
+ with DatalabClient("https://api.datalab.industries") as client:
66
+ assert mocked_api["api"].called
67
+ assert mocked_api["info"].called
68
+ assert mocked_api["info-blocks"].called
69
+
70
+ group = client.get_group("test_group", display=True)
71
+ assert mocked_api["search-groups"].called
72
+ assert group["immutable_id"] == "67d0b01e03000cdc75134dbd"
73
+ assert group["group_id"] == "test_group"
@@ -1,60 +0,0 @@
1
- import random
2
- import warnings
3
- from pathlib import Path
4
-
5
- from datalab_api import DatalabClient, DuplicateItemError
6
-
7
-
8
- def import_cheminventory(filename: Path, client: DatalabClient):
9
- """Helper function to import a ChemInventory xlsx export into datalab, using the
10
- 'starting_materials' item type.
11
-
12
- Migrated from the original version in the main datalab repository:
13
- https://github.com/the-grey-group/datalab/blob/43764fb494c2cc1bf9f7dc90c25594aeb79d5767/pydatalab/tasks.py#L350-L413
14
-
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
-
23
- def _generate_random_startingmaterial_id():
24
- """Generate 'XX' + a random 15-length string for use as an id for starting materials
25
- that don't have a barcode.
26
- """
27
- yield "".join(["XX"] + random.choices("abcdefghijklmnopqrstuvwxyz0123456789", k=15))
28
-
29
- try:
30
- import pandas as pd
31
-
32
- inventory_df = pd.read_excel(filename)
33
- except ImportError as exc:
34
- raise ImportError(
35
- "Please install pandas + openpyxl to use this helper function, via `pip install datalab-api[cheminventory-helper]`"
36
- ) from exc
37
- inventory_df["type"] = "starting_materials"
38
- inventory_df["item_id"] = inventory_df["Barcode"]
39
- # Fill missing barcodes
40
- inventory_df["item_id"] = inventory_df["item_id"].fillna(
41
- inventory_df["item_id"].apply(lambda _: next(_generate_random_startingmaterial_id()))
42
- )
43
- inventory_df["Molecular Weight"] = inventory_df["Molecular Weight"].replace(" ", float("nan"))
44
-
45
- counts = {"success": 0, "duplicate": 0, "failed": 0}
46
-
47
- for item in inventory_df.to_dict(orient="records"):
48
- try:
49
- client.create_item(
50
- item["item_id"], item["type"], item, collection_id="jul24-inventory-import"
51
- )
52
- counts["success"] += 1
53
- except DuplicateItemError:
54
- counts["duplicate"] += 1
55
- except Exception as exc:
56
- counts["failed"] += 1
57
- print(f"Failed to import item: {item}. Error: {exc}")
58
- continue
59
-
60
- print(f"Done: {counts=}")
File without changes
File without changes
File without changes