datalab-api 0.5.1__tar.gz → 0.5.3__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.5.1
3
+ Version: 0.5.3
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
@@ -120,6 +120,31 @@ with DatalabClient("https://demo-api.datalab-org.io") as client:
120
120
 
121
121
  ```
122
122
 
123
+ ### Elevated (admin) permissions
124
+
125
+ By default a client can only see items its account owns or has been shared on.
126
+ Admins can opt in to datalab's super-user mode to read items belonging to other users, by passing `elevate_permissions`:
127
+
128
+ ```python
129
+ with DatalabClient(
130
+ "https://demo-api.datalab-org.io", elevate_permissions=True
131
+ ) as client:
132
+ # Finds the item even if it belongs to another user
133
+ item = client.get_item(item_id="someone-elses-sample")
134
+ ```
135
+
136
+ This sends datalab's `sudo=1` flag on read requests.
137
+ It requires the API key in use to belong to an **admin** account; for any other account the server ignores
138
+ it and normal permissions apply.
139
+
140
+ The attribute can also be toggled at any point in the client's lifetime:
141
+
142
+ ```python
143
+ client.elevate_permissions = True
144
+ ```
145
+
146
+ Note that objects created while elevated (e.g. uploaded files) are still recorded as belonging to the admin's own account, not the item owner's.
147
+
123
148
  ### Command-line interface (CLI)
124
149
 
125
150
  There is also an EXPERIMENTAL CLI, exposed via the `datalab` command.
@@ -75,6 +75,31 @@ with DatalabClient("https://demo-api.datalab-org.io") as client:
75
75
 
76
76
  ```
77
77
 
78
+ ### Elevated (admin) permissions
79
+
80
+ By default a client can only see items its account owns or has been shared on.
81
+ Admins can opt in to datalab's super-user mode to read items belonging to other users, by passing `elevate_permissions`:
82
+
83
+ ```python
84
+ with DatalabClient(
85
+ "https://demo-api.datalab-org.io", elevate_permissions=True
86
+ ) as client:
87
+ # Finds the item even if it belongs to another user
88
+ item = client.get_item(item_id="someone-elses-sample")
89
+ ```
90
+
91
+ This sends datalab's `sudo=1` flag on read requests.
92
+ It requires the API key in use to belong to an **admin** account; for any other account the server ignores
93
+ it and normal permissions apply.
94
+
95
+ The attribute can also be toggled at any point in the client's lifetime:
96
+
97
+ ```python
98
+ client.elevate_permissions = True
99
+ ```
100
+
101
+ Note that objects created while elevated (e.g. uploaded files) are still recorded as belonging to the admin's own account, not the item owner's.
102
+
78
103
  ### Command-line interface (CLI)
79
104
 
80
105
  There is also an EXPERIMENTAL CLI, exposed via the `datalab` command.
@@ -268,8 +268,13 @@ class DatalabClient(BaseDatalabClient):
268
268
  if Path(f["name"]).exists():
269
269
  warnings.warn(f"Will not overwrite existing file {f['name']}")
270
270
  continue
271
+ # This streams directly from the session rather than going through
272
+ # `_request`, so the elevation flag has to be applied by hand.
273
+ stream_kwargs = self._apply_elevation("GET", {})
271
274
  with open(f["name"], "wb") as file:
272
- with self.session.stream("GET", url, follow_redirects=True) as response:
275
+ with self.session.stream(
276
+ "GET", url, follow_redirects=True, **stream_kwargs
277
+ ) as response:
273
278
  for chunk in response.iter_bytes(chunk_size=1024):
274
279
  file.write(chunk)
275
280
 
@@ -322,7 +327,12 @@ class DatalabClient(BaseDatalabClient):
322
327
  If provided, the new file will take the place of the old file in any blocks it was attached to.
323
328
 
324
329
  Returns:
325
- A dictionary of the uploaded file data.
330
+ A dictionary of the uploaded file data, with an additional
331
+ `not_modified` key. If `replace_file_id` was provided and the
332
+ server already holds identical content (compared by hash), the
333
+ update is a no-op: the server replies `304 Not Modified` and
334
+ `not_modified` is `True`. Servers older than datalab#1744 never
335
+ report this, so `not_modified` is always `False` against them.
326
336
 
