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/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
+ """