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/util/extract.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""XML Extraction Utilities Module.
|
|
2
|
+
|
|
3
|
+
This module provides utility functions for extracting data from `lxml.etree.Element`
|
|
4
|
+
objects, including flattening dictionaries for easier processing, converting
|
|
5
|
+
strings to booleans, and a general-purpose XML extraction function.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import warnings
|
|
11
|
+
from collections.abc import Callable, Mapping
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
from labapi.exceptions import ExtractionError
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from lxml.etree import Element
|
|
18
|
+
|
|
19
|
+
type EtreeExtractorDict = Mapping[str, "EtreeExtractorDict | Callable[[Any], Any]"]
|
|
20
|
+
"""
|
|
21
|
+
Type alias for a dictionary used to define the structure and extraction
|
|
22
|
+
logic for `lxml.etree.Element` objects.
|
|
23
|
+
|
|
24
|
+
It can be nested, where keys represent XML element tags and values are either
|
|
25
|
+
another `EtreeExtractorDict` for nested structures or a `Callable` to process
|
|
26
|
+
the text content of the element.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _flatten_dict(
|
|
31
|
+
val: EtreeExtractorDict, prefix: str = ""
|
|
32
|
+
) -> dict[str, Callable[[Any], Any]]:
|
|
33
|
+
"""Recursively flattens a nested dictionary of `EtreeExtractorDict` into a single-level dictionary.
|
|
34
|
+
|
|
35
|
+
The keys in the flattened dictionary represent the full path to the callable
|
|
36
|
+
extractor, separated by '/'.
|
|
37
|
+
|
|
38
|
+
:param val: The nested dictionary to flatten.
|
|
39
|
+
:param prefix: The current prefix for keys during recursion. Defaults to an empty string.
|
|
40
|
+
:returns: A flattened dictionary where keys are paths and values are callable extractors.
|
|
41
|
+
:raises ValueError: If an empty string is used as a key in the input dictionary.
|
|
42
|
+
"""
|
|
43
|
+
items: dict[str, Callable[[Any], Any]] = {}
|
|
44
|
+
|
|
45
|
+
for _key, value in val.items():
|
|
46
|
+
if len(_key) == 0:
|
|
47
|
+
raise ValueError("Key cannot be empty string")
|
|
48
|
+
|
|
49
|
+
key = f"{prefix}/{_key}"
|
|
50
|
+
|
|
51
|
+
if callable(value):
|
|
52
|
+
items[key] = value
|
|
53
|
+
else:
|
|
54
|
+
items.update(_flatten_dict(value, key))
|
|
55
|
+
|
|
56
|
+
return items
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def to_bool(s: str) -> bool:
|
|
60
|
+
"""Convert a string representation to a boolean value.
|
|
61
|
+
|
|
62
|
+
Recognizes "true" (case-insensitive) as True and "false" (case-insensitive) as False.
|
|
63
|
+
|
|
64
|
+
:param s: The string to convert.
|
|
65
|
+
:returns: The boolean representation of the string.
|
|
66
|
+
:raises ValueError: If the string cannot be converted to a boolean.
|
|
67
|
+
"""
|
|
68
|
+
match s.lower():
|
|
69
|
+
case "true":
|
|
70
|
+
return True
|
|
71
|
+
case "false":
|
|
72
|
+
return False
|
|
73
|
+
case _:
|
|
74
|
+
raise ValueError(f"Cannot convert '{s}' to bool")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def extract_etree(_etree: Element, schema: EtreeExtractorDict) -> dict[str, Any]:
|
|
78
|
+
"""Extract data from an ``lxml.etree.Element`` using a format dictionary.
|
|
79
|
+
|
|
80
|
+
This function navigates the XML tree using paths defined in the `schema` dictionary
|
|
81
|
+
and applies callable extractors to the text content of the found elements.
|
|
82
|
+
|
|
83
|
+
:param _etree: The `lxml.etree.Element` from which to extract data.
|
|
84
|
+
:param schema: A dictionary defining the structure and extraction logic.
|
|
85
|
+
Keys are XML element tags (or paths), and values are either
|
|
86
|
+
nested `EtreeExtractorDict` or callable functions to process the text.
|
|
87
|
+
:returns: A dictionary containing the extracted and processed data.
|
|
88
|
+
:raises ExtractionError: If an element specified in the format is not found in the etree,
|
|
89
|
+
or if a callable extractor fails to process a value.
|
|
90
|
+
"""
|
|
91
|
+
flat = _flatten_dict(schema)
|
|
92
|
+
|
|
93
|
+
items: dict[str, Any] = {}
|
|
94
|
+
etree_path = _etree.getroottree().getpath(_etree)
|
|
95
|
+
|
|
96
|
+
for key, mapper in flat.items():
|
|
97
|
+
message_path = f"./{key}"
|
|
98
|
+
value = _etree.findtext(f"./{key}")
|
|
99
|
+
|
|
100
|
+
if (
|
|
101
|
+
value is None
|
|
102
|
+
): # XXX should we collate errors and return at end with the dict or?
|
|
103
|
+
raise ExtractionError(
|
|
104
|
+
f"Could not find value for './{key}' while parsing element at {etree_path}"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
leaf = key.split("/")[-1]
|
|
108
|
+
|
|
109
|
+
if leaf in items:
|
|
110
|
+
warnings.warn(
|
|
111
|
+
f"Duplicate extractor leaf '{leaf}' encountered at './{key}'; "
|
|
112
|
+
"overwriting previous value",
|
|
113
|
+
stacklevel=2,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
items[leaf] = mapper(value)
|
|
118
|
+
except ValueError as err:
|
|
119
|
+
raise ExtractionError(
|
|
120
|
+
f"Could not map value {value!r} with {mapper.__name__} for "
|
|
121
|
+
f"{message_path!r} while parsing element at {etree_path}"
|
|
122
|
+
) from err
|
|
123
|
+
|
|
124
|
+
return items
|
labapi/util/path.py
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""Path utilities for navigating and creating LabArchives tree nodes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator, Sequence
|
|
6
|
+
from typing import TYPE_CHECKING, overload, override
|
|
7
|
+
|
|
8
|
+
from labapi.exceptions import PathError
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from labapi.tree.mixins import AbstractBaseTreeNode
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NotebookPath(Sequence[str]):
|
|
15
|
+
"""A structured path referencing a location in the notebook tree.
|
|
16
|
+
|
|
17
|
+
Behaves like a sequence of path segments (strings) and supports Unix-style
|
|
18
|
+
path semantics including absolute/relative paths and ``..`` parent navigation.
|
|
19
|
+
|
|
20
|
+
Paths can be constructed from a tree node, another ``NotebookPath``, or raw
|
|
21
|
+
slash-separated strings. Segments are normalised on construction: empty
|
|
22
|
+
segments and ``.`` are discarded, and ``..`` collapses the preceding segment
|
|
23
|
+
(or is kept literally when at the root of a relative path).
|
|
24
|
+
|
|
25
|
+
Examples::
|
|
26
|
+
|
|
27
|
+
# From a tree node (always absolute)
|
|
28
|
+
path = NotebookPath(folder) # e.g. /Experiments/2024
|
|
29
|
+
|
|
30
|
+
# From a string
|
|
31
|
+
path = NotebookPath("/Experiments/2024") # absolute
|
|
32
|
+
path = NotebookPath("2024/Results") # relative
|
|
33
|
+
|
|
34
|
+
# Combine with /
|
|
35
|
+
path = NotebookPath(notebook) / "Experiments" / "2024"
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
part: NotebookPath | AbstractBaseTreeNode | str,
|
|
41
|
+
*parts: str,
|
|
42
|
+
parent: NotebookPath | AbstractBaseTreeNode | None = None,
|
|
43
|
+
):
|
|
44
|
+
"""Construct a ``NotebookPath``.
|
|
45
|
+
|
|
46
|
+
The first argument ``part`` sets the base of the path; any additional
|
|
47
|
+
positional ``parts`` are appended as extra segments.
|
|
48
|
+
|
|
49
|
+
:param part: The base of the path. Pass a tree node to create an
|
|
50
|
+
absolute path rooted at that node's location, a ``NotebookPath``
|
|
51
|
+
to extend it, or a slash-separated string (absolute strings start
|
|
52
|
+
with ``/``; others are relative).
|
|
53
|
+
:param parts: Additional slash-separated path segments appended after
|
|
54
|
+
``part``. Segments are split on ``/`` and normalised.
|
|
55
|
+
:param parent: An absolute path (or node) that anchors a relative
|
|
56
|
+
string path for later resolution. Must be absolute.
|
|
57
|
+
:raises PathError: If ``parent`` is not absolute.
|
|
58
|
+
"""
|
|
59
|
+
if parent is not None:
|
|
60
|
+
self._parent = NotebookPath(parent)
|
|
61
|
+
if not self._parent.is_absolute():
|
|
62
|
+
raise PathError(
|
|
63
|
+
"Parent path must be absolute",
|
|
64
|
+
path=str(part),
|
|
65
|
+
parent=str(self._parent),
|
|
66
|
+
)
|
|
67
|
+
else:
|
|
68
|
+
self._parent = None
|
|
69
|
+
|
|
70
|
+
if isinstance(part, NotebookPath):
|
|
71
|
+
self._parts: Sequence[str] = NotebookPath._combine(
|
|
72
|
+
part._parts, parts, part._absolute
|
|
73
|
+
)
|
|
74
|
+
self._absolute: bool = part._absolute
|
|
75
|
+
self._parent = part._parent
|
|
76
|
+
elif isinstance(part, str):
|
|
77
|
+
is_abs = NotebookPath._is_absolute_seq(part) and self._parent is None
|
|
78
|
+
self._parts: Sequence[str] = NotebookPath._combine((part,), parts, is_abs)
|
|
79
|
+
self._absolute = is_abs
|
|
80
|
+
else:
|
|
81
|
+
self._parts: Sequence[str] = NotebookPath._combine(
|
|
82
|
+
NotebookPath._of_node(part), parts, True
|
|
83
|
+
)
|
|
84
|
+
self._absolute = True
|
|
85
|
+
|
|
86
|
+
def __truediv__(self, other: str | NotebookPath) -> NotebookPath:
|
|
87
|
+
"""Append a segment or another path using the ``/`` operator.
|
|
88
|
+
|
|
89
|
+
When ``other`` is a string it is appended as a new segment. When
|
|
90
|
+
``other`` is a relative ``NotebookPath`` it is resolved against
|
|
91
|
+
``self``; when it is absolute it is returned as-is.
|
|
92
|
+
|
|
93
|
+
:param other: A path segment string or another ``NotebookPath``.
|
|
94
|
+
:returns: A new ``NotebookPath`` with ``other`` appended or resolved.
|
|
95
|
+
"""
|
|
96
|
+
if isinstance(other, str):
|
|
97
|
+
return NotebookPath(self, other)
|
|
98
|
+
return other.resolve(self)
|
|
99
|
+
|
|
100
|
+
def to_string(self) -> str:
|
|
101
|
+
"""Return the path as a slash-separated string.
|
|
102
|
+
|
|
103
|
+
Absolute paths are prefixed with ``/``; relative paths are not.
|
|
104
|
+
|
|
105
|
+
:returns: The string representation of this path (e.g.
|
|
106
|
+
``"/Experiments/2024"`` or ``"2024/Results"``).
|
|
107
|
+
"""
|
|
108
|
+
if self._absolute:
|
|
109
|
+
return f"/{'/'.join(self._parts)}"
|
|
110
|
+
|
|
111
|
+
return "/".join(self._parts)
|
|
112
|
+
|
|
113
|
+
def is_absolute(self) -> bool:
|
|
114
|
+
"""Return whether this path is absolute.
|
|
115
|
+
|
|
116
|
+
An absolute path is rooted at the notebook level and begins with
|
|
117
|
+
``/`` in its string form.
|
|
118
|
+
|
|
119
|
+
:returns: ``True`` if the path is absolute, ``False`` if relative.
|
|
120
|
+
"""
|
|
121
|
+
return self._absolute
|
|
122
|
+
|
|
123
|
+
def resolve(
|
|
124
|
+
self, parent: NotebookPath | None = None, recurse: bool = False
|
|
125
|
+
) -> NotebookPath:
|
|
126
|
+
"""Return an absolute version of this path.
|
|
127
|
+
|
|
128
|
+
If the path is already absolute it is returned unchanged. Otherwise
|
|
129
|
+
the path is resolved against ``parent`` (if given) or against the
|
|
130
|
+
``parent`` anchor stored at construction time.
|
|
131
|
+
|
|
132
|
+
:param parent: An absolute path to resolve against. Ignored when the
|
|
133
|
+
path is already absolute or has a stored parent anchor.
|
|
134
|
+
:param recurse: If ``True``, ``parent`` itself is resolved before use.
|
|
135
|
+
:returns: A new absolute ``NotebookPath``.
|
|
136
|
+
:raises PathError: If the path is relative and no parent is available
|
|
137
|
+
to resolve against.
|
|
138
|
+
"""
|
|
139
|
+
if self.is_absolute():
|
|
140
|
+
return self
|
|
141
|
+
if self._parent is None:
|
|
142
|
+
if parent is not None:
|
|
143
|
+
return NotebookPath(
|
|
144
|
+
parent.resolve() if recurse else parent, *self._parts
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
raise PathError(
|
|
148
|
+
"Cannot resolve relative path without an absolute parent",
|
|
149
|
+
path=str(self),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
return NotebookPath(self._parent, *self._parts)
|
|
153
|
+
|
|
154
|
+
def startswith(self, other: NotebookPath) -> bool:
|
|
155
|
+
"""Return whether this path starts with another path's segments.
|
|
156
|
+
|
|
157
|
+
Compares raw segments without resolving either path.
|
|
158
|
+
|
|
159
|
+
:param other: The prefix path to test against.
|
|
160
|
+
:returns: ``True`` if the leading segments of this path equal all
|
|
161
|
+
segments of ``other``.
|
|
162
|
+
"""
|
|
163
|
+
if len(self) < len(other):
|
|
164
|
+
return False
|
|
165
|
+
return self[: len(other)] == other[: len(other)]
|
|
166
|
+
|
|
167
|
+
def is_relative_to(self, other: NotebookPath | AbstractBaseTreeNode) -> bool:
|
|
168
|
+
"""Return whether this path is located inside ``other``.
|
|
169
|
+
|
|
170
|
+
Unanchored relative paths are considered to be relative to any
|
|
171
|
+
absolute path.
|
|
172
|
+
|
|
173
|
+
:param other: The candidate ancestor path or tree node.
|
|
174
|
+
:returns: ``True`` if this path is equal to or below ``other``.
|
|
175
|
+
"""
|
|
176
|
+
if not isinstance(other, NotebookPath):
|
|
177
|
+
other = NotebookPath(other)
|
|
178
|
+
|
|
179
|
+
if not other._absolute and other._parent is None:
|
|
180
|
+
if not self._absolute and self._parent is None:
|
|
181
|
+
return self.startswith(other)
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
if not self._absolute and self._parent is None:
|
|
185
|
+
return True
|
|
186
|
+
|
|
187
|
+
return self.resolve().startswith(other.resolve())
|
|
188
|
+
|
|
189
|
+
def relative_to(self, other: NotebookPath | AbstractBaseTreeNode) -> NotebookPath:
|
|
190
|
+
"""Return this path made relative to ``other``.
|
|
191
|
+
|
|
192
|
+
The result is a new relative ``NotebookPath`` whose ``parent`` anchor
|
|
193
|
+
is set to the resolved form of ``other``, so it can be resolved back
|
|
194
|
+
to an absolute path later.
|
|
195
|
+
|
|
196
|
+
:param other: The ancestor path or tree node to relativise against.
|
|
197
|
+
:returns: A relative ``NotebookPath`` from ``other`` to this path.
|
|
198
|
+
:raises PathError: If this path is not located inside ``other``.
|
|
199
|
+
"""
|
|
200
|
+
if not isinstance(other, NotebookPath):
|
|
201
|
+
other = NotebookPath(other)
|
|
202
|
+
|
|
203
|
+
if not self.is_relative_to(other):
|
|
204
|
+
raise PathError(
|
|
205
|
+
f'Cannot compute relative path: "{self}" is outside of "{other}"',
|
|
206
|
+
path=str(self),
|
|
207
|
+
parent=str(other),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
if not other._absolute and other._parent is None:
|
|
211
|
+
return NotebookPath(*self[len(other) :])
|
|
212
|
+
|
|
213
|
+
p_origin = other.resolve()
|
|
214
|
+
p_endpoint = self.resolve(other)
|
|
215
|
+
|
|
216
|
+
remaining = list(p_endpoint[len(p_origin) :])
|
|
217
|
+
return (
|
|
218
|
+
NotebookPath(*remaining, parent=p_origin)
|
|
219
|
+
if remaining
|
|
220
|
+
else NotebookPath("", parent=p_origin)
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
@property
|
|
224
|
+
def name(self) -> str:
|
|
225
|
+
"""The final segment of the path.
|
|
226
|
+
|
|
227
|
+
Equivalent to the node's display name when the path was built from a
|
|
228
|
+
tree node. Returns ``"."`` for an empty path.
|
|
229
|
+
|
|
230
|
+
:returns: The last path segment, or ``"."`` if the path is empty.
|
|
231
|
+
"""
|
|
232
|
+
if len(self._parts):
|
|
233
|
+
return self._parts[-1]
|
|
234
|
+
return "."
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def parts(self) -> Sequence[str]:
|
|
238
|
+
"""All path segments except the last one.
|
|
239
|
+
|
|
240
|
+
Analogous to the parent directory in a file path.
|
|
241
|
+
|
|
242
|
+
:returns: A sequence of segment strings, empty if the path has only
|
|
243
|
+
one segment.
|
|
244
|
+
"""
|
|
245
|
+
return self._parts[:-1]
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def parent(self) -> NotebookPath:
|
|
249
|
+
"""The parent path (all segments except the last).
|
|
250
|
+
|
|
251
|
+
Resolves the path first, then appends ``..`` to obtain the parent.
|
|
252
|
+
|
|
253
|
+
:returns: An absolute ``NotebookPath`` pointing to the parent location.
|
|
254
|
+
"""
|
|
255
|
+
return self.resolve() / ".."
|
|
256
|
+
|
|
257
|
+
@override
|
|
258
|
+
def __iter__(self) -> Iterator[str]:
|
|
259
|
+
"""Iterate over the path segments in order."""
|
|
260
|
+
return iter(self._parts)
|
|
261
|
+
|
|
262
|
+
@override
|
|
263
|
+
def __len__(self) -> int:
|
|
264
|
+
"""Return the number of segments in the path."""
|
|
265
|
+
return len(self._parts)
|
|
266
|
+
|
|
267
|
+
@overload
|
|
268
|
+
def __getitem__(self, idx: int) -> str: ...
|
|
269
|
+
|
|
270
|
+
@overload
|
|
271
|
+
def __getitem__(self, idx: slice) -> Sequence[str]: ...
|
|
272
|
+
|
|
273
|
+
@override
|
|
274
|
+
def __getitem__(self, idx: int | slice) -> str | Sequence[str]:
|
|
275
|
+
"""Return the segment at ``idx``, or a sub-sequence for a slice."""
|
|
276
|
+
return self._parts[idx]
|
|
277
|
+
|
|
278
|
+
@override
|
|
279
|
+
def __hash__(self) -> int:
|
|
280
|
+
"""Hash based on absoluteness, segments, and parent anchor."""
|
|
281
|
+
return hash((self._absolute, tuple(self._parts)))
|
|
282
|
+
|
|
283
|
+
@override
|
|
284
|
+
def __eq__(self, other: object) -> bool:
|
|
285
|
+
"""Return ``True`` if ``other`` has the same path semantics.
|
|
286
|
+
|
|
287
|
+
Equality compares absoluteness, normalized segments, and any stored
|
|
288
|
+
parent anchor.
|
|
289
|
+
"""
|
|
290
|
+
if self is other:
|
|
291
|
+
return True
|
|
292
|
+
if not isinstance(other, NotebookPath):
|
|
293
|
+
return False
|
|
294
|
+
return (
|
|
295
|
+
self._absolute == other._absolute
|
|
296
|
+
and self._parts == other._parts
|
|
297
|
+
and self._parent == other._parent
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
@override
|
|
301
|
+
def __repr__(self) -> str:
|
|
302
|
+
"""Return a developer-readable representation, e.g. ``NotebookPath('/a/b')``."""
|
|
303
|
+
return f"{type(self).__name__}({self.to_string()!r})"
|
|
304
|
+
|
|
305
|
+
@override
|
|
306
|
+
def __str__(self) -> str:
|
|
307
|
+
"""Return the slash-separated string form of the path."""
|
|
308
|
+
return self.to_string()
|
|
309
|
+
|
|
310
|
+
@staticmethod
|
|
311
|
+
def _is_absolute_seq(a: Sequence[str]) -> bool:
|
|
312
|
+
"""Return ``True`` if the first element of ``a`` starts with ``/``."""
|
|
313
|
+
return len(a) > 0 and a[0].startswith("/")
|
|
314
|
+
|
|
315
|
+
@staticmethod
|
|
316
|
+
def _combine(a: Sequence[str], b: Sequence[str], from_root: bool) -> Sequence[str]:
|
|
317
|
+
"""Merge two sequences of raw path segments into a normalised list.
|
|
318
|
+
|
|
319
|
+
Splits each element on ``/``, strips whitespace, drops empty segments
|
|
320
|
+
and ``.``, and resolves ``..`` (popping the previous segment, or
|
|
321
|
+
keeping ``..`` literally at the start of a relative path).
|
|
322
|
+
|
|
323
|
+
:param a: First sequence of raw segments (e.g. from an existing path).
|
|
324
|
+
:param b: Second sequence of raw segments to append.
|
|
325
|
+
:param from_root: Whether the combined path is rooted (absolute). When
|
|
326
|
+
``True``, a leading ``..`` is silently dropped instead of kept.
|
|
327
|
+
:returns: A flat list of normalised, non-empty segment strings.
|
|
328
|
+
"""
|
|
329
|
+
canonical: list[str] = []
|
|
330
|
+
|
|
331
|
+
# NOTE no support for escapes
|
|
332
|
+
for segment in [k.strip() for part in [*a, *b] for k in part.split("/")]:
|
|
333
|
+
match segment:
|
|
334
|
+
case "." | "":
|
|
335
|
+
continue
|
|
336
|
+
case "..":
|
|
337
|
+
if len(canonical) == 0:
|
|
338
|
+
if not from_root:
|
|
339
|
+
canonical.append("..")
|
|
340
|
+
elif canonical[-1] == "..":
|
|
341
|
+
canonical.append("..")
|
|
342
|
+
else:
|
|
343
|
+
canonical.pop()
|
|
344
|
+
case _:
|
|
345
|
+
canonical.append(segment)
|
|
346
|
+
return canonical
|
|
347
|
+
|
|
348
|
+
@staticmethod
|
|
349
|
+
def _of_node(a: AbstractBaseTreeNode) -> Sequence[str]:
|
|
350
|
+
"""Return the ordered list of ancestor names from the notebook root to ``a``.
|
|
351
|
+
|
|
352
|
+
Walks ``a.parent`` until the root is reached, building the segment list
|
|
353
|
+
from the bottom up.
|
|
354
|
+
|
|
355
|
+
:param a: The tree node to derive a path for.
|
|
356
|
+
:returns: A sequence of name strings representing the path from root to
|
|
357
|
+
``a``, not including the root notebook itself.
|
|
358
|
+
"""
|
|
359
|
+
stack: list[str] = []
|
|
360
|
+
|
|
361
|
+
curr = a
|
|
362
|
+
|
|
363
|
+
while curr is not curr.root:
|
|
364
|
+
stack.append(curr.name)
|
|
365
|
+
curr = curr.parent
|
|
366
|
+
|
|
367
|
+
return stack[::-1]
|
labapi/util/types.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Types Module.
|
|
2
|
+
|
|
3
|
+
This module defines enumeration classes and data types used throughout
|
|
4
|
+
the LabArchives API client.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class InsertBehavior(Enum):
|
|
14
|
+
"""Enumeration of behaviors when inserting a node that already exists."""
|
|
15
|
+
|
|
16
|
+
Replace = 0
|
|
17
|
+
"""Delete the existing node(s) and create a new one."""
|
|
18
|
+
Ignore = 1
|
|
19
|
+
"""Just create a new node anyways."""
|
|
20
|
+
Retain = 2
|
|
21
|
+
"""Keep the existing node and return it."""
|
|
22
|
+
Raise = 3
|
|
23
|
+
"""Raise :class:`~labapi.exceptions.NodeExistsError` if the node already exists."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Index(Enum):
|
|
27
|
+
"""Represents the available indexing methods for accessing items in a collection."""
|
|
28
|
+
|
|
29
|
+
Id = "id"
|
|
30
|
+
"""Index by the unique identifier of an item."""
|
|
31
|
+
Name = "name"
|
|
32
|
+
"""Index by the human-readable name of an item."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
type IdIndex = "slice[Literal[Index.Id], str, None]"
|
|
36
|
+
"""
|
|
37
|
+
Type alias for indexing by item ID.
|
|
38
|
+
|
|
39
|
+
Example: ``Index.Id:"some_id"``
|
|
40
|
+
"""
|
|
41
|
+
type NameIndex = "slice[Literal[Index.Name], str, None]"
|
|
42
|
+
"""
|
|
43
|
+
Type alias for indexing by item name.
|
|
44
|
+
|
|
45
|
+
Example: ``Index.Name:"some_name"``
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
type IdOrNameIndex = str | IdIndex | NameIndex
|
|
49
|
+
"""
|
|
50
|
+
Type alias representing a flexible index that can be either an item's ID (string),
|
|
51
|
+
or a slice using :attr:`Index.Id` or :attr:`Index.Name`.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class NotebookInit:
|
|
57
|
+
"""Represents the initial data required to set up a LabArchives notebook object.
|
|
58
|
+
|
|
59
|
+
This dataclass holds essential information such as the notebook's ID, and name.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
id: str
|
|
63
|
+
"""The unique identifier of the notebook."""
|
|
64
|
+
name: str
|
|
65
|
+
"""The name of the notebook."""
|
|
66
|
+
is_default: bool
|
|
67
|
+
"""A value indicating if this notebook is the user's default."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
type JsonData = (
|
|
71
|
+
Sequence["JsonData"] | Mapping[str, "JsonData"] | str | bool | int | float | None
|
|
72
|
+
)
|
|
73
|
+
"""
|
|
74
|
+
A recursive type alias representing any data structure that can be
|
|
75
|
+
serialized to or deserialized from JSON.
|
|
76
|
+
"""
|