327
337
  """
328
338
  if isinstance(file_path, str):
@@ -341,10 +351,23 @@ class DatalabClient(BaseDatalabClient):
341
351
  upload_url,
342
352
  files=files,
343
353
  data={"item_id": item_id, "replace_file": replace_file_id},
344
- expected_status=201,
354
+ expected_status=[201, 304],
345
355
  timeout=upload_timeout,
346
356
  )
347
- return upload
357
+
358
+ # A 201 always carries a body describing the stored file, so an
359
+ # empty dict here can only be the 304: the server compared hashes,
360
+ # found it already holds this content, and made no update. That is
361
+ # a success, so report it rather than raising.
362
+ if not upload:
363
+ return {
364
+ "status": "success",
365
+ "file_id": replace_file_id,
366
+ "is_update": True,
367
+ "not_modified": True,
368
+ }
369
+
370
+ return {**upload, "not_modified": False}
348
371
 
349
372
  def create_data_block(
350
373
  self,
@@ -58,7 +58,22 @@ class BaseDatalabClient(metaclass=AutoPrettyPrint):
58
58
  min_server_version: tuple[int, int, int] = (0, 1, 0)
59
59
  """The minimum supported server version that this client supports."""
60
60
 
61
- def __init__(self, datalab_api_url: str, log_level: str = "WARNING"):
61
+ elevate_permissions: bool = False
62
+ """Whether to make requests in datalab's admin super-user mode, i.e., with
63
+ access to items belonging to other users.
64
+
65
+ This only has an effect if the API key in use belongs to an admin account;
66
+ for any other account the server ignores the request and normal permissions
67
+ apply. Can be set at any point in the client's lifetime to toggle the
68
+ behaviour.
69
+ """
70
+
71
+ def __init__(
72
+ self,
73
+ datalab_api_url: str,
74
+ log_level: str = "WARNING",
75
+ elevate_permissions: bool = False,
76
+ ):
62
77
  """Creates an authenticated client.
63
78
 
64
79
  An API key is required to authenticate requests. The client will attempt to load it from a
@@ -72,10 +87,14 @@ class BaseDatalabClient(metaclass=AutoPrettyPrint):
72
87
  to resolve the underlying API URL (e.g., `https://demo-api.datalab-org.io`
73
88
  will 'redirect' to `https://demo-api.datalab-org.io`).
74
89
  log_level: The logging level to use for the client. Defaults to "WARNING".
90
+ elevate_permissions: Whether to read items belonging to other users, using
91
+ datalab's admin super-user mode. Requires the API key in use to belong
92
+ to an admin account; ignored by the server otherwise. Defaults to False.
75
93
 
76
94
 
77
95
  """
78
96
 
97
+ self.elevate_permissions = elevate_permissions
79
98
  self.datalab_api_url = datalab_api_url
80
99
  if not self.datalab_api_url:
81
100
  raise ValueError("No Datalab API URL provided.")
@@ -317,6 +336,12 @@ This is likely a server-side bug. Please report this issue to the datalab develo
317
336
  f"HTTP {response.status_code} error at {url}: {error_message}"
318
337
  )
319
338
 
339
+ # 204 and 304 carry no entity body by definition
340
+ # Where such a status is expected, an empty body
341
+ # is the success case rather than a parse failure.
342
+ if response.status_code in (204, 304):
343
+ return {}
344
+
320
345
  # Parse JSON response
321
346
  try:
322
347
  data = response.json()
@@ -361,6 +386,7 @@ This is likely a server-side bug. Please report this issue to the datalab develo
361
386
  The JSON response data
362
387
  """
363
388
  timeout = kwargs.pop("timeout", None)
389
+ kwargs = self._apply_elevation(method, kwargs)
364
390
  try:
