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/tree/notebook.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Notebook Module.
|
|
2
|
+
|
|
3
|
+
This module defines the :class:`~labapi.tree.notebook.Notebook` class,
|
|
4
|
+
representing a LabArchives notebook. It extends :class:`~labapi.tree.mixins.AbstractTreeContainer`
|
|
5
|
+
to manage its hierarchical content (directories and pages) and provides
|
|
6
|
+
notebook-specific functionalities.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING, override
|
|
12
|
+
|
|
13
|
+
from .mixins import AbstractTreeContainer, HasNameMixin
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from labapi.user import User
|
|
17
|
+
from labapi.util import NotebookInit
|
|
18
|
+
|
|
19
|
+
from .collection import Notebooks
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Notebook(AbstractTreeContainer):
|
|
23
|
+
"""Represents a LabArchives notebook, acting as the root of a tree structure.
|
|
24
|
+
|
|
25
|
+
A notebook is a specialized :class:`~labapi.tree.mixins.AbstractTreeContainer`
|
|
26
|
+
that holds directories and pages. It provides methods to access notebook-specific
|
|
27
|
+
information and manage its contents.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, init: NotebookInit, user: User, notebooks: Notebooks):
|
|
31
|
+
"""Initialize a notebook.
|
|
32
|
+
|
|
33
|
+
:param init: Initial data for the notebook.
|
|
34
|
+
:param user: The authenticated user.
|
|
35
|
+
:param notebooks: The collection of notebooks this notebook belongs to.
|
|
36
|
+
"""
|
|
37
|
+
super().__init__("0", init.name, self, self, user)
|
|
38
|
+
self._id = init.id
|
|
39
|
+
self._is_default = init.is_default
|
|
40
|
+
self._notebooks = notebooks
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
@override
|
|
44
|
+
def id(self) -> str:
|
|
45
|
+
"""Return the notebook identifier.
|
|
46
|
+
|
|
47
|
+
:returns: The notebook's ID.
|
|
48
|
+
"""
|
|
49
|
+
return self._id
|
|
50
|
+
|
|
51
|
+
@HasNameMixin.name.setter
|
|
52
|
+
def name(self, value: str):
|
|
53
|
+
"""Set the notebook name.
|
|
54
|
+
|
|
55
|
+
This operation updates the notebook's name in LabArchives via an API call.
|
|
56
|
+
|
|
57
|
+
:param value: The new name for the notebook.
|
|
58
|
+
"""
|
|
59
|
+
self.user.api_get("notebooks/modify_notebook_info", nbid=self.id, name=value)
|
|
60
|
+
|
|
61
|
+
self._name = value
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def is_default(self) -> bool:
|
|
65
|
+
"""Return whether this notebook is the user's default notebook.
|
|
66
|
+
|
|
67
|
+
:returns: True if the notebook is the default, False otherwise.
|
|
68
|
+
"""
|
|
69
|
+
return self._is_default
|
labapi/tree/page.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Notebook Page Module.
|
|
2
|
+
|
|
3
|
+
This module defines the :class:`~labapi.tree.page.NotebookPage` class,
|
|
4
|
+
representing a page within a LabArchives notebook. It extends
|
|
5
|
+
:class:`~labapi.tree.mixins.AbstractTreeNode` and provides access to the
|
|
6
|
+
entries contained within the page.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import warnings
|
|
12
|
+
from typing import TYPE_CHECKING, Any, Literal, Self, cast, override
|
|
13
|
+
|
|
14
|
+
from labapi.entry import Attachment, Entries, Entry, UnknownEntry
|
|
15
|
+
from labapi.util import ALL_PART_TYPES, InsertBehavior, extract_etree
|
|
16
|
+
|
|
17
|
+
from .mixins import AbstractTreeContainer, AbstractTreeNode
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from labapi.user import User
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class NotebookPage(AbstractTreeNode):
|
|
24
|
+
"""Represents a single page within a LabArchives notebook.
|
|
25
|
+
|
|
26
|
+
A `NotebookPage` is a leaf node in the tree structure and contains
|
|
27
|
+
a collection of :class:`~labapi.entry.Entry` objects. It provides
|
|
28
|
+
functionalities to access and manage these entries.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
tree_id: str,
|
|
34
|
+
name: str,
|
|
35
|
+
root: AbstractTreeContainer,
|
|
36
|
+
parent: AbstractTreeContainer,
|
|
37
|
+
user: User,
|
|
38
|
+
):
|
|
39
|
+
"""Initialize a notebook page.
|
|
40
|
+
|
|
41
|
+
:param tree_id: The unique ID of the page.
|
|
42
|
+
:param name: The name of the page.
|
|
43
|
+
:param root: The root node of the tree (the Notebook).
|
|
44
|
+
:param parent: The parent node of this page (a Directory or Notebook).
|
|
45
|
+
:param user: The authenticated user.
|
|
46
|
+
"""
|
|
47
|
+
super().__init__(tree_id, name, root, parent, user)
|
|
48
|
+
self._entries: Entries | None = None
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
@override
|
|
52
|
+
def id(self) -> str:
|
|
53
|
+
"""Return the page identifier.
|
|
54
|
+
|
|
55
|
+
:returns: The page's ID.
|
|
56
|
+
"""
|
|
57
|
+
return super().id
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def entries(self) -> Entries:
|
|
61
|
+
"""Return this page's entries, loading them on first access.
|
|
62
|
+
|
|
63
|
+
This property lazily loads the entries from the LabArchives API if they
|
|
64
|
+
have not been loaded yet.
|
|
65
|
+
|
|
66
|
+
.. note::
|
|
67
|
+
Slicing on the returned collection provides snapshots.
|
|
68
|
+
Iterators over the collection are also snapshots and are therefore
|
|
69
|
+
insulated from later collection mutations.
|
|
70
|
+
|
|
71
|
+
:returns: An :class:`~labapi.entry.Entries` object managing the page's entries.
|
|
72
|
+
"""
|
|
73
|
+
if self._entries is None:
|
|
74
|
+
entries: list[Entry[Any]] = []
|
|
75
|
+
|
|
76
|
+
entries_tree = self._user.api_get(
|
|
77
|
+
"tree_tools/get_entries_for_page",
|
|
78
|
+
page_tree_id=self.id,
|
|
79
|
+
nbid=self.root.id,
|
|
80
|
+
entry_data=True,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
for entry in entries_tree.iterfind(".//entry"):
|
|
84
|
+
entry_data = extract_etree(
|
|
85
|
+
entry,
|
|
86
|
+
{
|
|
87
|
+
"eid": str,
|
|
88
|
+
"part-type": str,
|
|
89
|
+
"attach-file-name": str,
|
|
90
|
+
"attach-content-type": str,
|
|
91
|
+
"entry-data": str,
|
|
92
|
+
},
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
part_type = entry_data["part-type"]
|
|
96
|
+
|
|
97
|
+
if part_type in ALL_PART_TYPES:
|
|
98
|
+
if Entry.is_registered(part_type):
|
|
99
|
+
# Cast extracted string values to ensure type checker knows they're not None
|
|
100
|
+
entries.append(
|
|
101
|
+
Entry.from_part_type(
|
|
102
|
+
part_type,
|
|
103
|
+
cast(str, entry_data["eid"]),
|
|
104
|
+
cast(str, entry_data["entry-data"]),
|
|
105
|
+
self._user,
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
else:
|
|
109
|
+
warnings.warn(
|
|
110
|
+
f"Entry type '{part_type}' (ID: {entry_data['eid']}) is recognized but not "
|
|
111
|
+
f"implemented in labapi. Wrapping as UnknownEntry.",
|
|
112
|
+
UserWarning,
|
|
113
|
+
stacklevel=2,
|
|
114
|
+
)
|
|
115
|
+
entries.append(
|
|
116
|
+
UnknownEntry(
|
|
117
|
+
cast(str, entry_data["eid"]),
|
|
118
|
+
cast(str, entry_data["entry-data"]),
|
|
119
|
+
self._user,
|
|
120
|
+
part_type=part_type,
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
else:
|
|
124
|
+
warnings.warn(
|
|
125
|
+
f"Unknown entry type '{part_type}' (ID: {entry_data['eid']}) encountered. "
|
|
126
|
+
f"Wrapping as UnknownEntry.",
|
|
127
|
+
RuntimeWarning,
|
|
128
|
+
stacklevel=2,
|
|
129
|
+
)
|
|
130
|
+
entries.append(
|
|
131
|
+
UnknownEntry(
|
|
132
|
+
cast(str, entry_data["eid"]),
|
|
133
|
+
cast(str, entry_data["entry-data"]),
|
|
134
|
+
self._user,
|
|
135
|
+
part_type=part_type,
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
self._entries = Entries(entries, self._user, self)
|
|
140
|
+
|
|
141
|
+
return self._entries
|
|
142
|
+
|
|
143
|
+
@override
|
|
144
|
+
def copy_to(self, destination: AbstractTreeContainer) -> NotebookPage:
|
|
145
|
+
"""Copy this page and its entries into ``destination``.
|
|
146
|
+
|
|
147
|
+
.. warning::
|
|
148
|
+
This method has known limitations:
|
|
149
|
+
|
|
150
|
+
- LabArchives may rename attachment files during copy operations
|
|
151
|
+
- Only certain entry types are fully supported (text, plain text, headers, attachments)
|
|
152
|
+
- Some entry types may fail to copy and will cause errors
|
|
153
|
+
|
|
154
|
+
:param destination: The target container to copy the page to.
|
|
155
|
+
:returns: A new instance of the copied page in the destination.
|
|
156
|
+
|
|
157
|
+
Copy behavior for attachments is explicit:
|
|
158
|
+
|
|
159
|
+
- attachment payloads are copied by reading and re-uploading the attachment content,
|
|
160
|
+
- attachment resources opened during copy are always released,
|
|
161
|
+
- any per-entry copy failure is reported via warning and that entry is skipped.
|
|
162
|
+
|
|
163
|
+
.. note::
|
|
164
|
+
This method is best-effort and may produce partial copies if one or more
|
|
165
|
+
entries fail while others succeed.
|
|
166
|
+
|
|
167
|
+
:raises RuntimeWarning: Emitted when an individual entry fails to copy.
|
|
168
|
+
"""
|
|
169
|
+
new_page = destination.create(
|
|
170
|
+
NotebookPage, self.name, if_exists=InsertBehavior.Ignore
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
for entry in self.entries:
|
|
174
|
+
entry_content: Any | None = None
|
|
175
|
+
try:
|
|
176
|
+
entry_content = entry.content
|
|
177
|
+
# Re-upload behavior is intentional: copy_to creates a new entry on the
|
|
178
|
+
# destination page using the source entry's runtime class and content.
|
|
179
|
+
# For attachments, Entries.create uploads the payload and returns a
|
|
180
|
+
# distinct destination attachment entry; it does not mutate the source
|
|
181
|
+
# entry or preserve source attachment IDs.
|
|
182
|
+
assert entry_content is not None
|
|
183
|
+
new_page.entries.create(cast(Any, entry.__class__), entry_content)
|
|
184
|
+
except Exception as exc:
|
|
185
|
+
warnings.warn(
|
|
186
|
+
f"Failed to copy entry {entry.id!r} ({entry.content_type!r}) from page "
|
|
187
|
+
f"{self.id!r} to page {new_page.id!r}: {exc}. This entry was skipped.",
|
|
188
|
+
RuntimeWarning,
|
|
189
|
+
stacklevel=2,
|
|
190
|
+
)
|
|
191
|
+
finally:
|
|
192
|
+
if isinstance(entry_content, Attachment):
|
|
193
|
+
entry_content.close()
|
|
194
|
+
|
|
195
|
+
return new_page
|
|
196
|
+
|
|
197
|
+
@override
|
|
198
|
+
def is_dir(self) -> Literal[False]:
|
|
199
|
+
"""Return ``False`` because pages are leaf nodes.
|
|
200
|
+
|
|
201
|
+
:returns: Always False.
|
|
202
|
+
"""
|
|
203
|
+
return False
|
|
204
|
+
|
|
205
|
+
@override
|
|
206
|
+
def refresh(self) -> Self:
|
|
207
|
+
"""Refresh this page by clearing its cached entries.
|
|
208
|
+
|
|
209
|
+
This method clears the internal entries cache, forcing the page
|
|
210
|
+
to re-fetch its entries from the LabArchives API on the next access.
|
|
211
|
+
|
|
212
|
+
.. note::
|
|
213
|
+
Currently only clears the entries cache. Future implementation should
|
|
214
|
+
properly invalidate all entry objects before clearing.
|
|
215
|
+
"""
|
|
216
|
+
# TODO: Properly invalidate all entry objects before clearing
|
|
217
|
+
self._entries = None
|
|
218
|
+
return self
|
labapi/user.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""LabArchives User Module.
|
|
2
|
+
|
|
3
|
+
This module defines the :class:`~labapi.user.User` class, which represents an
|
|
4
|
+
authenticated user session with the LabArchives API. It provides methods for
|
|
5
|
+
interacting with the API on behalf of the user, managing notebooks, and
|
|
6
|
+
accessing user-specific information.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import IO, TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from labapi.tree.collection import Notebooks
|
|
14
|
+
from labapi.util import extract_etree
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Mapping, Sequence
|
|
18
|
+
|
|
19
|
+
from labapi.util import NotebookInit
|
|
20
|
+
|
|
21
|
+
from .client import Client
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class User:
|
|
25
|
+
"""Represents an authenticated LabArchives user session.
|
|
26
|
+
|
|
27
|
+
This class holds user-specific information such as the user ID and provides
|
|
28
|
+
an interface to interact with the LabArchives API, particularly for
|
|
29
|
+
accessing and managing notebooks and their contents.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
uid: str,
|
|
35
|
+
email: str,
|
|
36
|
+
notebooks: Sequence[NotebookInit],
|
|
37
|
+
client: Client,
|
|
38
|
+
):
|
|
39
|
+
"""Initialize a user session.
|
|
40
|
+
|
|
41
|
+
:param uid: The unique ID of the user.
|
|
42
|
+
:param email: The email address of the user.
|
|
43
|
+
:param notebooks: A sequence of :class:`~labapi.util.types.NotebookInit` objects
|
|
44
|
+
representing the notebooks accessible to the user.
|
|
45
|
+
:param client: The :class:`~labapi.client.Client` instance used for API communication.
|
|
46
|
+
"""
|
|
47
|
+
super().__init__()
|
|
48
|
+
self._id: str = uid
|
|
49
|
+
self._email: str = email
|
|
50
|
+
self._notebooks = Notebooks(notebooks, self)
|
|
51
|
+
self._client = client
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def id(self) -> str:
|
|
55
|
+
"""The unique ID of the user.
|
|
56
|
+
|
|
57
|
+
:returns: The user's ID.
|
|
58
|
+
"""
|
|
59
|
+
return self._id
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def email(self) -> str:
|
|
63
|
+
"""The email address of the user.
|
|
64
|
+
|
|
65
|
+
:returns: The user's email.
|
|
66
|
+
"""
|
|
67
|
+
return self._email
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def client(self) -> Client:
|
|
71
|
+
"""The :class:`~labapi.client.Client` instance associated with this user session.
|
|
72
|
+
|
|
73
|
+
:returns: The client instance.
|
|
74
|
+
"""
|
|
75
|
+
return self._client
|
|
76
|
+
|
|
77
|
+
def api_get(self, api_method_uri: str | Sequence[str], **kwargs: Any):
|
|
78
|
+
"""Send a GET request on behalf of this user.
|
|
79
|
+
|
|
80
|
+
This method automatically appends the user's ID to the API call.
|
|
81
|
+
|
|
82
|
+
:param api_method_uri: The API method URI (e.g., "get_user_settings").
|
|
83
|
+
Can be a string or a sequence of strings representing path segments.
|
|
84
|
+
:param kwargs: Additional query parameters to pass to the API method.
|
|
85
|
+
:returns: The response from the API, typically an
|
|
86
|
+
``lxml.etree.Element``.
|
|
87
|
+
:raises RuntimeError: If the underlying client session has been closed.
|
|
88
|
+
:raises AuthenticationError: If LabArchives rejects the request due to
|
|
89
|
+
invalid or expired credentials.
|
|
90
|
+
:raises ApiError: If LabArchives returns any other non-success response.
|
|
91
|
+
|
|
92
|
+
Invalid XML propagates ``lxml.etree.XMLSyntaxError``.
|
|
93
|
+
"""
|
|
94
|
+
return self._client.api_get(api_method_uri, **kwargs, uid=self._id)
|
|
95
|
+
|
|
96
|
+
def api_post(
|
|
97
|
+
self,
|
|
98
|
+
api_method_uri: str | Sequence[str],
|
|
99
|
+
body: Mapping[str, str] | IO[bytes] | IO[str],
|
|
100
|
+
**kwargs: Any,
|
|
101
|
+
):
|
|
102
|
+
"""Send a POST request on behalf of this user.
|
|
103
|
+
|
|
104
|
+
This method automatically appends the user's ID to the API call.
|
|
105
|
+
|
|
106
|
+
:param api_method_uri: The API method URI (e.g., "create_entry").
|
|
107
|
+
Can be a string or a sequence of strings representing path segments.
|
|
108
|
+
:param body: The request body, which can be a mapping of form data or a file-like object.
|
|
109
|
+
:param kwargs: Additional query parameters to pass to the API method.
|
|
110
|
+
:returns: The response from the API, typically an
|
|
111
|
+
``lxml.etree.Element``.
|
|
112
|
+
:raises RuntimeError: If the underlying client session has been closed.
|
|
113
|
+
:raises AuthenticationError: If LabArchives rejects the request due to
|
|
114
|
+
invalid or expired credentials.
|
|
115
|
+
:raises ApiError: If LabArchives returns any other non-success response.
|
|
116
|
+
|
|
117
|
+
Invalid XML propagates ``lxml.etree.XMLSyntaxError``.
|
|
118
|
+
"""
|
|
119
|
+
return self._client.api_post(api_method_uri, body, **kwargs, uid=self._id)
|
|
120
|
+
|
|
121
|
+
def get_max_upload_size(self) -> int:
|
|
122
|
+
"""Return the maximum upload size for this user in bytes.
|
|
123
|
+
|
|
124
|
+
The unit of the returned value is bytes.
|
|
125
|
+
|
|
126
|
+
:returns: The maximum upload size in bytes.
|
|
127
|
+
:raises RuntimeError: If the underlying client session has been closed.
|
|
128
|
+
:raises AuthenticationError: If LabArchives rejects the request due to
|
|
129
|
+
invalid or expired credentials.
|
|
130
|
+
:raises ApiError: If LabArchives returns any other non-success response.
|
|
131
|
+
:raises labapi.exceptions.ExtractionError: If the response does not
|
|
132
|
+
include ``max-file-size``.
|
|
133
|
+
|
|
134
|
+
Invalid XML propagates ``lxml.etree.XMLSyntaxError``.
|
|
135
|
+
"""
|
|
136
|
+
return extract_etree(
|
|
137
|
+
self.api_get("users/max_file_size"), {"max-file-size": int}
|
|
138
|
+
)["max-file-size"]
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def notebooks(self) -> Notebooks:
|
|
142
|
+
"""Provides access to the user's notebooks.
|
|
143
|
+
|
|
144
|
+
:returns: A :class:`~labapi.tree.collection.Notebooks` object managing the user's notebooks.
|
|
145
|
+
"""
|
|
146
|
+
return self._notebooks
|
labapi/util/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Utility Functions and Classes for LabArchives API Client.
|
|
2
|
+
|
|
3
|
+
This package provides various utility functions and classes used throughout
|
|
4
|
+
the LabArchives API client, including XML extraction, type conversions,
|
|
5
|
+
indexing mechanisms, and data structures for notebook initialization.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .extract import extract_etree, to_bool
|
|
9
|
+
from .path import NotebookPath
|
|
10
|
+
from .types import (
|
|
11
|
+
IdIndex,
|
|
12
|
+
IdOrNameIndex,
|
|
13
|
+
Index,
|
|
14
|
+
InsertBehavior,
|
|
15
|
+
JsonData,
|
|
16
|
+
NameIndex,
|
|
17
|
+
NotebookInit,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
#: All known LabArchives entry part types.
|
|
21
|
+
ALL_PART_TYPES = (
|
|
22
|
+
"Attachment",
|
|
23
|
+
"plain text entry",
|
|
24
|
+
"heading",
|
|
25
|
+
"text entry",
|
|
26
|
+
"widget entry",
|
|
27
|
+
"sketch entry",
|
|
28
|
+
"reference entry",
|
|
29
|
+
"equation entry",
|
|
30
|
+
"assignment entry",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"ALL_PART_TYPES",
|
|
35
|
+
"IdIndex",
|
|
36
|
+
"IdOrNameIndex",
|
|
37
|
+
"Index",
|
|
38
|
+
"InsertBehavior",
|
|
39
|
+
"JsonData",
|
|
40
|
+
"NameIndex",
|
|
41
|
+
"NotebookInit",
|
|
42
|
+
"NotebookPath",
|
|
43
|
+
"extract_etree",
|
|
44
|
+
"to_bool",
|
|
45
|
+
]
|
labapi/util/browser.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Browser detection helpers for interactive authentication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import warnings
|
|
7
|
+
from typing import Literal, TypeGuard
|
|
8
|
+
|
|
9
|
+
_DETECTABLE_BROWSERS = ("chrome", "firefox", "edge")
|
|
10
|
+
_CHOOSEABLE_BROWSERS = ("chrome", "firefox", "edge", "terminal")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
type _DetectableBrowser = Literal["chrome", "firefox", "edge"]
|
|
14
|
+
type _ChoosableBrowser = Literal["chrome", "firefox", "edge", "terminal"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _is_choosable(string: str) -> TypeGuard[_ChoosableBrowser]:
|
|
18
|
+
return string in _CHOOSEABLE_BROWSERS
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _parse_detectable(string: str | None) -> _DetectableBrowser | Literal[False]:
|
|
22
|
+
if string is None:
|
|
23
|
+
return False
|
|
24
|
+
|
|
25
|
+
lowered = string.lower().strip()
|
|
26
|
+
for d in _DETECTABLE_BROWSERS:
|
|
27
|
+
if d in lowered:
|
|
28
|
+
return d
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _get_env_browser() -> _ChoosableBrowser | None:
|
|
33
|
+
# TODO put the load dotenv here
|
|
34
|
+
browser = os.getenv("LA_AUTH_BROWSER", "").strip().lower()
|
|
35
|
+
|
|
36
|
+
if browser == "":
|
|
37
|
+
return None
|
|
38
|
+
if _is_choosable(browser):
|
|
39
|
+
return browser
|
|
40
|
+
|
|
41
|
+
warnings.warn(
|
|
42
|
+
f"Unrecognized LA_AUTH_BROWSER value {browser!r}; "
|
|
43
|
+
"supported values are chrome, firefox, edge, or terminal. "
|
|
44
|
+
"Falling back to automatic browser detection.",
|
|
45
|
+
stacklevel=2,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _find_chosen_browser(browser: _ChoosableBrowser | None) -> _ChoosableBrowser | None:
|
|
52
|
+
if browser == "terminal":
|
|
53
|
+
return browser
|
|
54
|
+
if browser is None:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
import installed_browsers # pyright: ignore[reportMissingImports, reportMissingTypeStubs]
|
|
59
|
+
except ImportError:
|
|
60
|
+
warnings.warn(
|
|
61
|
+
"Non-terminal browsers require the optional 'builtin-auth' "
|
|
62
|
+
"dependencies. Install them with: pip install 'labapi[builtin-auth]' "
|
|
63
|
+
"or set LA_AUTH_BROWSER=terminal for manual authentication.",
|
|
64
|
+
stacklevel=2,
|
|
65
|
+
)
|
|
66
|
+
return "terminal"
|
|
67
|
+
|
|
68
|
+
if installed_browsers.do_i_have_installed(browser):
|
|
69
|
+
return browser
|
|
70
|
+
|
|
71
|
+
warnings.warn(
|
|
72
|
+
f"Configured LA_AUTH_BROWSER value {browser!r} is not installed. "
|
|
73
|
+
"Falling back to automatic browser detection.",
|
|
74
|
+
stacklevel=2,
|
|
75
|
+
)
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _autodetect_browser() -> _DetectableBrowser | None:
|
|
80
|
+
try:
|
|
81
|
+
import installed_browsers # pyright: ignore[reportMissingImports, reportMissingTypeStubs]
|
|
82
|
+
except ImportError:
|
|
83
|
+
warnings.warn(
|
|
84
|
+
"Automatic browser detection requires the optional 'builtin-auth' "
|
|
85
|
+
"dependencies. Install them with: pip install 'labapi[builtin-auth]' "
|
|
86
|
+
"or set LA_AUTH_BROWSER=terminal for manual authentication.",
|
|
87
|
+
stacklevel=2,
|
|
88
|
+
)
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
raw_default_browser = installed_browsers.what_is_the_default_browser()
|
|
93
|
+
default_browser = _parse_detectable(raw_default_browser)
|
|
94
|
+
|
|
95
|
+
if default_browser:
|
|
96
|
+
return default_browser
|
|
97
|
+
|
|
98
|
+
for browser in installed_browsers.browsers():
|
|
99
|
+
name = _parse_detectable(browser.get("name"))
|
|
100
|
+
if name:
|
|
101
|
+
return name
|
|
102
|
+
|
|
103
|
+
warnings.warn(
|
|
104
|
+
"Automatic browser detection failed: No compatible browser. Falling back to terminal/manual auth.",
|
|
105
|
+
RuntimeWarning,
|
|
106
|
+
stacklevel=2,
|
|
107
|
+
)
|
|
108
|
+
return None
|
|
109
|
+
except Exception as exc:
|
|
110
|
+
warnings.warn(
|
|
111
|
+
f"Automatic browser detection failed: {exc}. Falling back to terminal/manual auth.",
|
|
112
|
+
RuntimeWarning,
|
|
113
|
+
stacklevel=2,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def detect_default_browser() -> _ChoosableBrowser:
|
|
118
|
+
"""Resolve the preferred browser for the current auth attempt."""
|
|
119
|
+
env_browser = _get_env_browser()
|
|
120
|
+
chosen_browser = _find_chosen_browser(env_browser)
|
|
121
|
+
|
|
122
|
+
if chosen_browser is None:
|
|
123
|
+
return _autodetect_browser() or "terminal"
|
|
124
|
+
|
|
125
|
+
return chosen_browser
|