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
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Base entry classes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from inspect import isabstract
|
|
7
|
+
from typing import TYPE_CHECKING, Any, TypeVar
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from labapi.user import User
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
T = TypeVar("T")
|
|
14
|
+
|
|
15
|
+
_entries_registry: dict[str, type[Entry[Any]]] = {}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Entry[T](ABC):
|
|
19
|
+
"""Abstract base class for all entry types on a LabArchives page.
|
|
20
|
+
|
|
21
|
+
This class provides a common interface for different entry types such as
|
|
22
|
+
text entries, headers, attachments, and widgets. It uses a generic type
|
|
23
|
+
parameter `T` to represent the content type of the entry.
|
|
24
|
+
|
|
25
|
+
LabArchives does not currently expose an API endpoint for deleting
|
|
26
|
+
individual entries, so this class intentionally does not provide a
|
|
27
|
+
``delete()`` method.
|
|
28
|
+
|
|
29
|
+
:param T: The type of content stored in the entry (e.g., str for text, Attachment for files).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
_part_type: str
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def is_registered(part_type: str) -> bool:
|
|
36
|
+
"""Return whether an entry class is registered for ``part_type``.
|
|
37
|
+
|
|
38
|
+
:param part_type: The LabArchives part type identifier to check.
|
|
39
|
+
:returns: True if a class is registered for this part type, False otherwise.
|
|
40
|
+
"""
|
|
41
|
+
return part_type in _entries_registry
|
|
42
|
+
|
|
43
|
+
@staticmethod
|
|
44
|
+
def class_of(part_type: str) -> type[Entry[Any]]:
|
|
45
|
+
"""Return the registered entry class for ``part_type``.
|
|
46
|
+
|
|
47
|
+
:param part_type: The LabArchives part type identifier.
|
|
48
|
+
:returns: The :class:`Entry` subclass registered for this part type.
|
|
49
|
+
:raises KeyError: If no class is registered for the specified part type.
|
|
50
|
+
"""
|
|
51
|
+
return _entries_registry[part_type]
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def from_part_type(part_type: str, eid: str, data: str, user: User) -> Entry[Any]:
|
|
55
|
+
"""Create an entry instance for a LabArchives part type.
|
|
56
|
+
|
|
57
|
+
This method takes a part type string and returns the corresponding
|
|
58
|
+
entry class instance. The part type is normalized before matching.
|
|
59
|
+
|
|
60
|
+
:param part_type: The type of entry to create (e.g., "heading", "text entry",
|
|
61
|
+
"plain text entry", "attachment", "widget entry").
|
|
62
|
+
:param eid: The unique ID of the entry.
|
|
63
|
+
:param data: The entry data. For text-based entries, this is the text content.
|
|
64
|
+
For attachment entries, this is the caption.
|
|
65
|
+
:param user: The authenticated user associated with this entry.
|
|
66
|
+
:returns: An entry instance of the appropriate type.
|
|
67
|
+
:raises NotImplementedError: If the part type is not recognized or implemented.
|
|
68
|
+
"""
|
|
69
|
+
try:
|
|
70
|
+
klass = _entries_registry[part_type]
|
|
71
|
+
return klass(eid, data, user)
|
|
72
|
+
except KeyError as err:
|
|
73
|
+
raise NotImplementedError(f"{part_type}") from err
|
|
74
|
+
|
|
75
|
+
# TODO perms
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
eid: str,
|
|
79
|
+
data: str,
|
|
80
|
+
user: User,
|
|
81
|
+
):
|
|
82
|
+
"""Initialize an entry.
|
|
83
|
+
|
|
84
|
+
:param eid: The unique ID of the entry.
|
|
85
|
+
:param user: The authenticated user associated with this entry.
|
|
86
|
+
"""
|
|
87
|
+
super().__init__()
|
|
88
|
+
self._id = eid
|
|
89
|
+
self._data = data
|
|
90
|
+
self._user = user
|
|
91
|
+
|
|
92
|
+
def __init_subclass__(cls, part_type: str = "", **kwargs: Any) -> None:
|
|
93
|
+
"""Register concrete entry subclasses by their LabArchives part type."""
|
|
94
|
+
if not isabstract(cls) and part_type == "":
|
|
95
|
+
raise TypeError(f"{cls.__name__} must define a part_type")
|
|
96
|
+
|
|
97
|
+
cls._part_type = part_type
|
|
98
|
+
|
|
99
|
+
_entries_registry[part_type] = cls
|
|
100
|
+
super().__init_subclass__(**kwargs)
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def id(self):
|
|
104
|
+
"""Return the unique identifier of the entry.
|
|
105
|
+
|
|
106
|
+
:returns: The entry's ID as a string.
|
|
107
|
+
"""
|
|
108
|
+
return self._id
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def content_type(self) -> str:
|
|
112
|
+
"""Return the LabArchives content type identifier for this entry.
|
|
113
|
+
|
|
114
|
+
:returns: A string representing the entry's type (e.g., "text entry", "Attachment").
|
|
115
|
+
"""
|
|
116
|
+
return self._part_type
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
@abstractmethod
|
|
120
|
+
def content(self) -> T:
|
|
121
|
+
"""Return the entry content.
|
|
122
|
+
|
|
123
|
+
The specific type of the content depends on the entry type
|
|
124
|
+
(e.g., string for text entries, :class:`~labapi.entry.attachment.Attachment` for attachments).
|
|
125
|
+
|
|
126
|
+
:returns: The content of the entry.
|
|
127
|
+
"""
|
|
128
|
+
raise NotImplementedError
|
|
129
|
+
|
|
130
|
+
@content.setter
|
|
131
|
+
@abstractmethod
|
|
132
|
+
def content(self, value: T) -> None:
|
|
133
|
+
"""Set the entry content.
|
|
134
|
+
|
|
135
|
+
This operation typically updates the entry in LabArchives via an API call.
|
|
136
|
+
|
|
137
|
+
:param value: The new content for the entry.
|
|
138
|
+
"""
|
|
139
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Text-based Entry Classes Module.
|
|
2
|
+
|
|
3
|
+
This module defines various classes for text-based entries within LabArchives,
|
|
4
|
+
including a base class for common text entry functionalities and specific
|
|
5
|
+
implementations for plain text, rich text, and header entries.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING, override
|
|
11
|
+
|
|
12
|
+
from .base import Entry
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from labapi.user import User
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PlainTextEntry(Entry[str], part_type="plain text entry"):
|
|
19
|
+
"""Represents a plain text entry on a LabArchives page.
|
|
20
|
+
|
|
21
|
+
This class is used for entries containing unformatted, raw text.
|
|
22
|
+
Additionally, it provides common functionalities for entries whose content is
|
|
23
|
+
represented as a string, including methods for getting and setting the content.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, eid: str, data: str, user: User):
|
|
27
|
+
"""Initialize a plain-text entry.
|
|
28
|
+
|
|
29
|
+
:param eid: The unique ID of the entry.
|
|
30
|
+
:param data: The text content of the entry.
|
|
31
|
+
:param user: The authenticated user.
|
|
32
|
+
"""
|
|
33
|
+
super().__init__(eid, data, user)
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
@override
|
|
37
|
+
def content(self) -> str:
|
|
38
|
+
"""Return the entry text.
|
|
39
|
+
|
|
40
|
+
:returns: The content of the entry as a string.
|
|
41
|
+
"""
|
|
42
|
+
return self._data
|
|
43
|
+
|
|
44
|
+
@content.setter
|
|
45
|
+
@override
|
|
46
|
+
def content(self, value: str) -> None:
|
|
47
|
+
"""Set the entry text.
|
|
48
|
+
|
|
49
|
+
This operation updates the entry's content in LabArchives via an API call.
|
|
50
|
+
|
|
51
|
+
:param value: The new text content for the entry.
|
|
52
|
+
"""
|
|
53
|
+
self._user.api_post("entries/update_entry", {"entry_data": value}, eid=self.id)
|
|
54
|
+
|
|
55
|
+
self._data = value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TextEntry(PlainTextEntry, part_type="text entry"):
|
|
59
|
+
"""Represents a rich text entry on a LabArchives page.
|
|
60
|
+
|
|
61
|
+
This class is used for entries containing formatted text, typically HTML.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class HeaderEntry(PlainTextEntry, part_type="heading"):
|
|
66
|
+
"""Represents a header entry on a LabArchives page.
|
|
67
|
+
|
|
68
|
+
This class is used for entries that function as headings or titles within a page.
|
|
69
|
+
"""
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Unknown entry fallback type."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, override
|
|
6
|
+
|
|
7
|
+
from .base import Entry
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from labapi.user import User
|
|
11
|
+
|
|
12
|
+
_UNKNOWN_ENTRY_REGISTRY_SENTINEL = "__labapi_internal_unknown_entry__"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class UnknownEntry(Entry[str], part_type=_UNKNOWN_ENTRY_REGISTRY_SENTINEL):
|
|
16
|
+
"""Fallback entry wrapper for unimplemented or unknown upstream part types."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, eid: str, data: str, user: User, *, part_type: str):
|
|
19
|
+
"""Initialize an unknown entry wrapper."""
|
|
20
|
+
super().__init__(eid, data, user)
|
|
21
|
+
self._source_part_type = part_type
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
@override
|
|
25
|
+
def content_type(self) -> str:
|
|
26
|
+
"""Return the original upstream part type."""
|
|
27
|
+
return self._source_part_type
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
@override
|
|
31
|
+
def content(self) -> str:
|
|
32
|
+
"""Return the raw entry payload."""
|
|
33
|
+
return self._data
|
|
34
|
+
|
|
35
|
+
@content.setter
|
|
36
|
+
@override
|
|
37
|
+
def content(self, value: str) -> None:
|
|
38
|
+
"""Reject updates for unsupported entry types."""
|
|
39
|
+
raise NotImplementedError(
|
|
40
|
+
f"Cannot update unsupported entry type '{self._source_part_type}'"
|
|
41
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Widget Entry Module.
|
|
2
|
+
|
|
3
|
+
This module defines the :class:`~labapi.entry.entries.widget.WidgetEntry` class,
|
|
4
|
+
which represents a widget entry within a LabArchives page.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import override
|
|
10
|
+
|
|
11
|
+
from .text import PlainTextEntry
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class WidgetEntry(PlainTextEntry, part_type="widget entry"):
|
|
15
|
+
"""Represents a widget entry on a LabArchives page.
|
|
16
|
+
|
|
17
|
+
Widget entries typically embed interactive content or external applications.
|
|
18
|
+
At this time, LabArchives returns the value of the widget as a JSON string
|
|
19
|
+
and not the content making up the widget.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
@PlainTextEntry.content.setter
|
|
23
|
+
@override
|
|
24
|
+
def content(self, value: str) -> None:
|
|
25
|
+
"""Widget entries are read-only for the API.
|
|
26
|
+
|
|
27
|
+
:raises AttributeError: Always, as updating widget content is not supported.
|
|
28
|
+
"""
|
|
29
|
+
raise AttributeError("Widget entries are read-only.")
|
labapi/exceptions.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Custom exception types raised by ``labapi``."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class LabArchivesError(Exception):
|
|
5
|
+
"""Base for all ``labapi`` exceptions."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AuthenticationError(LabArchivesError):
|
|
9
|
+
"""Missing credentials or failed authentication flow.
|
|
10
|
+
|
|
11
|
+
``error_code`` is set when the error originates from the LabArchives API
|
|
12
|
+
(e.g. 4506 invalid akid, 4514 bad login, 4520 bad signature, 4533 session
|
|
13
|
+
timeout). It is ``None`` for locally-detected credential errors.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, message: str, error_code: int | None = None) -> None:
|
|
17
|
+
"""Initialize an authentication error."""
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.error_code = error_code
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ApiError(LabArchivesError):
|
|
23
|
+
"""LabArchives API returned an error or unexpected response.
|
|
24
|
+
|
|
25
|
+
``error_code`` is the numeric code from the API ``<error-code>`` element,
|
|
26
|
+
or ``None`` if the error was detected before parsing the response body.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, message: str, error_code: int | None = None) -> None:
|
|
30
|
+
"""Initialize an API error."""
|
|
31
|
+
super().__init__(message)
|
|
32
|
+
self.error_code = error_code
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class NodeExistsError(LabArchivesError):
|
|
36
|
+
"""A tree node with the given name already exists (raised by InsertBehavior.Raise)."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class PathError(LabArchivesError):
|
|
40
|
+
"""Path construction or resolution failed."""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
message: str,
|
|
45
|
+
*,
|
|
46
|
+
path: str | None = None,
|
|
47
|
+
parent: str | None = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Initialize a path error with optional path context."""
|
|
50
|
+
super().__init__(message)
|
|
51
|
+
self.path = path
|
|
52
|
+
self.parent = parent
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TraversalError(LabArchivesError):
|
|
56
|
+
"""Tree traversal failed."""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
message: str,
|
|
61
|
+
*,
|
|
62
|
+
path: str | None = None,
|
|
63
|
+
segment: str | None = None,
|
|
64
|
+
parent: str | None = None,
|
|
65
|
+
available_children: list[str] | None = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Initialize a traversal error with optional path context."""
|
|
68
|
+
super().__init__(message)
|
|
69
|
+
self.path = path
|
|
70
|
+
self.segment = segment
|
|
71
|
+
self.parent = parent
|
|
72
|
+
self.available_children = available_children
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ExtractionError(LabArchivesError, ValueError):
|
|
76
|
+
"""Structured parse/extraction failure while reading XML data."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TreeChildParseError(ExtractionError):
|
|
80
|
+
"""A tree child node could not be parsed from a tree-level response."""
|
labapi/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
labapi/tree/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""LabArchives Tree Structure Package.
|
|
2
|
+
|
|
3
|
+
This package defines the hierarchical structure of LabArchives notebooks,
|
|
4
|
+
including abstract base classes for tree nodes and containers, and concrete
|
|
5
|
+
implementations for notebooks, directories, and pages.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .collection import Notebooks
|
|
9
|
+
from .directory import NotebookDirectory
|
|
10
|
+
from .mixins import AbstractTreeContainer, AbstractTreeNode
|
|
11
|
+
from .notebook import Notebook
|
|
12
|
+
from .page import NotebookPage
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"AbstractTreeContainer",
|
|
16
|
+
"AbstractTreeNode",
|
|
17
|
+
"Notebook",
|
|
18
|
+
"NotebookDirectory",
|
|
19
|
+
"NotebookPage",
|
|
20
|
+
"Notebooks",
|
|
21
|
+
]
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Notebook Collection Module.
|
|
2
|
+
|
|
3
|
+
This module defines the :class:`~labapi.tree.collection.Notebooks` class,
|
|
4
|
+
which acts as a collection manager for a user's LabArchives notebooks.
|
|
5
|
+
It provides methods for accessing, iterating over, and creating notebooks.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import ItemsView, Iterator, KeysView, Mapping, Sequence, ValuesView
|
|
11
|
+
from typing import TYPE_CHECKING, Literal, overload, override
|
|
12
|
+
|
|
13
|
+
from labapi.exceptions import ApiError
|
|
14
|
+
from labapi.util import IdOrNameIndex, Index, NotebookInit, extract_etree
|
|
15
|
+
|
|
16
|
+
from .notebook import Notebook
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from labapi.user import User
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Notebooks(Mapping[IdOrNameIndex, Notebook | Sequence[Notebook]]):
|
|
23
|
+
"""A collection of LabArchives notebooks accessible to a user.
|
|
24
|
+
|
|
25
|
+
This class provides dictionary-like access to notebooks by their ID or name,
|
|
26
|
+
and supports creating new notebooks. It manages a list of :class:`~labapi.tree.notebook.Notebook`
|
|
27
|
+
objects.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, notebooks: Sequence[NotebookInit], user: User):
|
|
31
|
+
"""Initialize the notebook collection.
|
|
32
|
+
|
|
33
|
+
:param notebooks: A sequence of :class:`~labapi.util.types.NotebookInit` objects
|
|
34
|
+
containing initial data for the notebooks.
|
|
35
|
+
:param user: The authenticated :class:`~labapi.user.User` associated with these notebooks.
|
|
36
|
+
"""
|
|
37
|
+
super().__init__()
|
|
38
|
+
self._user = user
|
|
39
|
+
self._notebooks = [Notebook(n, user, self) for n in notebooks]
|
|
40
|
+
self._notebooks_by_id = {n.id: n for n in self._notebooks}
|
|
41
|
+
|
|
42
|
+
@overload
|
|
43
|
+
def __getitem__(self, key: str) -> Notebook: ...
|
|
44
|
+
|
|
45
|
+
@overload
|
|
46
|
+
def __getitem__(self, key: slice[Literal[Index.Id], str, None]) -> Notebook: ...
|
|
47
|
+
|
|
48
|
+
@overload
|
|
49
|
+
def __getitem__(
|
|
50
|
+
self, key: slice[Literal[Index.Name], str, None]
|
|
51
|
+
) -> list[Notebook]: ...
|
|
52
|
+
|
|
53
|
+
@override
|
|
54
|
+
def __getitem__(self, key: IdOrNameIndex) -> Notebook | list[Notebook]:
|
|
55
|
+
"""Look up notebooks by name or indexed selector.
|
|
56
|
+
|
|
57
|
+
- If `key` is a string, it attempts to find a single notebook with that name.
|
|
58
|
+
- If `key` is a slice with start of :attr:`~labapi.util.Index.Id`
|
|
59
|
+
(e.g., ``Index.Id:"some_id"``),
|
|
60
|
+
it returns the notebook with the matching ID.
|
|
61
|
+
- If `key` is a slice with start of :attr:`~labapi.util.Index.Name`
|
|
62
|
+
(e.g., ``Index.Name:"some_name"``),
|
|
63
|
+
it returns a list of all notebooks with the matching name (as names are not unique).
|
|
64
|
+
|
|
65
|
+
:param key: The index to use for accessing notebooks. Can be a string (for name lookup),
|
|
66
|
+
or a slice with :attr:`~labapi.util.Index.Id` or
|
|
67
|
+
:attr:`~labapi.util.Index.Name`.
|
|
68
|
+
:returns: A single :class:`~labapi.tree.notebook.Notebook` object or a list of them.
|
|
69
|
+
:raises KeyError: If a single notebook is requested by ID or unique name and not found.
|
|
70
|
+
"""
|
|
71
|
+
match key:
|
|
72
|
+
case slice(start=Index.Id, stop=val):
|
|
73
|
+
return self._notebooks_by_id[val]
|
|
74
|
+
case slice(start=Index.Name, stop=val):
|
|
75
|
+
return [node for node in self._notebooks if node.name == val]
|
|
76
|
+
case str():
|
|
77
|
+
for node in self._notebooks:
|
|
78
|
+
if node.name == key:
|
|
79
|
+
return node
|
|
80
|
+
raise KeyError(f'Notebook with name "{key}" not found')
|
|
81
|
+
case _:
|
|
82
|
+
raise TypeError(
|
|
83
|
+
"Invalid key type. Use `str`, `Index.Id:<id>`, or `Index.Name:<name>`."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
@override
|
|
87
|
+
def __iter__(self) -> Iterator[str]:
|
|
88
|
+
"""Iterate over notebook names in collection order."""
|
|
89
|
+
return iter([c.name for c in self._notebooks])
|
|
90
|
+
|
|
91
|
+
def __reversed__(self) -> Iterator[str]:
|
|
92
|
+
"""Iterate over notebook names in reverse collection order."""
|
|
93
|
+
return reversed([c.name for c in self._notebooks])
|
|
94
|
+
|
|
95
|
+
@override
|
|
96
|
+
def __len__(self) -> int:
|
|
97
|
+
"""Return the number of notebooks in this collection."""
|
|
98
|
+
return len(self._notebooks)
|
|
99
|
+
|
|
100
|
+
@override
|
|
101
|
+
def keys(self) -> KeysView[str]:
|
|
102
|
+
"""Return a mapping-compatible view of notebook names.
|
|
103
|
+
|
|
104
|
+
:returns: A keys view of notebook names.
|
|
105
|
+
"""
|
|
106
|
+
return KeysView({n.name: n for n in self._notebooks})
|
|
107
|
+
|
|
108
|
+
@override
|
|
109
|
+
def items(self) -> ItemsView[str, Notebook]:
|
|
110
|
+
"""Return a mapping-compatible view of ``(name, notebook)`` pairs.
|
|
111
|
+
|
|
112
|
+
:returns: An items view of ``(name, notebook)`` pairs.
|
|
113
|
+
"""
|
|
114
|
+
return ItemsView({n.name: n for n in self._notebooks})
|
|
115
|
+
|
|
116
|
+
@override
|
|
117
|
+
def values(self) -> ValuesView[Notebook]:
|
|
118
|
+
"""Return a mapping-compatible view of notebook objects.
|
|
119
|
+
|
|
120
|
+
:returns: A values view of notebook objects.
|
|
121
|
+
"""
|
|
122
|
+
return ValuesView({n.name: n for n in self._notebooks})
|
|
123
|
+
|
|
124
|
+
def all_keys(self) -> Sequence[str]:
|
|
125
|
+
"""Return notebook names in collection order, preserving duplicates."""
|
|
126
|
+
return [n.name for n in self._notebooks]
|
|
127
|
+
|
|
128
|
+
def all_items(self) -> Sequence[tuple[str, Notebook]]:
|
|
129
|
+
"""Return ``(name, notebook)`` pairs in collection order, preserving duplicates."""
|
|
130
|
+
return [(n.name, n) for n in self._notebooks]
|
|
131
|
+
|
|
132
|
+
def all_values(self) -> Sequence[Notebook]:
|
|
133
|
+
"""Return notebook objects in collection order, preserving duplicates."""
|
|
134
|
+
return list(self._notebooks)
|
|
135
|
+
|
|
136
|
+
def create_notebook(self, name: str) -> Notebook:
|
|
137
|
+
"""Create a new notebook in LabArchives.
|
|
138
|
+
|
|
139
|
+
:param name: The name of the new notebook.
|
|
140
|
+
:returns: The newly created :class:`~labapi.tree.notebook.Notebook` object.
|
|
141
|
+
:raises RuntimeError: If the underlying client session has been closed.
|
|
142
|
+
:raises AuthenticationError: If LabArchives rejects the request due to
|
|
143
|
+
invalid or expired credentials.
|
|
144
|
+
:raises ApiError: If LabArchives returns a non-success response, or if
|
|
145
|
+
the API returns a notebook ID that already exists in
|
|
146
|
+
the local collection.
|
|
147
|
+
"""
|
|
148
|
+
nbid = extract_etree(
|
|
149
|
+
self._user.api_get(
|
|
150
|
+
"notebooks/create_notebook", name=name, initial_folders="Empty"
|
|
151
|
+
),
|
|
152
|
+
{"nbid": str},
|
|
153
|
+
)["nbid"]
|
|
154
|
+
|
|
155
|
+
if nbid in self._notebooks_by_id:
|
|
156
|
+
raise ApiError(f"API returned an existing notebook ID: {nbid}")
|
|
157
|
+
|
|
158
|
+
new_notebook = Notebook(NotebookInit(nbid, name, False), self._user, self)
|
|
159
|
+
|
|
160
|
+
self._notebooks.append(new_notebook)
|
|
161
|
+
self._notebooks_by_id[nbid] = new_notebook
|
|
162
|
+
|
|
163
|
+
return new_notebook
|
labapi/tree/directory.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Notebook Directory Module.
|
|
2
|
+
|
|
3
|
+
This module defines the :class:`~labapi.tree.directory.NotebookDirectory` class,
|
|
4
|
+
representing a directory (folder) within a LabArchives notebook. It extends
|
|
5
|
+
both :class:`~labapi.tree.mixins.AbstractTreeContainer` and
|
|
6
|
+
:class:`~labapi.tree.mixins.AbstractTreeNode` to allow it to contain other
|
|
7
|
+
nodes and be managed as a node itself.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import override
|
|
13
|
+
|
|
14
|
+
from labapi.util import InsertBehavior
|
|
15
|
+
|
|
16
|
+
from .mixins import AbstractTreeContainer, AbstractTreeNode
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class NotebookDirectory(AbstractTreeContainer, AbstractTreeNode):
|
|
20
|
+
"""Represents a directory (folder) within a LabArchives notebook.
|
|
21
|
+
|
|
22
|
+
A `NotebookDirectory` can contain other directories and pages, forming
|
|
23
|
+
a hierarchical structure. It inherits functionalities for both being a
|
|
24
|
+
container and being a movable/modifiable node within the tree.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
@override
|
|
28
|
+
def copy_to(self, destination: AbstractTreeContainer) -> NotebookDirectory:
|
|
29
|
+
"""Copy this directory and its contents into ``destination``.
|
|
30
|
+
|
|
31
|
+
This operation recursively copies all child directories and pages.
|
|
32
|
+
|
|
33
|
+
:param destination: The target container to copy the directory to.
|
|
34
|
+
:returns: A new instance of the copied directory in the destination.
|
|
35
|
+
"""
|
|
36
|
+
if self.is_parent_of(destination) or self is destination:
|
|
37
|
+
raise ValueError(
|
|
38
|
+
"Cannot copy a directory into itself or one of its descendants"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
new_dir = destination.create(
|
|
42
|
+
NotebookDirectory, self.name, if_exists=InsertBehavior.Ignore
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
for child in self.children:
|
|
46
|
+
child.copy_to(new_dir)
|
|
47
|
+
|
|
48
|
+
return new_dir
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
@override
|
|
52
|
+
def id(self) -> str:
|
|
53
|
+
"""Return the directory identifier.
|
|
54
|
+
|
|
55
|
+
:returns: The directory's ID.
|
|
56
|
+
"""
|
|
57
|
+
return super().id
|