365
391
  response = self.session.request(
366
392
  method, url, follow_redirects=True, timeout=timeout, **kwargs
@@ -369,6 +395,22 @@ This is likely a server-side bug. Please report this issue to the datalab develo
369
395
  except (httpx.RequestError, httpx.HTTPStatusError) as e:
370
396
  raise DatalabAPIError(f"Request failed for {url}: {e}")
371
397
 
398
+ def _apply_elevation(self, method: str, kwargs: dict[str, Any]) -> dict[str, Any]:
399
+ """Add datalab's super-user flag to a read when running elevated.
400
+
401
+ datalab treats an admin as an ordinary user on GET requests unless the
402
+ request opts in with `sudo=1`, but grants them full access on writes
403
+ with no opt-in at all — so only reads need the flag. Any `params` the
404
+ caller supplied are preserved.
405
+ """
406
+ if not self.elevate_permissions or method.upper() != "GET":
407
+ return kwargs
408
+
409
+ params = dict(kwargs.pop("params", None) or {})
410
+ params.setdefault("sudo", "1")
411
+ kwargs["params"] = params
412
+ return kwargs
413
+
372
414
  def _get(self, url: str, **kwargs) -> dict[str, Any]:
373
415
  """Make a GET request with error handling."""
374
416
  return self._request("GET", url, **kwargs)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datalab-api
3
- Version: 0.5.1
3
+ Version: 0.5.3
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
@@ -120,6 +120,31 @@ with DatalabClient("https://demo-api.datalab-org.io") as client:
120
120
 
121
121
  ```
122
122
 
123
+ ### Elevated (admin) permissions
124
+
125
+ By default a client can only see items its account owns or has been shared on.
126
+ Admins can opt in to datalab's super-user mode to read items belonging to other users, by passing `elevate_permissions`:
127
+
128
+ ```python
129
+ with DatalabClient(
130
+ "https://demo-api.datalab-org.io", elevate_permissions=True
131
+ ) as client:
132
+ # Finds the item even if it belongs to another user
133
+ item = client.get_item(item_id="someone-elses-sample")
134
+ ```
135
+
136
+ This sends datalab's `sudo=1` flag on read requests.
137
+ It requires the API key in use to belong to an **admin** account; for any other account the server ignores
138
+ it and normal permissions apply.
139
+
140
+ The attribute can also be toggled at any point in the client's lifetime:
141
+
142
+ ```python
143
+ client.elevate_permissions = True
144
+ ```
145
+
146
+ Note that objects created while elevated (e.g. uploaded files) are still recorded as belonging to the admin's own account, not the item owner's.
147
+
123
148
  ### Command-line interface (CLI)
124
149
 
125
150
  There is also an EXPERIMENTAL CLI, exposed via the `datalab` command.
@@ -0,0 +1,171 @@
1
+ import pytest
2
+ import respx
3
+ from httpx import Response
4
+
5
+ from datalab_api import DatalabClient
6
+ from datalab_api._base import DatalabAPIError
7
+
8
+
9
+ def test_redirect_url(mocked_api, mocked_ui, fake_api_url, fake_ui_url):
10
+ with pytest.warns(
11
+ UserWarning,
12
+ match="^Found API URL https://api.datalab.industries in HTML meta tag. Creating client with this URL instead.$",
13
+ ):
14
+ client = DatalabClient(fake_ui_url)
15
+
16
+ assert mocked_ui["ui-redirect"].called
17
+ assert mocked_api["info"].called
18
+ assert mocked_api["info-blocks"].called
19
+ assert client.datalab_api_url == fake_api_url
20
+
21
+
22
+ def test_sample_operations(mocked_api, fake_api_url):
23
+ with DatalabClient(fake_api_url) as client:
24
+ assert mocked_api["api"].called
25
+ assert mocked_api["info"].called
26
+ assert mocked_api["info-blocks"].called
27
+
28
+ client.authenticate()
29
+ assert mocked_api["current_user"].called
30
+
31
+ samples = client.get_items(display=True)
32
+ assert mocked_api["samples"].called
33
+ assert len(samples)
34
+ assert samples[0]["item_id"] == "test"
35
+
36
+ sample = client.get_item("KUVEKJ", display=True)
37
+ assert mocked_api["sample-KUVEKJ"].called
38
+ assert sample["item_id"] == "KUVEKJ"
39
+
40
+ # Check that the full server error message is shown for a missing item
41
+ with pytest.raises(
42
+ DatalabAPIError,
43
+ match="No matching items for match={'item_id': 'TEST'} with current authorization",
44
+ ):
45
+ client.get_item("TEST", display=True)
46
+ assert mocked_api["sample-missing-TEST"].called
47
+
48
+ with pytest.raises(DatalabAPIError, match="KeyError"):
49
+ client.update_item(item_id="TEST", item_data={"name": "test"})
50
+ assert mocked_api["bad-save"].called
51
+
52
+
53
+ @respx.mock
54
+ def test_collection_get(fake_api_url, mocked_api):
55
+ with DatalabClient("https://api.datalab.industries") as client:
56
+ assert mocked_api["api"].called
57
+ assert mocked_api["info"].called
58
+ assert mocked_api["info-blocks"].called
59
+
60
+ collection, children = client.get_collection("test_collection", display=True)
61
+ assert mocked_api["collection-test_collection"].called
62
+
63
+
64
+ @respx.mock
65
+ def test_group_get(fake_api_url, mocked_api):
66
+ with DatalabClient("https://api.datalab.industries") as client:
67
+ assert mocked_api["api"].called
68
+ assert mocked_api["info"].called
69
+ assert mocked_api["info-blocks"].called
70
+
71
+ group = client.get_group("test_group", display=True)
72
+ assert mocked_api["search-groups"].called
73
+ assert group["immutable_id"] == "67d0b01e03000cdc75134dbd"
74
+ assert group["group_id"] == "test_group"
75
+
76
+
77
+ @respx.mock
78
+ def test_upload_file(fake_api_url, mocked_api, tmp_path):
79
+ """A new upload is stored by the server and reported as modified."""
80
+ fake_upload = mocked_api.post("/upload-file/", name="upload")
81
+ fake_upload.return_value = Response(
82
+ 201,
83
+ json={
84
+ "status": "success",
85
+ "file_id": "6810ab6bb2b13a2be6c1e37f",
86
+ "file_information": {"name": "data.csv"},
87
+ "is_update": False,
88
+ },
89
+ )
90
+
91
+ test_file = tmp_path / "data.csv"
92
+ test_file.write_text("a,b\n1,2\n")
93
+
94
+ with DatalabClient(fake_api_url) as client:
95
+ result = client.upload_file(item_id="KUVEKJ", file_path=test_file)
96
+
97
+ assert mocked_api["upload"].called
98
+ assert result["file_id"] == "6810ab6bb2b13a2be6c1e37f"
99
+ assert result["not_modified"] is False
100
+
101
+
102
+ @respx.mock
103
+ def test_upload_file_not_modified(fake_api_url, mocked_api, tmp_path):
104
+ """When replacing a file whose content the server already holds, it
105
+ replies 304 with an empty body. That is a successful no-op, so the
106
+ existing file ID is returned with `not_modified` set, rather than
107
+ the empty body being treated as an error."""
108
+ fake_upload = mocked_api.post("/upload-file/", name="upload")
109
+ fake_upload.return_value = Response(304, content=b"")
110
+
111
+ test_file = tmp_path / "data.csv"
112
+ test_file.write_text("a,b\n1,2\n")
113
+
114
+ with DatalabClient(fake_api_url) as client:
115
+ result = client.upload_file(
116
+ item_id="KUVEKJ",
117
+ file_path=test_file,
118
+ replace_file_id="6810ab6bb2b13a2be6c1e37f",
119
+ )
120
+
121
+ assert mocked_api["upload"].called
122
+ assert result["status"] == "success"
123
+ assert result["file_id"] == "6810ab6bb2b13a2be6c1e37f"
124
+ assert result["is_update"] is True
125
+ assert result["not_modified"] is True
126
+
127
+
128
+ def test_elevate_permissions_adds_sudo_to_reads(mocked_api, fake_api_url):
129
+ """An elevated client sends `sudo=1` on GETs (and nothing else)."""
130
+ with DatalabClient(fake_api_url, elevate_permissions=True) as client:
131
+ client.get_item("KUVEKJ")
132
+
133
+ request = mocked_api["sample-KUVEKJ"].calls.last.request
134
+ assert request.url.params.get("sudo") == "1"
135
+
136
+
137
+ def test_elevate_permissions_is_toggleable(mocked_api, fake_api_url):
138
+ """`elevate_permissions` can be flipped after construction."""
139
+ with DatalabClient(fake_api_url) as client:
140
+ client.get_item("KUVEKJ")
141
+ assert "sudo" not in mocked_api["sample-KUVEKJ"].calls.last.request.url.params
142
+
143
+ client.elevate_permissions = True
144
+ client.get_item("KUVEKJ")
145
+ assert mocked_api["sample-KUVEKJ"].calls.last.request.url.params.get("sudo") == "1"
146
+
147
+
148
+ def test_elevate_permissions_not_sent_on_writes(mocked_api, fake_api_url):
149
+ """datalab grants admins full write access with no opt-in, so the flag
150
+ must not leak onto non-GET requests."""
151
+ with DatalabClient(fake_api_url, elevate_permissions=True) as client:
152
+ with pytest.raises(DatalabAPIError):
153
+ # `/save-item/` is mocked as a 500; we only care about the request
154
+ client.update_item("KUVEKJ", {"description": "test"})
155
+
156
+ request = mocked_api["bad-save"].calls.last.request
157
+ assert request.method == "POST"
158
+ assert "sudo" not in request.url.params
159
+
160
+
161
+ def test_elevate_permissions_preserves_existing_params(mocked_api, fake_api_url):
162
+ """A caller-supplied query string must survive elevation."""
163
+ with DatalabClient(fake_api_url, elevate_permissions=True) as client:
164
+ client._get(
165
+ f"{client.datalab_api_url}/get-item-data/KUVEKJ",
166
+ params={"load_blocks": "false"},
167
+ )
168
+
169
+ request = mocked_api["sample-KUVEKJ"].calls.last.request
170
+ assert request.url.params.get("load_blocks") == "false"
171
+ assert request.url.params.get("sudo") == "1"
@@ -1,73 +0,0 @@
1
- import pytest
2
- import respx
3
-
4
- from datalab_api import DatalabClient
5
- from datalab_api._base import DatalabAPIError
6
-
7
-
8
- def test_redirect_url(mocked_api, mocked_ui, fake_api_url, fake_ui_url):
9
- with pytest.warns(
10
- UserWarning,
11
- match="^Found API URL https://api.datalab.industries in HTML meta tag. Creating client with this URL instead.$",
12
- ):
13
- client = DatalabClient(fake_ui_url)
14
-
15
- assert mocked_ui["ui-redirect"].called
16
- assert mocked_api["info"].called
17
- assert mocked_api["info-blocks"].called
18
- assert client.datalab_api_url == fake_api_url
19
-
20
-
21
- def test_sample_operations(mocked_api, fake_api_url):
22
- with DatalabClient(fake_api_url) as client:
23
- assert mocked_api["api"].called
24
- assert mocked_api["info"].called
25
- assert mocked_api["info-blocks"].called
26
-
27
- client.authenticate()
28
- assert mocked_api["current_user"].called
29
-
30
- samples = client.get_items(display=True)
31
- assert mocked_api["samples"].called
32
- assert len(samples)
33
- assert samples[0]["item_id"] == "test"
34
-
35
- sample = client.get_item("KUVEKJ", display=True)
36
- assert mocked_api["sample-KUVEKJ"].called
37
- assert sample["item_id"] == "KUVEKJ"
38
-
39
- # Check that the full server error message is shown for a missing item
40
- with pytest.raises(
41
- DatalabAPIError,
42
- match="No matching items for match={'item_id': 'TEST'} with current authorization",
43
- ):
44
- client.get_item("TEST", display=True)
45
- assert mocked_api["sample-missing-TEST"].called
46
-
47
- with pytest.raises(DatalabAPIError, match="KeyError"):
48
- client.update_item(item_id="TEST", item_data={"name": "test"})
49
- assert mocked_api["bad-save"].called
50
-
51
-
52
- @respx.mock
53
- def test_collection_get(fake_api_url, mocked_api):
54
- with DatalabClient("https://api.datalab.industries") as client:
55
- assert mocked_api["api"].called
56
- assert mocked_api["info"].called
57
- assert mocked_api["info-blocks"].called
58
-
59
- collection, children = client.get_collection("test_collection", display=True)
60
- assert mocked_api["collection-test_collection"].called
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"
File without changes
File without changes
File without changes