datalab-api 0.2.1__tar.gz → 0.2.4__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.1/src/datalab_api.egg-info → datalab_api-0.2.4}/PKG-INFO +15 -2
- {datalab_api-0.2.1 → datalab_api-0.2.4}/README.md +1 -1
- datalab_api-0.2.4/pyproject.toml +175 -0
- {datalab_api-0.2.1 → datalab_api-0.2.4}/src/datalab_api/__init__.py +106 -10
- {datalab_api-0.2.1 → datalab_api-0.2.4}/src/datalab_api/_base.py +69 -13
- datalab_api-0.2.4/src/datalab_api/helpers.py +51 -0
- {datalab_api-0.2.1 → datalab_api-0.2.4/src/datalab_api.egg-info}/PKG-INFO +15 -2
- {datalab_api-0.2.1 → datalab_api-0.2.4}/src/datalab_api.egg-info/SOURCES.txt +3 -1
- {datalab_api-0.2.1 → datalab_api-0.2.4}/src/datalab_api.egg-info/requires.txt +10 -0
- datalab_api-0.2.4/tests/test_client.py +22 -0
- datalab_api-0.2.1/pyproject.toml +0 -79
- {datalab_api-0.2.1 → datalab_api-0.2.4}/LICENSE +0 -0
- {datalab_api-0.2.1 → datalab_api-0.2.4}/setup.cfg +0 -0
- {datalab_api-0.2.1 → datalab_api-0.2.4}/src/datalab_api/cli.py +0 -0
- {datalab_api-0.2.1 → datalab_api-0.2.4}/src/datalab_api.egg-info/dependency_links.txt +0 -0
- {datalab_api-0.2.1 → datalab_api-0.2.4}/src/datalab_api.egg-info/entry_points.txt +0 -0
- {datalab_api-0.2.1 → datalab_api-0.2.4}/src/datalab_api.egg-info/top_level.txt +0 -0
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: datalab-api
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
|
+
Summary: A Python API for instances of the datalab data management platform (https://github.com/the-grey-group/datalab).
|
|
4
5
|
Author-email: Matthew Evans <matthew@ml-evs.science>
|
|
5
6
|
License: MIT
|
|
7
|
+
Project-URL: homepage, https://github.com/datalab-org/datalab-api
|
|
8
|
+
Project-URL: repository, https://github.com/datalab-org/datalab-api
|
|
9
|
+
Project-URL: documentation, https://datalab-python-api.readthedocs.io
|
|
10
|
+
Project-URL: changelog, https://github.com/datalab-org/datalab-api/releases
|
|
6
11
|
Classifier: Programming Language :: Python :: 3
|
|
7
12
|
Classifier: Programming Language :: Python :: 3.9
|
|
8
13
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -21,6 +26,9 @@ Requires-Dist: bokeh~=2.4
|
|
|
21
26
|
Requires-Dist: rich~=13.0
|
|
22
27
|
Provides-Extra: dev
|
|
23
28
|
Requires-Dist: pre-commit~=3.0; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest~=8.0; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest-cov~=5.0; extra == "dev"
|
|
31
|
+
Requires-Dist: respx~=0.21; extra == "dev"
|
|
24
32
|
Provides-Extra: docs
|
|
25
33
|
Requires-Dist: mkdocs; extra == "docs"
|
|
26
34
|
Requires-Dist: mkdocs-material; extra == "docs"
|
|
@@ -30,6 +38,11 @@ Requires-Dist: mkdocs-jupyter; extra == "docs"
|
|
|
30
38
|
Provides-Extra: cli
|
|
31
39
|
Requires-Dist: typer~=0.9; extra == "cli"
|
|
32
40
|
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
|
+
Provides-Extra: all
|
|
45
|
+
Requires-Dist: datalab-api[cli,dev,docs]; extra == "all"
|
|
33
46
|
|
|
34
47
|
# <div align="center"><i>datalab</i> Python API</div>
|
|
35
48
|
|
|
@@ -95,7 +108,7 @@ This package implements basic functionality for displaying and manipulating entr
|
|
|
95
108
|
```python
|
|
96
109
|
from datalab_api import DatalabClient
|
|
97
110
|
|
|
98
|
-
with DatalabClient("https://api.
|
|
111
|
+
with DatalabClient("https://public.api.odbx.science") as client:
|
|
99
112
|
|
|
100
113
|
# List all items of a given type
|
|
101
114
|
items = client.get_items()
|
|
@@ -62,7 +62,7 @@ This package implements basic functionality for displaying and manipulating entr
|
|
|
62
62
|
```python
|
|
63
63
|
from datalab_api import DatalabClient
|
|
64
64
|
|
|
65
|
-
with DatalabClient("https://api.
|
|
65
|
+
with DatalabClient("https://public.api.odbx.science") as client:
|
|
66
66
|
|
|
67
67
|
# List all items of a given type
|
|
68
68
|
items = client.get_items()
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools >= 62", "versioningit ~= 3.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "datalab-api"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
description = "A Python API for instances of the datalab data management platform (https://github.com/the-grey-group/datalab)."
|
|
9
|
+
keywords = []
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "Matthew Evans", email = "matthew@ml-evs.science" }]
|
|
12
|
+
dynamic = ["version"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3.9",
|
|
15
|
+
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Intended Audience :: Science/Research",
|
|
19
|
+
"Intended Audience :: Information Technology",
|
|
20
|
+
"Topic :: Other/Nonlisted Topic",
|
|
21
|
+
"Topic :: Scientific/Engineering",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
requires-python = ">=3.9"
|
|
25
|
+
dependencies =[
|
|
26
|
+
"httpx ~= 0.27", # better HTTP requests
|
|
27
|
+
"bokeh ~= 2.4", # interactive plots from datalab directly
|
|
28
|
+
"rich ~= 13.0", # nicer terminal output
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
dev = [
|
|
34
|
+
"pre-commit ~= 3.0",
|
|
35
|
+
"pytest ~= 8.0",
|
|
36
|
+
"pytest-cov ~= 5.0",
|
|
37
|
+
"respx ~= 0.21",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
docs = [
|
|
41
|
+
"mkdocs",
|
|
42
|
+
"mkdocs-material",
|
|
43
|
+
"mkdocstrings[python-legacy]",
|
|
44
|
+
"mkdocs-awesome-pages-plugin",
|
|
45
|
+
"mkdocs-jupyter",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
cli = [
|
|
49
|
+
"typer ~= 0.9", # command line interface
|
|
50
|
+
"click-shell ~= 2.1", # REPL-like interface
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
cheminventory-helper = [
|
|
54
|
+
"pandas ~= 2.2",
|
|
55
|
+
"openpyxl ~= 3.0"
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
all = [
|
|
59
|
+
"datalab-api[dev,docs,cli]"
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
[project.scripts]
|
|
63
|
+
datalab = "datalab_api.cli:app"
|
|
64
|
+
|
|
65
|
+
[project.urls]
|
|
66
|
+
homepage = "https://github.com/datalab-org/datalab-api"
|
|
67
|
+
repository = "https://github.com/datalab-org/datalab-api"
|
|
68
|
+
documentation = "https://datalab-python-api.readthedocs.io"
|
|
69
|
+
changelog = "https://github.com/datalab-org/datalab-api/releases"
|
|
70
|
+
|
|
71
|
+
[tool.versioningit.vcs]
|
|
72
|
+
method = "git"
|
|
73
|
+
default-tag = "0.0.1"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
[tool.mypy]
|
|
77
|
+
# plugins = "pydantic.mypy"
|
|
78
|
+
ignore_missing_imports = true
|
|
79
|
+
follow_imports = "skip"
|
|
80
|
+
|
|
81
|
+
[tool.ruff]
|
|
82
|
+
line-length = 100
|
|
83
|
+
target-version = "py39"
|
|
84
|
+
|
|
85
|
+
[tool.ruff.lint]
|
|
86
|
+
select = ["ALL"]
|
|
87
|
+
ignore = [
|
|
88
|
+
"A001",
|
|
89
|
+
"A002",
|
|
90
|
+
"ANN", # TODO fix all ANN errors
|
|
91
|
+
"ARG", # TODO fix unused method argument
|
|
92
|
+
"B007",
|
|
93
|
+
"B028",
|
|
94
|
+
"B904",
|
|
95
|
+
"BLE001",
|
|
96
|
+
"C408", # Unnecessary (dict/list/tuple) call - remove call
|
|
97
|
+
"C416",
|
|
98
|
+
"C419",
|
|
99
|
+
"C901", # function too complex
|
|
100
|
+
"COM812", # trailing comma missing
|
|
101
|
+
"D",
|
|
102
|
+
"D205",
|
|
103
|
+
"DTZ", # datetime-tz-now
|
|
104
|
+
"E501", # TODO fix line too long
|
|
105
|
+
"EM", # exception message must not use f-string literal
|
|
106
|
+
"ERA001", # found commented out code
|
|
107
|
+
"FA100", # TODO fix FA errors
|
|
108
|
+
"FBT001",
|
|
109
|
+
"FBT002",
|
|
110
|
+
"FBT003",
|
|
111
|
+
"FIX002",
|
|
112
|
+
"G004", # logging uses fstring
|
|
113
|
+
"G201",
|
|
114
|
+
"INP001",
|
|
115
|
+
"ISC001",
|
|
116
|
+
"N802", # TODO maybe fix these
|
|
117
|
+
"N805",
|
|
118
|
+
"N818",
|
|
119
|
+
"PD011", # pandas-use-of-dot-values
|
|
120
|
+
"PERF102",
|
|
121
|
+
"PERF203", # try-except-in-loop
|
|
122
|
+
"PERF401",
|
|
123
|
+
"PGH003",
|
|
124
|
+
"PGH004",
|
|
125
|
+
"PLR", # pylint-refactor
|
|
126
|
+
"PLW0602",
|
|
127
|
+
"PLW0603",
|
|
128
|
+
"PLW2901",
|
|
129
|
+
"PT003",
|
|
130
|
+
"PT004", # pytest-missing-fixture-name-underscore
|
|
131
|
+
"PT006", # pytest-parametrize-names-wrong-type
|
|
132
|
+
"PT013", # pytest-incorrect-pytest-import
|
|
133
|
+
"PTH", # prefer Pathlib to os.path
|
|
134
|
+
"PYI024",
|
|
135
|
+
"RET",
|
|
136
|
+
"RET504",
|
|
137
|
+
"RUF005",
|
|
138
|
+
"RUF012",
|
|
139
|
+
"RUF013", # implicit-optional
|
|
140
|
+
"RUF015",
|
|
141
|
+
"S106",
|
|
142
|
+
"S110",
|
|
143
|
+
"S112",
|
|
144
|
+
"S311",
|
|
145
|
+
"S324", # use of insecure hash function
|
|
146
|
+
"S507", # paramiko auto trust
|
|
147
|
+
"S602",
|
|
148
|
+
"S603",
|
|
149
|
+
"S607",
|
|
150
|
+
"SIM102",
|
|
151
|
+
"SIM105",
|
|
152
|
+
"SIM108",
|
|
153
|
+
"SIM117",
|
|
154
|
+
"SLF", # private member accessed outside class
|
|
155
|
+
"SLOT000",
|
|
156
|
+
"T201",
|
|
157
|
+
"TCH",
|
|
158
|
+
"TD", # TODOs
|
|
159
|
+
"TRY", # long message outside exception class
|
|
160
|
+
"UP031",
|
|
161
|
+
]
|
|
162
|
+
pydocstyle.convention = "numpy"
|
|
163
|
+
isort.known-first-party = ["jobflow_remote"]
|
|
164
|
+
isort.split-on-trailing-comma = false
|
|
165
|
+
fixable = ["A", "B", "C", "D", "E", "F", "I"]
|
|
166
|
+
unfixable = []
|
|
167
|
+
# Allow unused variables when underscore-prefixed.
|
|
168
|
+
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
|
|
169
|
+
|
|
170
|
+
[tool.ruff.format]
|
|
171
|
+
docstring-code-format = true
|
|
172
|
+
|
|
173
|
+
[tool.ruff.lint.per-file-ignores]
|
|
174
|
+
"__init__.py" = ["F401"]
|
|
175
|
+
"**/tests/*" = ["INP001", "S101"]
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import warnings
|
|
2
|
+
from collections.abc import Iterable
|
|
1
3
|
from pathlib import Path
|
|
2
4
|
from typing import Any, Optional, Union
|
|
3
5
|
|
|
@@ -6,6 +8,10 @@ from ._base import BaseDatalabClient, __version__
|
|
|
6
8
|
__all__ = ("__version__", "DatalabClient")
|
|
7
9
|
|
|
8
10
|
|
|
11
|
+
class DuplicateItemError(ValueError):
|
|
12
|
+
"""Raised when the API operation would create a duplicate item."""
|
|
13
|
+
|
|
14
|
+
|
|
9
15
|
class DatalabClient(BaseDatalabClient):
|
|
10
16
|
"""A client for the Datalab API.
|
|
11
17
|
|
|
@@ -65,7 +71,7 @@ class DatalabClient(BaseDatalabClient):
|
|
|
65
71
|
return items[item_type]
|
|
66
72
|
|
|
67
73
|
def search_items(
|
|
68
|
-
self, query: str, item_types: Union[
|
|
74
|
+
self, query: str, item_types: Union[Iterable[str], str] = ("samples", "cells")
|
|
69
75
|
) -> list[dict[str, Any]]:
|
|
70
76
|
"""Search for items of the given types that match the query.
|
|
71
77
|
|
|
@@ -95,7 +101,11 @@ class DatalabClient(BaseDatalabClient):
|
|
|
95
101
|
return items["items"]
|
|
96
102
|
|
|
97
103
|
def create_item(
|
|
98
|
-
self,
|
|
104
|
+
self,
|
|
105
|
+
item_id: str,
|
|
106
|
+
item_type: str,
|
|
107
|
+
item_data: Optional[dict[str, Any]] = None,
|
|
108
|
+
collection_id: Optional[str] = None,
|
|
99
109
|
) -> dict[str, Any]:
|
|
100
110
|
"""Create an item with a given ID and item data.
|
|
101
111
|
|
|
@@ -103,12 +113,22 @@ class DatalabClient(BaseDatalabClient):
|
|
|
103
113
|
item_id: The ID of the item to create.
|
|
104
114
|
item_type: The type of item to create, e.g., 'samples', 'cells'.
|
|
105
115
|
item_data: The data for the item.
|
|
116
|
+
collection_id: The ID of the collection to add the item to (optional).
|
|
117
|
+
If such a collection does not exist, one will be made.
|
|
106
118
|
|
|
107
119
|
"""
|
|
108
120
|
new_item = {}
|
|
109
121
|
if item_data is not None:
|
|
110
122
|
new_item = item_data
|
|
111
|
-
new_item.update(
|
|
123
|
+
new_item.update({"item_id": item_id, "type": item_type})
|
|
124
|
+
|
|
125
|
+
if collection_id is not None:
|
|
126
|
+
try:
|
|
127
|
+
collection_immutable_id = self.get_collection(collection_id)["immutable_id"]
|
|
128
|
+
except RuntimeError:
|
|
129
|
+
collection_immutable_id = self.create_collection(collection_id)["immutable_id"]
|
|
130
|
+
new_item["collections"] = new_item.get("collections", [])
|
|
131
|
+
new_item["collections"].append({"immutable_id": collection_immutable_id})
|
|
112
132
|
|
|
113
133
|
create_item_url = f"{self.datalab_api_url}/new-sample/"
|
|
114
134
|
create_item_resp = self.session.post(
|
|
@@ -118,16 +138,18 @@ class DatalabClient(BaseDatalabClient):
|
|
|
118
138
|
)
|
|
119
139
|
try:
|
|
120
140
|
created_item = create_item_resp.json()
|
|
121
|
-
if
|
|
122
|
-
raise
|
|
123
|
-
f"
|
|
141
|
+
if create_item_resp.status_code == 409:
|
|
142
|
+
raise DuplicateItemError(
|
|
143
|
+
f"Item {item_id=} already exists at {create_item_url}: {created_item['status']!r}."
|
|
124
144
|
)
|
|
145
|
+
if created_item["status"] != "success":
|
|
146
|
+
raise RuntimeError(f"Failed to create item at {create_item_url}: {created_item}.")
|
|
125
147
|
return created_item["sample_list_entry"]
|
|
126
148
|
|
|
127
|
-
except Exception:
|
|
128
|
-
raise
|
|
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
|
-
)
|
|
149
|
+
except Exception as exc:
|
|
150
|
+
raise exc.__class__(
|
|
151
|
+
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."
|
|
152
|
+
) from exc
|
|
131
153
|
|
|
132
154
|
def update_item(self, item_id: str, item_data: dict[str, Any]) -> dict[str, Any]:
|
|
133
155
|
"""Update an item with the given item data.
|
|
@@ -214,6 +236,26 @@ class DatalabClient(BaseDatalabClient):
|
|
|
214
236
|
|
|
215
237
|
return item["item_data"]
|
|
216
238
|
|
|
239
|
+
def get_item_files(self, item_id: str) -> None:
|
|
240
|
+
"""Download all the files for a given item and save them locally
|
|
241
|
+
in the current working directory.
|
|
242
|
+
|
|
243
|
+
Parameters:
|
|
244
|
+
item_id: The ID of the item to search for.
|
|
245
|
+
|
|
246
|
+
"""
|
|
247
|
+
|
|
248
|
+
item_data = self.get_item(item_id)
|
|
249
|
+
for f in item_data.get("files", []):
|
|
250
|
+
file_location = f["location"]
|
|
251
|
+
url = file_location.replace("/app", self.datalab_api_url)
|
|
252
|
+
if Path(f["name"]).exists():
|
|
253
|
+
warnings.warn(f"Will not overwrite existing file {f['name']}")
|
|
254
|
+
continue
|
|
255
|
+
with open(f["name"], "wb") as file:
|
|
256
|
+
response = self.session.get(url, follow_redirects=True)
|
|
257
|
+
file.write(response.content)
|
|
258
|
+
|
|
217
259
|
def get_block(self, item_id: str, block_id: str, block_data: dict[str, Any]) -> dict[str, Any]:
|
|
218
260
|
"""Get a block with a given ID and block data.
|
|
219
261
|
Should be used in conjunction with `get_item` to load an existing block.
|
|
@@ -369,3 +411,57 @@ class DatalabClient(BaseDatalabClient):
|
|
|
369
411
|
raise RuntimeError(f"Failed to update block {block_type=}:\n{resp.text}")
|
|
370
412
|
|
|
371
413
|
return resp.json()["new_block_data"]
|
|
414
|
+
|
|
415
|
+
def get_collection(self, collection_id: str) -> dict[str, Any]:
|
|
416
|
+
"""Get a collection with a given ID.
|
|
417
|
+
|
|
418
|
+
Parameters:
|
|
419
|
+
collection_id: The ID of the collection to search for.
|
|
420
|
+
|
|
421
|
+
Returns:
|
|
422
|
+
A dictionary of collection data for the collection with the given ID.
|
|
423
|
+
|
|
424
|
+
"""
|
|
425
|
+
collection_url = f"{self.datalab_api_url}/collections/{collection_id}"
|
|
426
|
+
collection_resp = self.session.get(collection_url, follow_redirects=True)
|
|
427
|
+
if collection_resp.status_code != 200:
|
|
428
|
+
raise RuntimeError(
|
|
429
|
+
f"Failed to find collection {collection_id=} at {collection_url}: {collection_resp.status_code=}. Check the collection ID is correct."
|
|
430
|
+
)
|
|
431
|
+
collection = collection_resp.json()
|
|
432
|
+
if collection["status"] != "success":
|
|
433
|
+
raise RuntimeError(
|
|
434
|
+
f"Failed to get collection at {collection_url}: {collection['status']!r}."
|
|
435
|
+
)
|
|
436
|
+
return collection["data"]
|
|
437
|
+
|
|
438
|
+
def create_collection(
|
|
439
|
+
self, collection_id: str, collection_data: Optional[dict] = None
|
|
440
|
+
) -> dict[str, Any]:
|
|
441
|
+
"""Create a collection with a given ID and collection data.
|
|
442
|
+
|
|
443
|
+
Parameters:
|
|
444
|
+
collection_id: The ID of the collection to create.
|
|
445
|
+
collection_data: The data for the collection.
|
|
446
|
+
|
|
447
|
+
"""
|
|
448
|
+
collection_url = f"{self.datalab_api_url}/collections"
|
|
449
|
+
|
|
450
|
+
new_collection = {}
|
|
451
|
+
if collection_data is not None:
|
|
452
|
+
new_collection = collection_data
|
|
453
|
+
new_collection.update({"collection_id": collection_id, "type": "collections"})
|
|
454
|
+
|
|
455
|
+
collection_resp = self.session.put(
|
|
456
|
+
collection_url,
|
|
457
|
+
json={"data": new_collection},
|
|
458
|
+
follow_redirects=True,
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
if collection_resp.status_code != 201 or collection_resp.json()["status"] != "success":
|
|
462
|
+
raise RuntimeError(
|
|
463
|
+
f"Failed to create collection {collection_id=} at {collection_url}: {collection_resp.status_code=}. Check the collection information is correct: {collection_resp.content}"
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
created_collection = collection_resp.json()["data"]
|
|
467
|
+
return created_collection
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import functools
|
|
2
2
|
import logging
|
|
3
3
|
import os
|
|
4
|
+
import re
|
|
5
|
+
import warnings
|
|
4
6
|
from importlib.metadata import version
|
|
5
7
|
from typing import Any, Optional
|
|
6
8
|
|
|
7
9
|
import httpx
|
|
10
|
+
from rich.console import Console
|
|
8
11
|
from rich.logging import RichHandler
|
|
9
12
|
from rich.pretty import pprint
|
|
13
|
+
from rich.table import Table
|
|
10
14
|
|
|
11
15
|
__version__ = version("datalab-api")
|
|
12
16
|
|
|
@@ -14,17 +18,46 @@ __all__ = ("__version__", "BaseDatalabClient")
|
|
|
14
18
|
|
|
15
19
|
|
|
16
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
|
+
|
|
17
26
|
@functools.wraps(method)
|
|
18
27
|
def rich_wrapper(self, *args, **kwargs):
|
|
19
28
|
display = kwargs.pop("display", False)
|
|
29
|
+
page_limit = kwargs.pop("page_limit", 10)
|
|
20
30
|
result = method(self, *args, **kwargs)
|
|
21
31
|
if display:
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
+
table = Table(show_lines=True)
|
|
47
|
+
table.add_column("type", overflow="crop", justify="center")
|
|
48
|
+
table.add_column("ID", style="cyan", no_wrap=True)
|
|
49
|
+
table.add_column("refcode", style="magenta", no_wrap=True)
|
|
50
|
+
table.add_column("name", width=30, overflow="ellipsis", no_wrap=True)
|
|
51
|
+
for item in result[:page_limit]:
|
|
52
|
+
table.add_row(
|
|
53
|
+
item["type"][0].upper(),
|
|
54
|
+
item["item_id"],
|
|
55
|
+
item["refcode"],
|
|
56
|
+
item["name"],
|
|
57
|
+
end_section=True,
|
|
58
|
+
)
|
|
59
|
+
console = Console()
|
|
60
|
+
console.print(table)
|
|
28
61
|
|
|
29
62
|
return result
|
|
30
63
|
|
|
@@ -39,16 +72,16 @@ class AutoPrettyPrint(type):
|
|
|
39
72
|
return super().__new__(cls, name, bases, dct)
|
|
40
73
|
|
|
41
74
|
|
|
42
|
-
def bokeh_from_json(block_data):
|
|
75
|
+
def bokeh_from_json(block_data, show=True):
|
|
43
76
|
from bokeh.io import curdoc
|
|
44
|
-
from bokeh.plotting import show
|
|
77
|
+
from bokeh.plotting import show as bokeh_show
|
|
45
78
|
|
|
46
|
-
|
|
47
|
-
bokeh_plot_data = block_data["bokeh_plot_data"]
|
|
48
|
-
else:
|
|
49
|
-
bokeh_plot_data = block_data
|
|
79
|
+
bokeh_plot_data = block_data.get("bokeh_plot_data", block_data)
|
|
50
80
|
curdoc().replace_with_json(bokeh_plot_data["doc"])
|
|
51
|
-
show
|
|
81
|
+
if show:
|
|
82
|
+
bokeh_show(curdoc().roots[0])
|
|
83
|
+
|
|
84
|
+
return curdoc()
|
|
52
85
|
|
|
53
86
|
|
|
54
87
|
class BaseDatalabClient(metaclass=AutoPrettyPrint):
|
|
@@ -98,6 +131,8 @@ class BaseDatalabClient(metaclass=AutoPrettyPrint):
|
|
|
98
131
|
self._http_client = httpx.Client
|
|
99
132
|
self._headers["User-Agent"] = f"Datalab Python API/{__version__}"
|
|
100
133
|
|
|
134
|
+
self._detect_api_url()
|
|
135
|
+
|
|
101
136
|
info_json = self.get_info()
|
|
102
137
|
|
|
103
138
|
self._datalab_api_versions: list[str] = info_json["data"]["attributes"][
|
|
@@ -110,6 +145,27 @@ class BaseDatalabClient(metaclass=AutoPrettyPrint):
|
|
|
110
145
|
|
|
111
146
|
self._find_api_key()
|
|
112
147
|
|
|
148
|
+
def _detect_api_url(self) -> None:
|
|
149
|
+
"""Perform a handshake with the chosen URL to ascertain the correct API URL.
|
|
150
|
+
|
|
151
|
+
If a datalab UI URL is passed, the client will attempt to resolve the API URL by
|
|
152
|
+
inspecting the HTML meta tags.
|
|
153
|
+
|
|
154
|
+
Do not use the session for this, so we are not passing the API key to arbitrary URLs.
|
|
155
|
+
|
|
156
|
+
"""
|
|
157
|
+
response = httpx.get(self.datalab_api_url)
|
|
158
|
+
match = re.search(
|
|
159
|
+
r'<meta name="x_datalab_api_url" content="(.*?)">',
|
|
160
|
+
response.text,
|
|
161
|
+
re.IGNORECASE,
|
|
162
|
+
)
|
|
163
|
+
if match:
|
|
164
|
+
self.datalab_api_url = match.group(1)
|
|
165
|
+
warnings.warn(
|
|
166
|
+
f"Found API URL {self.datalab_api_url} in HTML meta tag. Creating client with this URL instead."
|
|
167
|
+
)
|
|
168
|
+
|
|
113
169
|
def get_info(self) -> dict[str, Any]:
|
|
114
170
|
raise NotImplementedError
|
|
115
171
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import random
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from datalab_api import DatalabClient, DuplicateItemError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def import_cheminventory(filename: Path, client: DatalabClient):
|
|
8
|
+
"""Helper function to import a ChemInventory xlsx export into datalab, using the
|
|
9
|
+
'starting_materials' item type.
|
|
10
|
+
|
|
11
|
+
Migrated from the original version in the main datalab repository:
|
|
12
|
+
https://github.com/the-grey-group/datalab/blob/43764fb494c2cc1bf9f7dc90c25594aeb79d5767/pydatalab/tasks.py#L350-L413
|
|
13
|
+
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def _generate_random_startingmaterial_id():
|
|
17
|
+
"""Generate 'XX' + a random 15-length string for use as an id for starting materials
|
|
18
|
+
that don't have a barcode.
|
|
19
|
+
"""
|
|
20
|
+
yield "".join(["XX"] + random.choices("abcdefghijklmnopqrstuvwxyz0123456789", k=15))
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
import pandas as pd
|
|
24
|
+
|
|
25
|
+
inventory_df = pd.read_excel(filename)
|
|
26
|
+
except ImportError as exc:
|
|
27
|
+
raise ImportError(
|
|
28
|
+
"Please install pandas + openpyxl to use this helper function, via `pip install datalab-api[cheminventory-helper]`"
|
|
29
|
+
) from exc
|
|
30
|
+
inventory_df["type"] = "starting_materials"
|
|
31
|
+
inventory_df["item_id"] = inventory_df["Barcode"]
|
|
32
|
+
# Fill missing barcodes
|
|
33
|
+
inventory_df["item_id"] = inventory_df["item_id"].fillna(
|
|
34
|
+
inventory_df["item_id"].apply(lambda _: _generate_random_startingmaterial_id())
|
|
35
|
+
)
|
|
36
|
+
inventory_df["Molecular Weight"] = inventory_df["Molecular Weight"].replace(" ", float("nan"))
|
|
37
|
+
|
|
38
|
+
inventory_df = inventory_df.dropna()
|
|
39
|
+
|
|
40
|
+
counts = {"success": 0, "duplicate": 0}
|
|
41
|
+
|
|
42
|
+
for item in inventory_df.to_dict(orient="records"):
|
|
43
|
+
try:
|
|
44
|
+
client.create_item(
|
|
45
|
+
item["item_id"], item["type"], item, collection_id="jul24-inventory-import"
|
|
46
|
+
)
|
|
47
|
+
counts["success"] += 1
|
|
48
|
+
except DuplicateItemError:
|
|
49
|
+
counts["duplicate"] += 1
|
|
50
|
+
|
|
51
|
+
print(f"Done: {counts=}")
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: datalab-api
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
|
+
Summary: A Python API for instances of the datalab data management platform (https://github.com/the-grey-group/datalab).
|
|
4
5
|
Author-email: Matthew Evans <matthew@ml-evs.science>
|
|
5
6
|
License: MIT
|
|
7
|
+
Project-URL: homepage, https://github.com/datalab-org/datalab-api
|
|
8
|
+
Project-URL: repository, https://github.com/datalab-org/datalab-api
|
|
9
|
+
Project-URL: documentation, https://datalab-python-api.readthedocs.io
|
|
10
|
+
Project-URL: changelog, https://github.com/datalab-org/datalab-api/releases
|
|
6
11
|
Classifier: Programming Language :: Python :: 3
|
|
7
12
|
Classifier: Programming Language :: Python :: 3.9
|
|
8
13
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -21,6 +26,9 @@ Requires-Dist: bokeh~=2.4
|
|
|
21
26
|
Requires-Dist: rich~=13.0
|
|
22
27
|
Provides-Extra: dev
|
|
23
28
|
Requires-Dist: pre-commit~=3.0; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest~=8.0; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest-cov~=5.0; extra == "dev"
|
|
31
|
+
Requires-Dist: respx~=0.21; extra == "dev"
|
|
24
32
|
Provides-Extra: docs
|
|
25
33
|
Requires-Dist: mkdocs; extra == "docs"
|
|
26
34
|
Requires-Dist: mkdocs-material; extra == "docs"
|
|
@@ -30,6 +38,11 @@ Requires-Dist: mkdocs-jupyter; extra == "docs"
|
|
|
30
38
|
Provides-Extra: cli
|
|
31
39
|
Requires-Dist: typer~=0.9; extra == "cli"
|
|
32
40
|
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
|
+
Provides-Extra: all
|
|
45
|
+
Requires-Dist: datalab-api[cli,dev,docs]; extra == "all"
|
|
33
46
|
|
|
34
47
|
# <div align="center"><i>datalab</i> Python API</div>
|
|
35
48
|
|
|
@@ -95,7 +108,7 @@ This package implements basic functionality for displaying and manipulating entr
|
|
|
95
108
|
```python
|
|
96
109
|
from datalab_api import DatalabClient
|
|
97
110
|
|
|
98
|
-
with DatalabClient("https://api.
|
|
111
|
+
with DatalabClient("https://public.api.odbx.science") as client:
|
|
99
112
|
|
|
100
113
|
# List all items of a given type
|
|
101
114
|
items = client.get_items()
|
|
@@ -4,9 +4,11 @@ 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
|
|
7
8
|
src/datalab_api.egg-info/PKG-INFO
|
|
8
9
|
src/datalab_api.egg-info/SOURCES.txt
|
|
9
10
|
src/datalab_api.egg-info/dependency_links.txt
|
|
10
11
|
src/datalab_api.egg-info/entry_points.txt
|
|
11
12
|
src/datalab_api.egg-info/requires.txt
|
|
12
|
-
src/datalab_api.egg-info/top_level.txt
|
|
13
|
+
src/datalab_api.egg-info/top_level.txt
|
|
14
|
+
tests/test_client.py
|
|
@@ -2,12 +2,22 @@ httpx~=0.27
|
|
|
2
2
|
bokeh~=2.4
|
|
3
3
|
rich~=13.0
|
|
4
4
|
|
|
5
|
+
[all]
|
|
6
|
+
datalab-api[cli,dev,docs]
|
|
7
|
+
|
|
8
|
+
[cheminventory-helper]
|
|
9
|
+
pandas~=2.2
|
|
10
|
+
openpyxl~=3.0
|
|
11
|
+
|
|
5
12
|
[cli]
|
|
6
13
|
typer~=0.9
|
|
7
14
|
click-shell~=2.1
|
|
8
15
|
|
|
9
16
|
[dev]
|
|
10
17
|
pre-commit~=3.0
|
|
18
|
+
pytest~=8.0
|
|
19
|
+
pytest-cov~=5.0
|
|
20
|
+
respx~=0.21
|
|
11
21
|
|
|
12
22
|
[docs]
|
|
13
23
|
mkdocs
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import respx
|
|
3
|
+
from datalab_api import DatalabClient
|
|
4
|
+
from httpx import Response
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@respx.mock
|
|
8
|
+
def test_redirect_url(fake_ui_html, fake_info_json):
|
|
9
|
+
fake_ui = respx.get("https://ui.datalab.industries").mock(
|
|
10
|
+
return_value=Response(200, content=fake_ui_html)
|
|
11
|
+
)
|
|
12
|
+
fake_api = respx.get("https://api.datalab.industries/info").mock(
|
|
13
|
+
return_value=Response(200, json=fake_info_json)
|
|
14
|
+
)
|
|
15
|
+
with pytest.warns(
|
|
16
|
+
UserWarning,
|
|
17
|
+
match="^Found API URL https://api.datalab.industries in HTML meta tag. Creating client with this URL instead.$",
|
|
18
|
+
):
|
|
19
|
+
client = DatalabClient("https://ui.datalab.industries")
|
|
20
|
+
assert fake_ui.called
|
|
21
|
+
assert fake_api.called
|
|
22
|
+
assert client.datalab_api_url == "https://api.datalab.industries"
|
datalab_api-0.2.1/pyproject.toml
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
[build-system]
|
|
2
|
-
requires = ["setuptools >= 62", "versioningit ~= 3.0", "wheel"]
|
|
3
|
-
build-backend = "setuptools.build_meta"
|
|
4
|
-
|
|
5
|
-
[project]
|
|
6
|
-
name = "datalab-api"
|
|
7
|
-
readme = "README.md"
|
|
8
|
-
keywords = []
|
|
9
|
-
license = { text = "MIT" }
|
|
10
|
-
authors = [{ name = "Matthew Evans", email = "matthew@ml-evs.science" }]
|
|
11
|
-
dynamic = ["version"]
|
|
12
|
-
classifiers = [
|
|
13
|
-
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3.9",
|
|
14
|
-
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10",
|
|
15
|
-
"Programming Language :: Python :: 3.11",
|
|
16
|
-
"Programming Language :: Python :: 3.12",
|
|
17
|
-
"Intended Audience :: Science/Research",
|
|
18
|
-
"Intended Audience :: Information Technology",
|
|
19
|
-
"Topic :: Other/Nonlisted Topic",
|
|
20
|
-
"Topic :: Scientific/Engineering",
|
|
21
|
-
]
|
|
22
|
-
|
|
23
|
-
requires-python = ">=3.9"
|
|
24
|
-
dependencies =[
|
|
25
|
-
"httpx ~= 0.27", # better HTTP requests
|
|
26
|
-
"bokeh ~= 2.4", # interactive plots from datalab directly
|
|
27
|
-
"rich ~= 13.0", # nicer terminal output
|
|
28
|
-
]
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
[project.optional-dependencies]
|
|
32
|
-
dev = [
|
|
33
|
-
"pre-commit ~= 3.0",
|
|
34
|
-
]
|
|
35
|
-
|
|
36
|
-
docs = [
|
|
37
|
-
"mkdocs",
|
|
38
|
-
"mkdocs-material",
|
|
39
|
-
"mkdocstrings[python-legacy]",
|
|
40
|
-
"mkdocs-awesome-pages-plugin",
|
|
41
|
-
"mkdocs-jupyter",
|
|
42
|
-
]
|
|
43
|
-
|
|
44
|
-
cli = [
|
|
45
|
-
"typer ~= 0.9", # command line interface
|
|
46
|
-
"click-shell ~= 2.1", # REPL-like interface
|
|
47
|
-
]
|
|
48
|
-
|
|
49
|
-
[project.scripts]
|
|
50
|
-
datalab = "datalab_api.cli:app"
|
|
51
|
-
|
|
52
|
-
[project.urls]
|
|
53
|
-
# homepage = "https://github.com/the-grey-group/datalab"
|
|
54
|
-
# repository = "https://github.com/the-grey-group/datalab"
|
|
55
|
-
# documentation = "https://the-datalab.readthedocs.io"
|
|
56
|
-
# changelog = "https://github.com/the-grey-group/datalab/releases"
|
|
57
|
-
|
|
58
|
-
[tool.versioningit.vcs]
|
|
59
|
-
method = "git"
|
|
60
|
-
default-tag = "0.0.1"
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
[tool.mypy]
|
|
64
|
-
# plugins = "pydantic.mypy"
|
|
65
|
-
ignore_missing_imports = true
|
|
66
|
-
follow_imports = "skip"
|
|
67
|
-
|
|
68
|
-
[tool.ruff]
|
|
69
|
-
line-length = 100
|
|
70
|
-
target-version = "py310"
|
|
71
|
-
|
|
72
|
-
[tool.ruff.lint]
|
|
73
|
-
select = ["E", "F", "I", "W", "Q"]
|
|
74
|
-
ignore = ["E501", "E402"]
|
|
75
|
-
fixable = ["A", "B", "C", "D", "E", "F", "I"]
|
|
76
|
-
unfixable = []
|
|
77
|
-
per-file-ignores = {}
|
|
78
|
-
# Allow unused variables when underscore-prefixed.
|
|
79
|
-
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|