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,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
+
@@ -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
@@ -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