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/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))