labapi 1.0.3__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.
- labapi/__init__.py +77 -0
- labapi/client.py +835 -0
- labapi/entry/__init__.py +28 -0
- labapi/entry/attachment.py +191 -0
- labapi/entry/collection.py +209 -0
- labapi/entry/comment.py +15 -0
- labapi/entry/entries/__init__.py +22 -0
- labapi/entry/entries/attachment.py +148 -0
- labapi/entry/entries/base.py +139 -0
- labapi/entry/entries/text.py +69 -0
- labapi/entry/entries/unknown.py +41 -0
- labapi/entry/entries/widget.py +29 -0
- labapi/exceptions.py +80 -0
- labapi/py.typed +1 -0
- labapi/tree/__init__.py +21 -0
- labapi/tree/collection.py +163 -0
- labapi/tree/directory.py +57 -0
- labapi/tree/mixins.py +852 -0
- labapi/tree/notebook.py +69 -0
- labapi/tree/page.py +218 -0
- labapi/user.py +146 -0
- labapi/util/__init__.py +45 -0
- labapi/util/browser.py +125 -0
- labapi/util/extract.py +124 -0
- labapi/util/path.py +367 -0
- labapi/util/types.py +76 -0
- labapi-1.0.3.dist-info/METADATA +210 -0
- labapi-1.0.3.dist-info/RECORD +31 -0
- labapi-1.0.3.dist-info/WHEEL +5 -0
- labapi-1.0.3.dist-info/licenses/LICENSE +121 -0
- labapi-1.0.3.dist-info/top_level.txt +1 -0
labapi/client.py
ADDED
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
"""LabArchives API Client.
|
|
2
|
+
|
|
3
|
+
This module provides the core client for interacting with the LabArchives API,
|
|
4
|
+
handling authentication, request signing, and various API call methods.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ssl
|
|
10
|
+
import warnings
|
|
11
|
+
from base64 import b64encode
|
|
12
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
13
|
+
from contextlib import suppress
|
|
14
|
+
from datetime import datetime, timedelta
|
|
15
|
+
from http.server import SimpleHTTPRequestHandler
|
|
16
|
+
from operator import itemgetter
|
|
17
|
+
from os import getenv
|
|
18
|
+
from secrets import token_urlsafe
|
|
19
|
+
from socketserver import TCPServer
|
|
20
|
+
from time import monotonic
|
|
21
|
+
from types import TracebackType
|
|
22
|
+
from typing import IO, Any, Self, override
|
|
23
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
24
|
+
|
|
25
|
+
from cryptography.hazmat.primitives.hashes import SHA512
|
|
26
|
+
from cryptography.hazmat.primitives.hmac import HMAC
|
|
27
|
+
from lxml.etree import Element, fromstring
|
|
28
|
+
from requests import Response, Session
|
|
29
|
+
from requests import codes as status_codes
|
|
30
|
+
from requests.adapters import HTTPAdapter
|
|
31
|
+
|
|
32
|
+
from .exceptions import ApiError, AuthenticationError
|
|
33
|
+
from .user import User
|
|
34
|
+
from .util import NotebookInit, extract_etree, to_bool
|
|
35
|
+
from .util.browser import detect_default_browser
|
|
36
|
+
|
|
37
|
+
# Error codes that indicate an authentication/credential failure.
|
|
38
|
+
_AUTH_ERROR_CODES: frozenset[int] = frozenset(
|
|
39
|
+
{
|
|
40
|
+
4506, # invalid akid
|
|
41
|
+
4514, # login or password incorrect
|
|
42
|
+
4520, # invalid signature
|
|
43
|
+
4533, # session timed out
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
_DEFAULT_AUTH_CALLBACK_HOST = "127.0.0.1"
|
|
48
|
+
_DEFAULT_AUTH_CALLBACK_PORT = 8089
|
|
49
|
+
_DEFAULT_AUTH_CALLBACK_PATH = "/"
|
|
50
|
+
_DEFAULT_AUTH_CALLBACK_TIMEOUT = 300.0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
from dotenv import load_dotenv # pyright: ignore[reportMissingImports]
|
|
55
|
+
|
|
56
|
+
# Optional behavior: auto-load local `.env` values when `labapi[dotenv]`
|
|
57
|
+
# (python-dotenv) is installed.
|
|
58
|
+
load_dotenv()
|
|
59
|
+
except ImportError:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
context = ssl.create_default_context()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class StreamingResponse:
|
|
67
|
+
"""Wrapper for streamed API responses.
|
|
68
|
+
|
|
69
|
+
Exposes both the chunk iterator and the underlying HTTP response object so
|
|
70
|
+
callers can read headers/status without relying on ``StopIteration.value``.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(self, response: Response):
|
|
74
|
+
"""Initialize a streamed response wrapper."""
|
|
75
|
+
self._response = response
|
|
76
|
+
self._closed = False
|
|
77
|
+
|
|
78
|
+
def __getattr__(self, name: str) -> Any:
|
|
79
|
+
"""Proxy response attributes (e.g., ``headers`` / ``status_code``)."""
|
|
80
|
+
return getattr(self._response, name)
|
|
81
|
+
|
|
82
|
+
def __iter__(self) -> Iterator[bytes]:
|
|
83
|
+
"""Iterate over response bytes in 1MiB chunks."""
|
|
84
|
+
try:
|
|
85
|
+
yield from self._response.iter_content(1024 * 1024)
|
|
86
|
+
finally:
|
|
87
|
+
self.close()
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def response(self) -> Response:
|
|
91
|
+
"""The raw response object backing the stream."""
|
|
92
|
+
return self._response
|
|
93
|
+
|
|
94
|
+
def close(self) -> None:
|
|
95
|
+
"""Close the underlying response and release its connection."""
|
|
96
|
+
if self._closed:
|
|
97
|
+
return
|
|
98
|
+
self._response.close()
|
|
99
|
+
self._closed = True
|
|
100
|
+
|
|
101
|
+
def __enter__(self) -> StreamingResponse:
|
|
102
|
+
"""Enter a context that guarantees connection cleanup on exit."""
|
|
103
|
+
return self
|
|
104
|
+
|
|
105
|
+
def __exit__(
|
|
106
|
+
self,
|
|
107
|
+
_exc_type: type[BaseException] | None,
|
|
108
|
+
_exc_val: BaseException | None,
|
|
109
|
+
_exc_tb: TracebackType | None,
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Close the stream when leaving a ``with`` block."""
|
|
112
|
+
self.close()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class _313HTTPAdapter(HTTPAdapter):
|
|
116
|
+
"""Custom HTTP adapter that disables strict X.509 certificate verification.
|
|
117
|
+
|
|
118
|
+
This adapter is used to work around certain SSL certificate validation issues
|
|
119
|
+
by disabling the VERIFY_X509_STRICT flag. This allows the client to connect
|
|
120
|
+
to servers with certificates that might not pass strict validation.
|
|
121
|
+
|
|
122
|
+
.. warning::
|
|
123
|
+
This reduces security by relaxing certificate validation. Use only when
|
|
124
|
+
necessary and with trusted servers.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def init_poolmanager(self, *args: Any, **kwargs: Any):
|
|
128
|
+
"""Initialize the connection pool manager with a custom SSL context.
|
|
129
|
+
|
|
130
|
+
This method overrides the default pool manager initialization to inject
|
|
131
|
+
a custom SSL context that disables strict X.509 verification.
|
|
132
|
+
|
|
133
|
+
:param args: Positional arguments to pass to the parent init_poolmanager.
|
|
134
|
+
:param kwargs: Keyword arguments to pass to the parent init_poolmanager.
|
|
135
|
+
"""
|
|
136
|
+
context = ssl.create_default_context()
|
|
137
|
+
context.verify_flags &= ~ssl.VERIFY_X509_STRICT
|
|
138
|
+
|
|
139
|
+
super().init_poolmanager(*args, **kwargs, ssl_context=context) # pyright: ignore[reportUnknownMemberType]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class _AuthResponseCollector:
|
|
143
|
+
"""Context manager for binding and waiting on a loopback auth callback."""
|
|
144
|
+
|
|
145
|
+
def __init__(
|
|
146
|
+
self,
|
|
147
|
+
client: Client,
|
|
148
|
+
*,
|
|
149
|
+
port: int = _DEFAULT_AUTH_CALLBACK_PORT,
|
|
150
|
+
callback_path: str = _DEFAULT_AUTH_CALLBACK_PATH,
|
|
151
|
+
timeout: float | None = _DEFAULT_AUTH_CALLBACK_TIMEOUT,
|
|
152
|
+
):
|
|
153
|
+
"""Initialize a loopback auth callback collector."""
|
|
154
|
+
self._client = client
|
|
155
|
+
self._port = port
|
|
156
|
+
self._callback_path = callback_path
|
|
157
|
+
self._timeout = timeout
|
|
158
|
+
self._error: str | None = None
|
|
159
|
+
self._email: str | None = None
|
|
160
|
+
self._auth_code: str | None = None
|
|
161
|
+
self._httpd: TCPServer | None = None
|
|
162
|
+
|
|
163
|
+
def __enter__(self) -> Self:
|
|
164
|
+
"""Bind the loopback callback server and return this collector."""
|
|
165
|
+
collector = self
|
|
166
|
+
callback_path = self._callback_path
|
|
167
|
+
|
|
168
|
+
class AuthRequestHandler(SimpleHTTPRequestHandler):
|
|
169
|
+
def _write_response(self, status_code: int, message: str) -> None:
|
|
170
|
+
self.send_response(status_code)
|
|
171
|
+
self.send_header("Content-type", "text/html")
|
|
172
|
+
self.end_headers()
|
|
173
|
+
self.wfile.write(message.encode("utf-8"))
|
|
174
|
+
|
|
175
|
+
@override
|
|
176
|
+
def do_GET(self) -> None:
|
|
177
|
+
_scheme, _netloc, path, querystring, _fragment = urlsplit(self.path)
|
|
178
|
+
|
|
179
|
+
if path != callback_path:
|
|
180
|
+
self._write_response(404, "Unexpected authentication callback.")
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
query = dict(parse_qsl(querystring))
|
|
184
|
+
|
|
185
|
+
error = query.get("error")
|
|
186
|
+
if error is not None:
|
|
187
|
+
self._write_response(200, f"Error: {error}")
|
|
188
|
+
collector._error = error
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
auth_code = query.get("auth_code")
|
|
192
|
+
email = query.get("email")
|
|
193
|
+
if auth_code is not None and email is not None:
|
|
194
|
+
self._write_response(
|
|
195
|
+
200,
|
|
196
|
+
"Thanks for Authenticating. Close this Window",
|
|
197
|
+
)
|
|
198
|
+
collector._auth_code = auth_code
|
|
199
|
+
collector._email = email
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
self._write_response(400, "Invalid authentication callback.")
|
|
203
|
+
|
|
204
|
+
@override
|
|
205
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
206
|
+
pass
|
|
207
|
+
|
|
208
|
+
class LoopbackTCPServer(TCPServer):
|
|
209
|
+
allow_reuse_address = True
|
|
210
|
+
|
|
211
|
+
self._httpd = LoopbackTCPServer(
|
|
212
|
+
(_DEFAULT_AUTH_CALLBACK_HOST, self._port),
|
|
213
|
+
AuthRequestHandler,
|
|
214
|
+
)
|
|
215
|
+
return self
|
|
216
|
+
|
|
217
|
+
def __exit__(
|
|
218
|
+
self,
|
|
219
|
+
_exc_type: type[BaseException] | None,
|
|
220
|
+
_exc_val: BaseException | None,
|
|
221
|
+
_exc_tb: TracebackType | None,
|
|
222
|
+
) -> None:
|
|
223
|
+
"""Close the loopback callback server."""
|
|
224
|
+
if self._httpd is not None:
|
|
225
|
+
self._httpd.server_close()
|
|
226
|
+
self._httpd = None
|
|
227
|
+
|
|
228
|
+
def wait(self) -> User:
|
|
229
|
+
"""Wait for a valid callback, then log in and return the user."""
|
|
230
|
+
if self._httpd is None:
|
|
231
|
+
raise RuntimeError(
|
|
232
|
+
"collect_auth_response() must be entered before waiting for a callback"
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
deadline = None if self._timeout is None else monotonic() + self._timeout
|
|
236
|
+
|
|
237
|
+
while True:
|
|
238
|
+
self._httpd.timeout = deadline if deadline is None else min(deadline, 0.5)
|
|
239
|
+
self._httpd.handle_request()
|
|
240
|
+
|
|
241
|
+
if self._error is not None:
|
|
242
|
+
raise AuthenticationError(f"Authentication failed: {self._error}")
|
|
243
|
+
|
|
244
|
+
if self._auth_code and self._email:
|
|
245
|
+
return self._client.login(self._email, self._auth_code)
|
|
246
|
+
|
|
247
|
+
if deadline is not None:
|
|
248
|
+
remaining = deadline - monotonic()
|
|
249
|
+
if remaining <= 0:
|
|
250
|
+
raise AuthenticationError(
|
|
251
|
+
"Timed out waiting for the authentication callback"
|
|
252
|
+
)
|
|
253
|
+
self._httpd.timeout = min(remaining, 0.5)
|
|
254
|
+
else:
|
|
255
|
+
self._httpd.timeout = None
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class Client:
|
|
259
|
+
"""A client for the LabArchives API.
|
|
260
|
+
|
|
261
|
+
This class handles the connection to the LabArchives API
|
|
262
|
+
and provides methods for making authenticated API calls.
|
|
263
|
+
It also manages the authentication flow.
|
|
264
|
+
"""
|
|
265
|
+
|
|
266
|
+
def __init__(
|
|
267
|
+
self,
|
|
268
|
+
base_url: str | None = None,
|
|
269
|
+
akid: str | None = None,
|
|
270
|
+
akpass: bytes | str | None = None,
|
|
271
|
+
*,
|
|
272
|
+
strict_cert: bool = True,
|
|
273
|
+
):
|
|
274
|
+
"""Initialize a LabArchives API client.
|
|
275
|
+
|
|
276
|
+
If any parameter is None, the client will attempt to load values from
|
|
277
|
+
a ``.env`` file using ``python-dotenv``. The environment variables used are:
|
|
278
|
+
|
|
279
|
+
- ``API_URL``: The base URL (defaults to ``https://api.labarchives.com``).
|
|
280
|
+
- ``ACCESS_KEYID``: The Access Key ID.
|
|
281
|
+
- ``ACCESS_PWD``: The Access Key Password.
|
|
282
|
+
|
|
283
|
+
:param base_url: The base URL of the LabArchives API (e.g., "https://mynotebook.labarchives.com").
|
|
284
|
+
If None, loaded from the ``API_URL`` environment variable.
|
|
285
|
+
:param akid: The Access Key ID for API authentication.
|
|
286
|
+
If None, loaded from the ``ACCESS_KEYID`` environment variable.
|
|
287
|
+
:param akpass: The Access Key Password for HMAC-SHA512 signing.
|
|
288
|
+
If None, loaded from the ``ACCESS_PWD`` environment variable.
|
|
289
|
+
:param strict_cert: Whether to use strict X.509 certificate verification.
|
|
290
|
+
If False, disables the VERIFY_X509_STRICT flag to allow connections
|
|
291
|
+
to servers with certificates that may not pass strict validation.
|
|
292
|
+
Defaults to True. **Warning:** Setting this to False reduces security.
|
|
293
|
+
"""
|
|
294
|
+
super().__init__()
|
|
295
|
+
|
|
296
|
+
if base_url is None:
|
|
297
|
+
base_url = getenv("API_URL", "https://api.labarchives.com")
|
|
298
|
+
if akid is None:
|
|
299
|
+
akid = getenv("ACCESS_KEYID")
|
|
300
|
+
if akpass is None:
|
|
301
|
+
akpass = getenv("ACCESS_PWD")
|
|
302
|
+
|
|
303
|
+
if not akid or not akpass:
|
|
304
|
+
raise AuthenticationError(
|
|
305
|
+
"ACCESS_KEYID or ACCESS_PWD environment variables not set, and parameters were not provided."
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
parsed_base_url = urlsplit(base_url)
|
|
309
|
+
normalized_base_url = parsed_base_url.geturl()
|
|
310
|
+
if (
|
|
311
|
+
parsed_base_url.scheme not in {"http", "https"}
|
|
312
|
+
or not parsed_base_url.netloc
|
|
313
|
+
):
|
|
314
|
+
raise AuthenticationError(
|
|
315
|
+
"Invalid API_URL/base_url: expected a full HTTP(S) URL such as "
|
|
316
|
+
"'https://api.labarchives.com'."
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
self._base_url = normalized_base_url
|
|
320
|
+
self._akid = akid
|
|
321
|
+
self._hmac = HMAC(
|
|
322
|
+
bytes(akpass, "utf8") if isinstance(akpass, str) else akpass, SHA512()
|
|
323
|
+
)
|
|
324
|
+
self.session = Session()
|
|
325
|
+
self._closed = False
|
|
326
|
+
if not strict_cert:
|
|
327
|
+
self.session.mount("https://", _313HTTPAdapter())
|
|
328
|
+
|
|
329
|
+
def close(self) -> None:
|
|
330
|
+
"""Close the underlying requests session.
|
|
331
|
+
|
|
332
|
+
Once closed, this client should not be used for further API requests.
|
|
333
|
+
Any :class:`~labapi.user.User` objects derived from this client should
|
|
334
|
+
also be treated as no longer usable for API calls.
|
|
335
|
+
"""
|
|
336
|
+
if not self._closed:
|
|
337
|
+
self.session.close()
|
|
338
|
+
self._closed = True
|
|
339
|
+
|
|
340
|
+
def __enter__(self) -> Self:
|
|
341
|
+
"""Return this client for use as a context manager."""
|
|
342
|
+
return self
|
|
343
|
+
|
|
344
|
+
def __exit__(self, *_: object) -> None:
|
|
345
|
+
"""Close the client session when leaving a context-manager block."""
|
|
346
|
+
self.close()
|
|
347
|
+
|
|
348
|
+
def __del__(self) -> None:
|
|
349
|
+
"""Best-effort cleanup for the underlying session at object finalization."""
|
|
350
|
+
with suppress(Exception):
|
|
351
|
+
self.close()
|
|
352
|
+
|
|
353
|
+
def _ensure_open(self) -> None:
|
|
354
|
+
"""Raise if the client has already been closed."""
|
|
355
|
+
if self._closed:
|
|
356
|
+
raise RuntimeError("Client session is closed")
|
|
357
|
+
|
|
358
|
+
def generate_auth_url(self, redirect_url: str) -> str:
|
|
359
|
+
"""Generate a LabArchives login URL for the given callback.
|
|
360
|
+
|
|
361
|
+
This URL is used to initiate the authorization code flow,
|
|
362
|
+
redirecting the user to LabArchives to grant permissions.
|
|
363
|
+
|
|
364
|
+
:param redirect_url: The URL to which LabArchives will redirect the user
|
|
365
|
+
after successful authentication, containing the authorization code.
|
|
366
|
+
:returns: The full authentication URL.
|
|
367
|
+
"""
|
|
368
|
+
return self.construct_url(
|
|
369
|
+
"api_user_login",
|
|
370
|
+
{"redirect_uri": redirect_url},
|
|
371
|
+
expires_in=timedelta(minutes=5),
|
|
372
|
+
should_prefix_api=False,
|
|
373
|
+
signature_method=redirect_url,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
def login(self, user_email: str, auth_code: str) -> User:
|
|
377
|
+
"""Log in a user with an authentication code.
|
|
378
|
+
|
|
379
|
+
This code can come from the standard browser flow or from a one-hour
|
|
380
|
+
code generated in the LabArchives website.
|
|
381
|
+
|
|
382
|
+
This method exchanges the authorization code for user access information,
|
|
383
|
+
including their user ID and available notebooks.
|
|
384
|
+
|
|
385
|
+
:param user_email: The email address of the authenticating user.
|
|
386
|
+
:param auth_code: The authorization code received from LabArchives.
|
|
387
|
+
:returns: A :class:`~labapi.user.User` object representing the authenticated user session.
|
|
388
|
+
"""
|
|
389
|
+
uid_tree = self.api_get(
|
|
390
|
+
"users/user_access_info", login_or_email=user_email, password=auth_code
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
uid = itemgetter("id")(extract_etree(uid_tree, {"id": str}))
|
|
394
|
+
|
|
395
|
+
notebooks: list[NotebookInit] = []
|
|
396
|
+
|
|
397
|
+
for notebook in uid_tree.iterfind(".//notebook"):
|
|
398
|
+
try:
|
|
399
|
+
notebook_id, notebook_name, is_default = itemgetter(
|
|
400
|
+
"id", "name", "is-default"
|
|
401
|
+
)(
|
|
402
|
+
extract_etree(
|
|
403
|
+
notebook, {"id": str, "name": str, "is-default": to_bool}
|
|
404
|
+
)
|
|
405
|
+
)
|
|
406
|
+
except ValueError as e:
|
|
407
|
+
warnings.warn(f"Failed to parse notebook entry: {e}", stacklevel=2)
|
|
408
|
+
continue
|
|
409
|
+
|
|
410
|
+
notebooks.append(NotebookInit(notebook_id, notebook_name, is_default))
|
|
411
|
+
|
|
412
|
+
notebooks.sort(key=lambda k: k.is_default)
|
|
413
|
+
|
|
414
|
+
return User(uid, user_email, notebooks, self)
|
|
415
|
+
|
|
416
|
+
@staticmethod
|
|
417
|
+
def _handle_request_status(response: Response) -> None:
|
|
418
|
+
"""Raise an error for an unsuccessful HTTP response.
|
|
419
|
+
|
|
420
|
+
Attempts to parse the LabArchives ``<error>`` XML element from the response
|
|
421
|
+
body to surface a specific error code and description. Falls back to a
|
|
422
|
+
generic message if the body is not parseable XML.
|
|
423
|
+
|
|
424
|
+
:param response: The HTTP response object from the requests library.
|
|
425
|
+
:raises AuthenticationError: For API error codes 4506, 4514, 4520, 4533.
|
|
426
|
+
:raises ApiError: For all other non-200 responses.
|
|
427
|
+
"""
|
|
428
|
+
# NOTE: See https://mynotebook.labarchives.com/share/LabArchives%2520API/NDEuNnwyNy8zMi9UcmVlTm9kZS83NDE1Mjk1NTJ8MTA1LjY= [ELN Error Codes]
|
|
429
|
+
if response.status_code != status_codes.ok:
|
|
430
|
+
error_code: int | None = None
|
|
431
|
+
error_desc: str | None = None
|
|
432
|
+
try:
|
|
433
|
+
tree = fromstring(bytes(response.text, encoding="utf-8"))
|
|
434
|
+
code_text = tree.findtext("./error-code")
|
|
435
|
+
if code_text is not None:
|
|
436
|
+
error_code = int(code_text)
|
|
437
|
+
error_desc = tree.findtext("./error-description")
|
|
438
|
+
except Exception:
|
|
439
|
+
pass
|
|
440
|
+
|
|
441
|
+
if error_code is not None:
|
|
442
|
+
message = f"[{error_code}] {error_desc}"
|
|
443
|
+
if error_code in _AUTH_ERROR_CODES:
|
|
444
|
+
raise AuthenticationError(message, error_code)
|
|
445
|
+
raise ApiError(message, error_code)
|
|
446
|
+
|
|
447
|
+
raise ApiError(
|
|
448
|
+
f"API request failed with status code {response.status_code} "
|
|
449
|
+
f"for URL {response.url}: {response.text}"
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
def stream_api_get(
|
|
453
|
+
self, api_method_uri: str | Sequence[str], **kwargs: Any
|
|
454
|
+
) -> StreamingResponse:
|
|
455
|
+
"""Send a GET request and return a streamed response wrapper.
|
|
456
|
+
|
|
457
|
+
This is useful for downloading large files or when the response content
|
|
458
|
+
needs to be processed incrementally.
|
|
459
|
+
|
|
460
|
+
:param api_method_uri: The API method URI (e.g., "get_file_attachment").
|
|
461
|
+
Can be a string or a sequence of strings representing path segments.
|
|
462
|
+
:param kwargs: Additional query parameters to pass to the API method.
|
|
463
|
+
:returns: A :class:`StreamingResponse` wrapper with both an iterable
|
|
464
|
+
byte stream and the full ``requests.Response``.
|
|
465
|
+
:raises RuntimeError: If the client session has been closed.
|
|
466
|
+
:raises AuthenticationError: If LabArchives rejects the request due to
|
|
467
|
+
invalid or expired credentials.
|
|
468
|
+
:raises ApiError: If LabArchives returns any other non-success response.
|
|
469
|
+
"""
|
|
470
|
+
self._ensure_open()
|
|
471
|
+
request = self.session.get(
|
|
472
|
+
self.construct_url(api_method_uri, query=kwargs), stream=True
|
|
473
|
+
)
|
|
474
|
+
try:
|
|
475
|
+
Client._handle_request_status(request)
|
|
476
|
+
except Exception:
|
|
477
|
+
request.close()
|
|
478
|
+
raise
|
|
479
|
+
|
|
480
|
+
return StreamingResponse(request)
|
|
481
|
+
|
|
482
|
+
def stream_api_post(
|
|
483
|
+
self,
|
|
484
|
+
api_method_uri: str | Sequence[str],
|
|
485
|
+
body: Mapping[str, str] | IO[bytes] | IO[str],
|
|
486
|
+
**kwargs: Any,
|
|
487
|
+
) -> StreamingResponse:
|
|
488
|
+
"""Send a POST request and return a streamed response wrapper.
|
|
489
|
+
|
|
490
|
+
This is useful for uploading large files or when the response content
|
|
491
|
+
needs to be processed incrementally.
|
|
492
|
+
|
|
493
|
+
:param api_method_uri: The API method URI (e.g., "upload_file_attachment").
|
|
494
|
+
Can be a string or a sequence of strings representing path segments.
|
|
495
|
+
:param body: The request body, which can be a mapping of form data or a file-like object.
|
|
496
|
+
:param kwargs: Additional query parameters to pass to the API method.
|
|
497
|
+
:returns: A :class:`StreamingResponse` wrapper with both an iterable
|
|
498
|
+
byte stream and the full ``requests.Response``.
|
|
499
|
+
:raises RuntimeError: If the client session has been closed.
|
|
500
|
+
:raises AuthenticationError: If LabArchives rejects the request due to
|
|
501
|
+
invalid or expired credentials.
|
|
502
|
+
:raises ApiError: If LabArchives returns any other non-success response.
|
|
503
|
+
"""
|
|
504
|
+
self._ensure_open()
|
|
505
|
+
request = self.session.post(
|
|
506
|
+
self.construct_url(api_method_uri, query=kwargs), data=body, stream=True
|
|
507
|
+
)
|
|
508
|
+
try:
|
|
509
|
+
Client._handle_request_status(request)
|
|
510
|
+
except Exception:
|
|
511
|
+
request.close()
|
|
512
|
+
raise
|
|
513
|
+
|
|
514
|
+
return StreamingResponse(request)
|
|
515
|
+
|
|
516
|
+
def raw_api_get(
|
|
517
|
+
self, api_method_uri: str | Sequence[str], **kwargs: Any
|
|
518
|
+
) -> Response:
|
|
519
|
+
"""Send a GET request and return the raw ``requests.Response``.
|
|
520
|
+
|
|
521
|
+
This method is suitable for API calls where the full HTTP response,
|
|
522
|
+
including headers and status code, is needed, and the content is not
|
|
523
|
+
expected to be streamed.
|
|
524
|
+
|
|
525
|
+
:param api_method_uri: The API method URI (e.g., "get_entry_data").
|
|
526
|
+
Can be a string or a sequence of strings representing path segments.
|
|
527
|
+
:param kwargs: Additional query parameters to pass to the API method.
|
|
528
|
+
:returns: The ``requests.Response`` object containing the API response.
|
|
529
|
+
:raises RuntimeError: If the client session has been closed.
|
|
530
|
+
:raises AuthenticationError: If LabArchives rejects the request due to
|
|
531
|
+
invalid or expired credentials.
|
|
532
|
+
:raises ApiError: If LabArchives returns any other non-success response.
|
|
533
|
+
"""
|
|
534
|
+
self._ensure_open()
|
|
535
|
+
request = self.session.get(self.construct_url(api_method_uri, query=kwargs))
|
|
536
|
+
Client._handle_request_status(request)
|
|
537
|
+
|
|
538
|
+
return request
|
|
539
|
+
|
|
540
|
+
def raw_api_post(
|
|
541
|
+
self,
|
|
542
|
+
api_method_uri: str | Sequence[str],
|
|
543
|
+
body: Mapping[str, str] | IO[bytes] | IO[str],
|
|
544
|
+
**kwargs: Any,
|
|
545
|
+
) -> Response:
|
|
546
|
+
"""Send a POST request and return the raw ``requests.Response``.
|
|
547
|
+
|
|
548
|
+
This method is suitable for API calls where the full HTTP response,
|
|
549
|
+
including headers and status code, is needed, and the content is not
|
|
550
|
+
expected to be streamed.
|
|
551
|
+
|
|
552
|
+
:param api_method_uri: The API method URI (e.g., "create_entry").
|
|
553
|
+
Can be a string or a sequence of strings representing path segments.
|
|
554
|
+
:param body: The request body, which can be a mapping of form data or a file-like object.
|
|
555
|
+
:param kwargs: Additional query parameters to pass to the API method.
|
|
556
|
+
:returns: The ``requests.Response`` object containing the API response.
|
|
557
|
+
:raises RuntimeError: If the client session has been closed.
|
|
558
|
+
:raises AuthenticationError: If LabArchives rejects the request due to
|
|
559
|
+
invalid or expired credentials.
|
|
560
|
+
:raises ApiError: If LabArchives returns any other non-success response.
|
|
561
|
+
"""
|
|
562
|
+
self._ensure_open()
|
|
563
|
+
request = self.session.post(
|
|
564
|
+
self.construct_url(api_method_uri, query=kwargs), data=body
|
|
565
|
+
)
|
|
566
|
+
Client._handle_request_status(request)
|
|
567
|
+
|
|
568
|
+
return request
|
|
569
|
+
|
|
570
|
+
def api_get(self, api_method_uri: str | Sequence[str], **kwargs: Any) -> Element:
|
|
571
|
+
"""Send a GET request and parse the XML response into an element.
|
|
572
|
+
|
|
573
|
+
This is the primary method for retrieving structured data from the API.
|
|
574
|
+
|
|
575
|
+
:param api_method_uri: The API method URI (e.g., "get_notebook_info").
|
|
576
|
+
Can be a string or a sequence of strings representing path segments.
|
|
577
|
+
:param kwargs: Additional query parameters to pass to the API method.
|
|
578
|
+
:returns: An ``lxml.etree.Element`` representing the root of the XML
|
|
579
|
+
response.
|
|
580
|
+
:raises RuntimeError: If the client session has been closed.
|
|
581
|
+
:raises AuthenticationError: If LabArchives rejects the request due to
|
|
582
|
+
invalid or expired credentials.
|
|
583
|
+
:raises ApiError: If LabArchives returns any other non-success response.
|
|
584
|
+
|
|
585
|
+
Invalid XML propagates ``lxml.etree.XMLSyntaxError``.
|
|
586
|
+
"""
|
|
587
|
+
return fromstring(self.raw_api_get(api_method_uri, **kwargs).content)
|
|
588
|
+
|
|
589
|
+
def api_post(
|
|
590
|
+
self,
|
|
591
|
+
api_method_uri: str | Sequence[str],
|
|
592
|
+
body: Mapping[str, str] | IO[bytes] | IO[str],
|
|
593
|
+
**kwargs: Any,
|
|
594
|
+
) -> Element:
|
|
595
|
+
"""Send a POST request and parse the XML response into an element.
|
|
596
|
+
|
|
597
|
+
This is the primary method for sending data to the API and receiving
|
|
598
|
+
structured XML responses.
|
|
599
|
+
|
|
600
|
+
:param api_method_uri: The API method URI (e.g., "create_entry").
|
|
601
|
+
Can be a string or a sequence of strings representing path segments.
|
|
602
|
+
:param body: The request body, which can be a mapping of form data or a file-like object.
|
|
603
|
+
:param kwargs: Additional query parameters to pass to the API method.
|
|
604
|
+
:returns: An ``lxml.etree.Element`` representing the root of the XML
|
|
605
|
+
response.
|
|
606
|
+
:raises RuntimeError: If the client session has been closed.
|
|
607
|
+
:raises AuthenticationError: If LabArchives rejects the request due to
|
|
608
|
+
invalid or expired credentials.
|
|
609
|
+
:raises ApiError: If LabArchives returns any other non-success response.
|
|
610
|
+
|
|
611
|
+
Invalid XML propagates ``lxml.etree.XMLSyntaxError``.
|
|
612
|
+
"""
|
|
613
|
+
return fromstring(self.raw_api_post(api_method_uri, body, **kwargs).content)
|
|
614
|
+
|
|
615
|
+
def default_authenticate(
|
|
616
|
+
self,
|
|
617
|
+
*,
|
|
618
|
+
port: int = _DEFAULT_AUTH_CALLBACK_PORT,
|
|
619
|
+
timeout: float | None = _DEFAULT_AUTH_CALLBACK_TIMEOUT,
|
|
620
|
+
) -> User:
|
|
621
|
+
"""Authenticate a user with the default browser and a loopback callback.
|
|
622
|
+
|
|
623
|
+
This method opens a browser window, directs the user to the LabArchives
|
|
624
|
+
authentication page, and then listens on a loopback callback URL on
|
|
625
|
+
``127.0.0.1:<port>`` for the redirect containing the authorization code.
|
|
626
|
+
If no compatible browser is available, it falls back to printing the
|
|
627
|
+
authentication URL to the terminal so the user can open it manually.
|
|
628
|
+
|
|
629
|
+
.. note::
|
|
630
|
+
Automatic browser launching requires the optional
|
|
631
|
+
``labapi[builtin-auth]`` dependencies.
|
|
632
|
+
|
|
633
|
+
:param port: The local callback port to listen on. Defaults to ``8089``.
|
|
634
|
+
:param timeout: Maximum number of seconds to wait for a valid callback.
|
|
635
|
+
Defaults to five minutes. Pass ``None`` to wait indefinitely.
|
|
636
|
+
:returns: A :class:`~labapi.user.User` object representing the authenticated user session.
|
|
637
|
+
:raises RuntimeError: If the client session has been closed.
|
|
638
|
+
:raises ImportError: If automatic browser-based authentication is
|
|
639
|
+
requested but the optional builtin-auth
|
|
640
|
+
dependencies are not installed.
|
|
641
|
+
:raises AuthenticationError: If authentication fails or times out.
|
|
642
|
+
"""
|
|
643
|
+
self._ensure_open()
|
|
644
|
+
callback_path = f"/auth/{token_urlsafe(24)}/"
|
|
645
|
+
auth_url = self.generate_auth_url(
|
|
646
|
+
f"http://{_DEFAULT_AUTH_CALLBACK_HOST}:{port}{callback_path}"
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
driver = None
|
|
650
|
+
with self.collect_auth_response(
|
|
651
|
+
port=port,
|
|
652
|
+
callback_path=callback_path,
|
|
653
|
+
timeout=timeout,
|
|
654
|
+
) as auth_response_collector:
|
|
655
|
+
try:
|
|
656
|
+
match detect_default_browser():
|
|
657
|
+
case "chrome":
|
|
658
|
+
import selenium.webdriver as webdriver # pyright: ignore[reportMissingImports]
|
|
659
|
+
|
|
660
|
+
driver = webdriver.Chrome(options=webdriver.ChromeOptions())
|
|
661
|
+
print("Opening Chrome for authentication...")
|
|
662
|
+
case "firefox":
|
|
663
|
+
import selenium.webdriver as webdriver # pyright: ignore[reportMissingImports]
|
|
664
|
+
|
|
665
|
+
driver = webdriver.Firefox(options=webdriver.FirefoxOptions())
|
|
666
|
+
print("Opening Firefox for authentication...")
|
|
667
|
+
case "edge":
|
|
668
|
+
import selenium.webdriver as webdriver # pyright: ignore[reportMissingImports]
|
|
669
|
+
|
|
670
|
+
driver = webdriver.Edge(options=webdriver.EdgeOptions())
|
|
671
|
+
print("Opening Edge for authentication...")
|
|
672
|
+
case "terminal":
|
|
673
|
+
print("Open authentication URL in your browser:")
|
|
674
|
+
print(auth_url)
|
|
675
|
+
|
|
676
|
+
if driver is not None:
|
|
677
|
+
driver.get(auth_url)
|
|
678
|
+
print(
|
|
679
|
+
"Please complete the authentication in the opened browser window..."
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
return auth_response_collector.wait()
|
|
683
|
+
except ImportError as e:
|
|
684
|
+
raise ImportError(
|
|
685
|
+
"The builtin-auth dependencies are required for automatic browser-based authentication. "
|
|
686
|
+
"Install with: pip install labapi[builtin-auth]\n"
|
|
687
|
+
"Alternatively, use manual authentication with LA_AUTH_BROWSER=terminal."
|
|
688
|
+
) from e
|
|
689
|
+
finally:
|
|
690
|
+
if driver is not None:
|
|
691
|
+
driver.quit()
|
|
692
|
+
|
|
693
|
+
def collect_auth_response(
|
|
694
|
+
self,
|
|
695
|
+
*,
|
|
696
|
+
port: int = _DEFAULT_AUTH_CALLBACK_PORT,
|
|
697
|
+
callback_path: str = _DEFAULT_AUTH_CALLBACK_PATH,
|
|
698
|
+
timeout: float | None = _DEFAULT_AUTH_CALLBACK_TIMEOUT,
|
|
699
|
+
) -> _AuthResponseCollector:
|
|
700
|
+
"""Return a context manager for collecting a loopback auth callback.
|
|
701
|
+
|
|
702
|
+
The returned collector binds a local HTTP server on enter, waits for a
|
|
703
|
+
valid callback via its ``wait()`` method, and closes the server on
|
|
704
|
+
exit.
|
|
705
|
+
|
|
706
|
+
:param port: The local callback port to listen on. Defaults to ``8089``.
|
|
707
|
+
:param callback_path: The callback path to accept. Defaults to ``/``.
|
|
708
|
+
:param timeout: Maximum number of seconds to wait for a valid callback.
|
|
709
|
+
Defaults to five minutes. Pass ``None`` to wait indefinitely.
|
|
710
|
+
:returns: An enterable collector with a ``wait()`` method for the authentication callback.
|
|
711
|
+
"""
|
|
712
|
+
self._ensure_open()
|
|
713
|
+
if not callback_path.startswith("/"):
|
|
714
|
+
callback_path = f"/{callback_path}"
|
|
715
|
+
|
|
716
|
+
return _AuthResponseCollector(
|
|
717
|
+
self,
|
|
718
|
+
port=port,
|
|
719
|
+
callback_path=callback_path,
|
|
720
|
+
timeout=timeout,
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
def construct_url(
|
|
724
|
+
self,
|
|
725
|
+
api_method_uri: str | Sequence[str],
|
|
726
|
+
query: Mapping[str, Any],
|
|
727
|
+
expires_in: timedelta | datetime | None = None,
|
|
728
|
+
*,
|
|
729
|
+
should_prefix_api: bool = True,
|
|
730
|
+
signature_method: str | None = None,
|
|
731
|
+
) -> str:
|
|
732
|
+
"""Construct a fully qualified and signed URL for an API method.
|
|
733
|
+
|
|
734
|
+
This method handles the assembly of the base URL, API method path,
|
|
735
|
+
query parameters, and the HMAC-SHA512 signature required by the LabArchives API.
|
|
736
|
+
|
|
737
|
+
:param api_method_uri: The API method URI (e.g., "get_notebook_info").
|
|
738
|
+
Can be a string or a sequence of strings representing path segments.
|
|
739
|
+
:param query: A dictionary of query parameters to include in the URL.
|
|
740
|
+
:param expires_in: The duration for which the URL should be valid. Can be a
|
|
741
|
+
`timedelta` object or a specific `datetime` object. If None,
|
|
742
|
+
defaults to 60 seconds from now.
|
|
743
|
+
:param should_prefix_api: If True, ensures the API method path starts with "api/".
|
|
744
|
+
Defaults to True.
|
|
745
|
+
:param signature_method: An optional string to use as the API method for
|
|
746
|
+
signature generation, overriding `api_method_uri`.
|
|
747
|
+
Useful for methods like `api_user_login` where the
|
|
748
|
+
actual method name differs from the URI path.
|
|
749
|
+
:returns: The fully constructed and signed URL.
|
|
750
|
+
:raises ValueError: If ``api_method_uri`` does not contain any non-empty
|
|
751
|
+
path segments after normalization.
|
|
752
|
+
"""
|
|
753
|
+
if isinstance(api_method_uri, str):
|
|
754
|
+
api_method_uri = api_method_uri.split("/")
|
|
755
|
+
|
|
756
|
+
raw_method_parts = tuple([part for part in api_method_uri if part.strip()])
|
|
757
|
+
method_parts = (
|
|
758
|
+
raw_method_parts[1:]
|
|
759
|
+
if raw_method_parts and raw_method_parts[0] == "api"
|
|
760
|
+
else raw_method_parts
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
if not method_parts:
|
|
764
|
+
raise ValueError(
|
|
765
|
+
"api_method_uri must contain at least one non-empty path segment"
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
if should_prefix_api:
|
|
769
|
+
method_parts = ("api", *method_parts)
|
|
770
|
+
|
|
771
|
+
api_method = method_parts[-1] if signature_method is None else signature_method
|
|
772
|
+
|
|
773
|
+
scheme, netloc, path, _qs, _f = urlsplit(self._base_url)
|
|
774
|
+
|
|
775
|
+
if not path.endswith("/"):
|
|
776
|
+
path += "/"
|
|
777
|
+
|
|
778
|
+
path += "/".join(method_parts)
|
|
779
|
+
|
|
780
|
+
url = urlunsplit((scheme, netloc, path, urlencode(query), _f))
|
|
781
|
+
|
|
782
|
+
if expires_in:
|
|
783
|
+
return self._sign_url(url, api_method, expires_in)
|
|
784
|
+
return self._sign_url(url, api_method)
|
|
785
|
+
|
|
786
|
+
def _signature(self, api_method: str, expiry: int) -> str:
|
|
787
|
+
"""Generate the HMAC-SHA512 signature for a LabArchives API request.
|
|
788
|
+
|
|
789
|
+
This private method is used internally by `_sign_url` to create the
|
|
790
|
+
cryptographic signature based on the Access Key ID, API method, and expiry.
|
|
791
|
+
|
|
792
|
+
:param api_method: The specific API method name used in the signature calculation.
|
|
793
|
+
:param expiry: The expiration timestamp (in milliseconds since epoch) for the request.
|
|
794
|
+
:returns: The base64-encoded HMAC-SHA512 signature.
|
|
795
|
+
"""
|
|
796
|
+
hmac = self._hmac.copy()
|
|
797
|
+
|
|
798
|
+
hmac.update(f"{self._akid}{api_method}{expiry}".encode())
|
|
799
|
+
|
|
800
|
+
sig_raw = hmac.finalize()
|
|
801
|
+
|
|
802
|
+
return b64encode(sig_raw).decode()
|
|
803
|
+
|
|
804
|
+
def _sign_url(
|
|
805
|
+
self,
|
|
806
|
+
url: str,
|
|
807
|
+
api_method: str,
|
|
808
|
+
expires_in: timedelta | datetime = timedelta(seconds=60),
|
|
809
|
+
) -> str:
|
|
810
|
+
"""Sign a URL and append the LabArchives auth query parameters.
|
|
811
|
+
|
|
812
|
+
This private method appends the Access Key ID, expiration timestamp, and
|
|
813
|
+
the generated signature to the URL's query string.
|
|
814
|
+
|
|
815
|
+
:param url: The unsigned URL to be signed.
|
|
816
|
+
:param api_method: The specific API method name used for signature generation.
|
|
817
|
+
:param expires_in: The duration for which the URL should be valid. Can be a
|
|
818
|
+
`timedelta` object or a specific `datetime` object. Defaults
|
|
819
|
+
to 60 seconds from the current time.
|
|
820
|
+
:returns: The fully signed URL.
|
|
821
|
+
"""
|
|
822
|
+
scheme, netloc, path, querystring, _f = urlsplit(url)
|
|
823
|
+
query = dict(parse_qsl(querystring))
|
|
824
|
+
|
|
825
|
+
if isinstance(expires_in, timedelta):
|
|
826
|
+
expiry = round((datetime.now() + expires_in).timestamp() * 1000)
|
|
827
|
+
else:
|
|
828
|
+
expiry = round(expires_in.timestamp() * 1000)
|
|
829
|
+
sig = self._signature(api_method, expiry)
|
|
830
|
+
|
|
831
|
+
query["akid"] = self._akid
|
|
832
|
+
query["expires"] = str(expiry)
|
|
833
|
+
query["sig"] = sig
|
|
834
|
+
|
|
835
|
+
return urlunsplit((scheme, netloc, path, urlencode(query), _f))
|