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.
@@ -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
@@ -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