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/tree/mixins.py ADDED
@@ -0,0 +1,852 @@
1
+ """Tree Mixins Module.
2
+
3
+ This module defines abstract base classes and mixins that form the hierarchical
4
+ structure of LabArchives notebooks, directories, and pages. These classes
5
+ provide common functionalities and properties for tree nodes and containers.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ import warnings
12
+ from abc import ABC, abstractmethod
13
+ from collections.abc import (
14
+ ItemsView,
15
+ Iterator,
16
+ KeysView,
17
+ Mapping,
18
+ MutableSequence,
19
+ Sequence,
20
+ ValuesView,
21
+ )
22
+ from datetime import datetime, timedelta
23
+ from typing import TYPE_CHECKING, Literal, Self, TypeVar, cast, overload, override
24
+
25
+ from labapi.exceptions import (
26
+ ExtractionError,
27
+ NodeExistsError,
28
+ TraversalError,
29
+ TreeChildParseError,
30
+ )
31
+ from labapi.util import (
32
+ IdIndex,
33
+ IdOrNameIndex,
34
+ Index,
35
+ InsertBehavior,
36
+ NameIndex,
37
+ NotebookPath,
38
+ extract_etree,
39
+ to_bool,
40
+ )
41
+
42
+ if TYPE_CHECKING:
43
+ from labapi.user import User
44
+
45
+ from .directory import NotebookDirectory
46
+ from .page import NotebookPage
47
+
48
+
49
+ class HasNameMixin:
50
+ """A mixin class that provides a `name` attribute for tree nodes.
51
+
52
+ Classes inheriting from this mixin are expected to have a `_name` instance
53
+ variable.
54
+ """
55
+
56
+ def __init__(self, name: str):
57
+ """Initialize the mixin with a node name.
58
+
59
+ :param name: The name of the tree node.
60
+ """
61
+ super().__init__()
62
+ self._name = name
63
+
64
+ @property
65
+ def name(self) -> str:
66
+ """Return the tree node name.
67
+
68
+ :returns: The name of the node.
69
+ """
70
+ return self._name
71
+
72
+
73
+ class AbstractBaseTreeNode(ABC, HasNameMixin):
74
+ """Abstract base class for any node within the LabArchives tree structure.
75
+
76
+ This class provides fundamental properties and methods common to all
77
+ tree nodes, such as ID, name, references to parent and root, and the
78
+ associated user.
79
+
80
+ :param tree_id: The unique identifier for this node within the LabArchives tree.
81
+ :param name: The display name of the node.
82
+ :param root: The root node of the tree (e.g., the Notebook).
83
+ :param parent: The parent node of this node in the tree.
84
+ :param user: The authenticated user associated with this node.
85
+ """
86
+
87
+ def __init__(
88
+ self,
89
+ tree_id: str,
90
+ name: str,
91
+ root: AbstractTreeContainer,
92
+ parent: AbstractTreeContainer,
93
+ user: User,
94
+ ):
95
+ """Initialize a tree node with IDs, hierarchy pointers, and user state."""
96
+ super().__init__(name)
97
+ self._root: AbstractTreeContainer = root
98
+ self._parent: AbstractTreeContainer = parent
99
+ self._tree_id: str = tree_id
100
+ self._user = user
101
+ self._has_path = False
102
+ self._path: NotebookPath
103
+
104
+ @property
105
+ def root(self) -> AbstractTreeContainer:
106
+ """Return the root node of the tree.
107
+
108
+ :returns: The root tree container.
109
+ """
110
+ return self._root
111
+
112
+ @property
113
+ def parent(self) -> AbstractTreeContainer:
114
+ """Return this node's parent container.
115
+
116
+ :returns: The parent tree container.
117
+ """
118
+ return self._parent
119
+
120
+ @property
121
+ def user(self) -> User:
122
+ """Return the authenticated user associated with this node.
123
+
124
+ :returns: The user object.
125
+ """
126
+ return self._user
127
+
128
+ @property
129
+ @abstractmethod
130
+ def id(self) -> str:
131
+ """Return the node identifier.
132
+
133
+ :returns: The node's ID.
134
+ """
135
+ return self.tree_id
136
+
137
+ @property
138
+ def tree_id(self) -> str:
139
+ """Return the node identifier within the LabArchives tree.
140
+
141
+ This is often the same as `id` but can be used to distinguish if needed.
142
+
143
+ :returns: The tree ID of the node.
144
+ """
145
+ return self._tree_id
146
+
147
+ @property
148
+ def path(self) -> NotebookPath:
149
+ """Return the cached absolute path for this node."""
150
+ if not self._has_path:
151
+ self._path = NotebookPath(self)
152
+ self._has_path = True
153
+
154
+ return self._path
155
+
156
+ def _invalidate_path(self) -> None:
157
+ """Clear cached paths for this node and any loaded descendants."""
158
+ self._has_path = False
159
+
160
+ if isinstance(self, AbstractTreeContainer):
161
+ for child in self._children:
162
+ child._invalidate_path()
163
+
164
+ @abstractmethod
165
+ def is_dir(self) -> bool:
166
+ """Return whether this node is a directory.
167
+
168
+ :returns: True if the node is a directory, False otherwise.
169
+ """
170
+ return False
171
+
172
+ @abstractmethod
173
+ def refresh(self) -> Self:
174
+ """Refresh this node's cached data from the LabArchives API.
175
+
176
+ This method updates the node's properties (such as name, ID, and children)
177
+ by fetching the latest data from the server. This is useful when the
178
+ node's state may have changed externally.
179
+ """
180
+ raise NotImplementedError()
181
+
182
+ def traverse(self, path: str | NotebookPath) -> AbstractBaseTreeNode:
183
+ """Traverse the notebook tree and return the node at ``path``.
184
+
185
+ String path segments should be separated by '/'. Each segment is treated
186
+ as a name to look up in the current container. Paths starting with '/'
187
+ are absolute (relative to the notebook root), while paths without a
188
+ leading '/' are relative to the current container.
189
+
190
+ Special path segments:
191
+ - '..' navigates to the parent container
192
+
193
+ .. note::
194
+ - When multiple children have the same name, this method returns the first match.
195
+
196
+ .. warning::
197
+ Nodes with names that are literally '..' cannot be accessed via
198
+ this method, as '..' is reserved for parent navigation.
199
+
200
+ :param path: The slash-separated path to the desired node (e.g., "My Folder/My Page" or "/Folder/Subfolder/Page").
201
+ :returns: The :class:`~labapi.tree.mixins.AbstractBaseTreeNode` found
202
+ at the specified path.
203
+ :raises TraversalError: If traversal cannot continue through a segment.
204
+ """
205
+ canonical = NotebookPath(path) if isinstance(path, str) else path
206
+ canonical = canonical.resolve(self.path)
207
+
208
+ curr = self.root
209
+
210
+ parsed_segments: list[str] = []
211
+
212
+ for segment in canonical:
213
+ parsed_segments.append(segment)
214
+ if segment == "..":
215
+ curr = curr.parent
216
+ elif isinstance(curr, AbstractTreeContainer):
217
+ try:
218
+ curr = curr[segment]
219
+ except KeyError as exc:
220
+ resolved_parent = (
221
+ "/"
222
+ if len(parsed_segments) == 1
223
+ else f"/{'/'.join(parsed_segments[:-1])}"
224
+ )
225
+ available_children = sorted(node.name for node in curr.children)
226
+ raise TraversalError(
227
+ (
228
+ f'Unable to traverse "{canonical}" at segment "{segment}": '
229
+ f'child "{segment}" not found in "{resolved_parent}"'
230
+ ),
231
+ path=str(canonical),
232
+ segment=segment,
233
+ parent=resolved_parent,
234
+ available_children=available_children,
235
+ ) from exc
236
+ else:
237
+ resolved_parent = (
238
+ "/"
239
+ if len(parsed_segments) == 1
240
+ else f"/{'/'.join(parsed_segments[:-1])}"
241
+ )
242
+ raise TraversalError(
243
+ (
244
+ f'Unable to traverse "{canonical}" at segment "{segment}": '
245
+ f'"{"/".join(parsed_segments)}" is not a directory'
246
+ ),
247
+ path=str(canonical),
248
+ segment=segment,
249
+ parent=resolved_parent,
250
+ )
251
+
252
+ return curr
253
+
254
+ def as_dir(self) -> AbstractTreeContainer:
255
+ """Return this node cast to :class:`~labapi.tree.mixins.AbstractTreeContainer`.
256
+
257
+ This method provides a convenient way to perform directory-specific
258
+ operations on a node after checking its type, with static type
259
+ checking support.
260
+
261
+ :returns: The node cast to an
262
+ :class:`~labapi.tree.mixins.AbstractTreeContainer`.
263
+ :raises TypeError: If the node is not a directory (i.e., `is_dir()` returns `False`).
264
+ """
265
+ if self.is_dir():
266
+ return cast(AbstractTreeContainer, self)
267
+
268
+ raise TypeError("Node is not a directory")
269
+
270
+ def as_page(self) -> NotebookPage:
271
+ """Return this node cast to :class:`~labapi.tree.page.NotebookPage`.
272
+
273
+ This method provides a convenient way to perform page-specific
274
+ operations on a node after checking its type, with static type
275
+ checking support.
276
+
277
+ :returns: The node cast to a
278
+ :class:`~labapi.tree.page.NotebookPage`.
279
+ :raises TypeError: If the node is not a page (i.e., `is_dir()` returns `True`).
280
+ """
281
+ if not self.is_dir():
282
+ from . import page
283
+
284
+ return cast(page.NotebookPage, self)
285
+
286
+ raise TypeError("Node is not a page")
287
+
288
+
289
+ class AbstractTreeNode(AbstractBaseTreeNode):
290
+ """Abstract base class for a non-container node within the LabArchives tree structure.
291
+
292
+ This class extends :class:`AbstractBaseTreeNode` with functionalities for
293
+ modifying the node's name, copying, moving, and deleting the node.
294
+ """
295
+
296
+ @HasNameMixin.name.setter
297
+ def name(self, value: str):
298
+ """Set the tree node name.
299
+
300
+ This operation updates the node's name in LabArchives via an API call.
301
+
302
+ :param value: The new name for the node.
303
+ """
304
+ self.user.api_get(
305
+ "tree_tools/update_node",
306
+ nbid=self.root.id,
307
+ tree_id=self.tree_id,
308
+ display_text=value,
309
+ )
310
+
311
+ self._name = value
312
+ self._invalidate_path()
313
+
314
+ @abstractmethod
315
+ def copy_to(self, destination: AbstractTreeContainer) -> Self:
316
+ """Copy this node into ``destination``.
317
+
318
+ :param destination: The target container to copy the node to.
319
+ :returns: A new instance of the copied node in the destination.
320
+ """
321
+
322
+ def move_to(self, destination: AbstractTreeContainer) -> Self:
323
+ """Move this node into ``destination``.
324
+
325
+ This operation updates the node's parent in LabArchives via an API call
326
+ and updates the local tree structure.
327
+
328
+ :param destination: The target container to move the node to.
329
+ :returns: The instance of the moved node.
330
+ """
331
+ if destination is self:
332
+ raise ValueError("Cannot move a node to itself")
333
+
334
+ if isinstance(self, AbstractTreeContainer) and self.is_parent_of(destination):
335
+ raise ValueError("Cannot move a directory into one of its descendants")
336
+
337
+ if destination.root is not self.root:
338
+ raise ValueError("Cannot move a node across notebooks")
339
+
340
+ self._user.api_get(
341
+ "tree_tools/update_node",
342
+ nbid=self.root.id,
343
+ tree_id=self.tree_id,
344
+ parent_tree_id=destination.tree_id,
345
+ )
346
+ del self.parent._children[ # pyright: ignore[reportPrivateUsage]
347
+ self.parent.children.index(self)
348
+ ] # This removes current node from old parent in-place
349
+ self._parent = destination
350
+ self.parent._children.append(self) # pyright: ignore[reportPrivateUsage]
351
+ # This adds current node to new parent in-place
352
+
353
+ self._invalidate_path()
354
+ return self
355
+
356
+ def delete(self) -> Self:
357
+ """Move this node into the special ``API Deleted Items`` directory.
358
+
359
+ If the "API Deleted Items" directory does not exist, it will be created.
360
+ The node's name will be updated to reflect its deletion time.
361
+
362
+ :returns: The instance of the deleted node.
363
+ """
364
+ api_deleted_items = self.root.dir("API Deleted Items")
365
+
366
+ self.name = (
367
+ f"{self.name} - Deleted at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
368
+ )
369
+ self.move_to(api_deleted_items)
370
+
371
+ return self
372
+
373
+
374
+ T = TypeVar("T", bound=AbstractTreeNode)
375
+
376
+
377
+ class AbstractTreeContainer(
378
+ AbstractBaseTreeNode,
379
+ Mapping[IdOrNameIndex, AbstractBaseTreeNode | Sequence[AbstractBaseTreeNode]],
380
+ ):
381
+ """Abstract base class for a tree node that can contain other tree nodes (e.g., Notebooks, Directories).
382
+
383
+ This class extends :class:`AbstractBaseTreeNode` and implements `collections.abc.Mapping`
384
+ to allow access to its children by ID or name. It provides methods for managing
385
+ its children, such as creating new pages or directories.
386
+ """
387
+
388
+ def __init__(
389
+ self,
390
+ tree_id: str,
391
+ name: str,
392
+ root: AbstractTreeContainer,
393
+ parent: AbstractTreeContainer,
394
+ user: User,
395
+ ):
396
+ """Initialize a tree container node.
397
+
398
+ :param tree_id: The unique identifier for this container within the LabArchives tree.
399
+ :param name: The display name of the container.
400
+ :param root: The root node of the tree (e.g., the Notebook).
401
+ :param parent: The parent node of this container in the tree.
402
+ :param user: The authenticated user associated with this container.
403
+ """
404
+ super().__init__(tree_id, name, root, parent, user)
405
+ self._children: MutableSequence[AbstractTreeNode] = []
406
+ self._populated: bool = False
407
+
408
+ @property
409
+ def children(self) -> Sequence[AbstractTreeNode]:
410
+ """Return a snapshot of this container's direct children.
411
+
412
+ :returns: An immutable point-in-time sequence of
413
+ :class:`~labapi.tree.mixins.AbstractTreeNode` objects.
414
+ """
415
+ self._ensure_populated()
416
+ return tuple(self._children)
417
+
418
+ def _ensure_populated(self) -> None:
419
+ """Load this container's children from the API if needed.
420
+
421
+ If the children have not been loaded yet, it makes an API call to
422
+ retrieve the tree level and populates the `_children` list.
423
+ """
424
+ from . import directory, page
425
+
426
+ if not self._populated:
427
+ xml_tree = self.user.api_get(
428
+ "tree_tools/get_tree_level",
429
+ nbid=self.root.id,
430
+ parent_tree_id=self.tree_id,
431
+ )
432
+
433
+ nodes: list[AbstractTreeNode] = []
434
+
435
+ for subtree in xml_tree.iterfind(".//level-node"):
436
+ subtree_path = subtree.getroottree().getpath(subtree)
437
+ try:
438
+ node = extract_etree(
439
+ subtree,
440
+ {
441
+ "is-page": to_bool,
442
+ "tree-id": str,
443
+ "display-text": str,
444
+ },
445
+ )
446
+ except ExtractionError as err:
447
+ raise TreeChildParseError(
448
+ "Could not parse tree child at "
449
+ f"{subtree_path} for parent tree_id={self.tree_id!r}"
450
+ ) from err
451
+
452
+ if not node["display-text"].strip():
453
+ raise TreeChildParseError(
454
+ "Could not parse tree child at "
455
+ f"{subtree_path} for parent tree_id={self.tree_id!r}: "
456
+ "display-text cannot be empty"
457
+ )
458
+
459
+ args = (
460
+ node["tree-id"],
461
+ node["display-text"],
462
+ self.root,
463
+ self,
464
+ self._user,
465
+ )
466
+
467
+ if node["is-page"]:
468
+ nodes.append(page.NotebookPage(*args))
469
+ else:
470
+ nodes.append(directory.NotebookDirectory(*args))
471
+
472
+ self._children = nodes
473
+ self._populated = True
474
+
475
+ def __len__(self) -> int:
476
+ """Return the number of direct children in this container.
477
+
478
+ :returns: The count of direct child nodes.
479
+ """
480
+ self._ensure_populated()
481
+ return len(self.children)
482
+
483
+ def __iter__(self) -> Iterator[str]:
484
+ """Iterate over direct child names in container order.
485
+
486
+ :returns: An iterator yielding name strings.
487
+ """
488
+ return iter(node.name for node in self.children)
489
+
490
+ @override
491
+ def keys(self) -> KeysView[str]:
492
+ """Return a mapping-compatible view of child names."""
493
+ self._ensure_populated()
494
+ return KeysView({node.name: node for node in self.children})
495
+
496
+ @override
497
+ def items(self) -> ItemsView[str, AbstractBaseTreeNode]:
498
+ """Return a mapping-compatible view of ``(name, child)`` pairs."""
499
+ self._ensure_populated()
500
+ return ItemsView({node.name: node for node in self.children})
501
+
502
+ @override
503
+ def values(self) -> ValuesView[AbstractBaseTreeNode]:
504
+ """Return a mapping-compatible view of child nodes."""
505
+ self._ensure_populated()
506
+ return ValuesView({node.name: node for node in self.children})
507
+
508
+ def all_keys(self) -> Sequence[str]:
509
+ """Return child names in container order, preserving duplicates."""
510
+ self._ensure_populated()
511
+ return tuple([node.name for node in self.children])
512
+
513
+ def all_items(self) -> Sequence[tuple[str, AbstractBaseTreeNode]]:
514
+ """Return ``(name, child)`` pairs in container order, preserving duplicates."""
515
+ self._ensure_populated()
516
+ return tuple([(node.name, node) for node in self.children])
517
+
518
+ def all_values(self) -> Sequence[AbstractBaseTreeNode]:
519
+ """Return child nodes in container order, preserving duplicates."""
520
+ self._ensure_populated()
521
+ return tuple(self.children)
522
+
523
+ @overload
524
+ def __getitem__(self, key: str) -> AbstractBaseTreeNode: ...
525
+
526
+ @overload
527
+ def __getitem__(self, key: IdIndex) -> AbstractBaseTreeNode: ...
528
+
529
+ @overload
530
+ def __getitem__(self, key: NameIndex) -> Sequence[AbstractBaseTreeNode]: ...
531
+
532
+ def __getitem__(
533
+ self, key: IdOrNameIndex
534
+ ) -> AbstractBaseTreeNode | Sequence[AbstractBaseTreeNode]:
535
+ """Look up child nodes by name or indexed selector.
536
+
537
+ - If `key` is a string, it attempts to find a single child with that name.
538
+ - If `key` is a slice with start of :attr:`~labapi.util.Index.Id`
539
+ (e.g., ``Index.Id:"some_id"``),
540
+ it returns the child with the matching ID.
541
+ - If `key` is a slice with start of :attr:`~labapi.util.Index.Name`
542
+ (e.g., ``Index.Name:"some_name"``),
543
+ it returns a list of all children with the matching name (as names are not unique).
544
+
545
+ This method ensures the children are populated before attempting to access them.
546
+
547
+ :param key: The index to use for accessing children.
548
+ :returns: A single :class:`AbstractBaseTreeNode` or a sequence of :class:`AbstractBaseTreeNode`.
549
+ :raises KeyError: If a single node is requested by ID or unique name and not found.
550
+ """
551
+ self._ensure_populated()
552
+
553
+ match key:
554
+ case slice(start=Index.Id, stop=val):
555
+ for node in self.children:
556
+ if node.id == val:
557
+ return node
558
+ raise KeyError(f'Node with id "{val}" not found in "{self.path}"')
559
+ case slice(start=Index.Name, stop=val):
560
+ return [node for node in self.children if node.name == val]
561
+ case str():
562
+ for node in self.children:
563
+ if node.name == key:
564
+ return node
565
+ available_children = sorted(node.name for node in self.children)
566
+ raise KeyError(
567
+ f'Child "{key}" not found in "{self.path}" (available: {available_children})'
568
+ )
569
+ case _:
570
+ raise TypeError(
571
+ "Invalid key type. Use `str`, `Index.Id:<id>`, or `Index.Name:<name>`."
572
+ )
573
+
574
+ def is_parent_of(self, other: AbstractBaseTreeNode) -> bool:
575
+ """Return whether this container is a strict ancestor of ``other``.
576
+
577
+ This method returns ``True`` when ``other`` is a descendant of this
578
+ container at any depth (direct child or deeper). A node is not
579
+ considered a parent of itself.
580
+
581
+ Nodes from different notebook roots are always considered unrelated,
582
+ even if their relative paths happen to match.
583
+
584
+ :param other: The node to test as a potential descendant.
585
+ :returns: ``True`` if this container is an ancestor of ``other``,
586
+ otherwise ``False``.
587
+ """
588
+ curr = other
589
+
590
+ while curr is not curr.root:
591
+ curr = curr.parent
592
+ if curr is self:
593
+ return True
594
+
595
+ return False
596
+
597
+ def _enumerate_nodes(
598
+ self,
599
+ *,
600
+ depth: int = 1,
601
+ timeout: timedelta = timedelta(seconds=5),
602
+ _timeout: float | None = None,
603
+ _current_depth: int = 0,
604
+ ) -> Sequence[tuple[str, AbstractTreeNode]]:
605
+ """Enumerate descendant paths paired with the node they resolved from."""
606
+ current: MutableSequence[tuple[str, AbstractTreeNode]] = []
607
+
608
+ if _current_depth >= depth:
609
+ return current
610
+
611
+ if _timeout is None:
612
+ _timeout = time.monotonic() + timeout.total_seconds()
613
+
614
+ self._ensure_populated()
615
+ for child in self._children:
616
+ name = child.name
617
+
618
+ if time.monotonic() > _timeout:
619
+ warnings.warn(
620
+ "Tree enumeration timed out before traversal completed; "
621
+ "returned paths are partial and may be unsafe for sync/export workflows.",
622
+ RuntimeWarning,
623
+ stacklevel=2,
624
+ )
625
+ break
626
+
627
+ current.append((name, child))
628
+
629
+ try:
630
+ container = child.as_dir()
631
+ current.extend(
632
+ [
633
+ (f"{name}/{child_path}", descendant)
634
+ for child_path, descendant in container._enumerate_nodes(
635
+ _current_depth=_current_depth + 1,
636
+ depth=depth,
637
+ _timeout=_timeout,
638
+ )
639
+ ]
640
+ )
641
+ except TypeError:
642
+ pass
643
+
644
+ return current
645
+
646
+ def enumerate_all(
647
+ self,
648
+ *,
649
+ depth: int = 1,
650
+ timeout: timedelta = timedelta(seconds=5),
651
+ ) -> Sequence[str]:
652
+ """Enumerate descendant directory and page paths.
653
+
654
+ Returns relative path strings from the current container for all descendant
655
+ nodes, including both directories and pages. Each path is relative to this
656
+ container (e.g., "Folder/Page" or "Folder/Subfolder/Page").
657
+
658
+ :param depth: The maximum depth to traverse. Default is 1 (only immediate children).
659
+ :param timeout: The maximum time to spend enumerating children. Defaults to 5 seconds.
660
+ :returns: A sequence of relative path strings for all descendants.
661
+ """
662
+ return [
663
+ path for path, _node in self.enumerate_nodes(depth=depth, timeout=timeout)
664
+ ]
665
+
666
+ def enumerate_nodes(
667
+ self,
668
+ *,
669
+ depth: int = 1,
670
+ timeout: timedelta = timedelta(seconds=5),
671
+ ) -> Sequence[tuple[str, AbstractTreeNode]]:
672
+ """Enumerate descendant paths paired with their concrete node objects.
673
+
674
+ Returns relative path strings from the current container for all descendant
675
+ nodes, including both directories and pages, paired with the exact in-memory
676
+ node instance each path came from.
677
+
678
+ :param depth: The maximum depth to traverse. Default is 1 (only immediate children).
679
+ :param timeout: The maximum time to spend enumerating children. Defaults to 5 seconds.
680
+ :returns: A sequence of ``(relative_path, node)`` pairs for all descendants.
681
+ """
682
+ return self._enumerate_nodes(depth=depth, timeout=timeout)
683
+
684
+ def enumerate_dirs(
685
+ self,
686
+ *,
687
+ depth: int = 1,
688
+ timeout: timedelta = timedelta(seconds=5),
689
+ ) -> Sequence[str]:
690
+ """Enumerate descendant directory paths.
691
+
692
+ Returns relative path strings from the current container for all descendant
693
+ directories (excluding pages). Each path is relative to this container.
694
+
695
+ :param depth: The maximum depth to traverse. Default is 1 (only immediate children).
696
+ :param timeout: The maximum time to spend enumerating children. Defaults to 5 seconds.
697
+ :returns: A sequence of relative path strings for all descendant directories.
698
+ """
699
+ all_nodes = self.enumerate_nodes(depth=depth, timeout=timeout)
700
+ return [path for path, node in all_nodes if node.is_dir()]
701
+
702
+ def enumerate_pages(
703
+ self,
704
+ *,
705
+ depth: int = 1,
706
+ timeout: timedelta = timedelta(seconds=5),
707
+ ) -> Sequence[str]:
708
+ """Enumerate descendant page paths.
709
+
710
+ Returns relative path strings from the current container for all descendant
711
+ pages (excluding directories). Each path is relative to this container.
712
+
713
+ :param depth: The maximum depth to traverse. Default is 1 (only immediate children).
714
+ :param timeout: The maximum time to spend enumerating children. Defaults to 5 seconds.
715
+ :returns: A sequence of relative path strings for all descendant pages.
716
+ """
717
+ all_nodes = self.enumerate_nodes(depth=depth, timeout=timeout)
718
+ return [path for path, node in all_nodes if not node.is_dir()]
719
+
720
+ def create( # noqa: PLR0912
721
+ self,
722
+ cls: type[T],
723
+ name: str | NotebookPath,
724
+ *,
725
+ parents: bool = False,
726
+ if_exists: InsertBehavior = InsertBehavior.Raise,
727
+ ) -> T:
728
+ """Create a child page or directory in this container.
729
+
730
+ This method supports different behaviors if a node with the same name already exists.
731
+
732
+ :param cls: The class of the node to create (e.g., :class:`~labapi.tree.page.NotebookPage` or :class:`~labapi.tree.directory.NotebookDirectory`).
733
+ :param name: The name of the new node.
734
+ :param parents: If True, intermediate directories in the path will be created
735
+ using `InsertBehavior.Retain` if they don't exist.
736
+ :param if_exists: The behavior to take if a node with the same name and
737
+ type already exists. Defaults to
738
+ ``InsertBehavior.Raise``.
739
+ :returns: The newly created (or existing) node of type `cls`.
740
+ :raises NodeExistsError: If ``if_exists`` is
741
+ ``InsertBehavior.Raise`` and the node already
742
+ exists.
743
+ """
744
+ normalized_name = NotebookPath(name) if isinstance(name, str) else name
745
+ path = normalized_name.resolve(self.path).relative_to(self)
746
+
747
+ if len(path) == 0:
748
+ raise ValueError("Path cannot be empty")
749
+ if len(path) == 1:
750
+ nodes = [n for n in self[Index.Name : path.name] if isinstance(n, cls)]
751
+
752
+ if nodes:
753
+ match if_exists:
754
+ case InsertBehavior.Raise:
755
+ raise NodeExistsError(
756
+ f'{cls.__name__} with name "{name}" already exists'
757
+ )
758
+ case InsertBehavior.Retain:
759
+ return nodes[0]
760
+ case InsertBehavior.Replace:
761
+ for node in nodes:
762
+ node.delete()
763
+
764
+ create_tree = self.user.api_get(
765
+ "tree_tools/insert_node",
766
+ nbid=self.root.id,
767
+ parent_tree_id=self.tree_id,
768
+ display_text=path.name,
769
+ is_folder=(
770
+ "true" if issubclass(cls, AbstractTreeContainer) else "false"
771
+ ),
772
+ )
773
+
774
+ tree_id = extract_etree(create_tree, {"node": {"tree-id": str}})["tree-id"]
775
+
776
+ new_node = cls(tree_id, path.name, self.root, self, self.user)
777
+ if isinstance(new_node, AbstractTreeContainer):
778
+ new_node._populated = True
779
+ self._children.append(new_node)
780
+ return new_node
781
+ if parents:
782
+ next_node = self.dir(path[0])
783
+
784
+ return next_node.create(
785
+ cls,
786
+ path,
787
+ parents=True,
788
+ if_exists=if_exists,
789
+ )
790
+
791
+ raise ValueError(
792
+ f'Parent path for "{name}" does not exist. Set `parents=True` to create it.'
793
+ )
794
+
795
+ def dir(self, name: str | NotebookPath) -> NotebookDirectory:
796
+ """Ensure a directory exists at ``name`` and return it.
797
+
798
+ Shorthand for :meth:`create` with ``cls=NotebookDirectory``,
799
+ ``if_exists=InsertBehavior.Retain``, and ``parents=True``.
800
+
801
+ :param name: The name or path of the directory.
802
+ :returns: The ensured :class:`~labapi.tree.directory.NotebookDirectory`.
803
+ """
804
+ from .directory import NotebookDirectory
805
+
806
+ return self.create(
807
+ NotebookDirectory,
808
+ name,
809
+ parents=True,
810
+ if_exists=InsertBehavior.Retain,
811
+ )
812
+
813
+ def page(self, name: str | NotebookPath) -> NotebookPage:
814
+ """Ensure a page exists at ``name`` and return it.
815
+
816
+ Shorthand for :meth:`create` with ``cls=NotebookPage``,
817
+ ``if_exists=InsertBehavior.Retain``, and ``parents=True``.
818
+
819
+ :param name: The name or path of the page.
820
+ :returns: The ensured :class:`~labapi.tree.page.NotebookPage`.
821
+ """
822
+ from .page import NotebookPage
823
+
824
+ return self.create(
825
+ NotebookPage,
826
+ name,
827
+ parents=True,
828
+ if_exists=InsertBehavior.Retain,
829
+ )
830
+
831
+ @override
832
+ def is_dir(self) -> Literal[True]:
833
+ """Return ``True`` because containers are directories.
834
+
835
+ :returns: Always True.
836
+ """
837
+ return True
838
+
839
+ @override
840
+ def refresh(self) -> Self:
841
+ """Refresh this container by clearing its cached children.
842
+
843
+ This method clears the internal children cache, forcing the container
844
+ to re-fetch its children from the LabArchives API on the next access.
845
+ """
846
+ # TODO if a child node is removed it won't know about it.
847
+ for child in self._children:
848
+ child.refresh()
849
+ self._children = []
850
+ self._populated = False
851
+
852
+ return self