vcti-tree 1.0.0__tar.gz

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,8 @@
1
+ Copyright (c) 2018-2026 Visual Collaboration Technologies Inc.
2
+ All Rights Reserved.
3
+
4
+ This software is proprietary and confidential. Unauthorized copying,
5
+ distribution, or use of this software, via any medium, is strictly
6
+ prohibited. Access is granted only to authorized VCollab developers
7
+ and individuals explicitly authorized by Visual Collaboration
8
+ Technologies Inc.
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.4
2
+ Name: vcti-tree
3
+ Version: 1.0.0
4
+ Summary: Tree protocols and generic algorithms for the vcti ecosystem. Zero-dependency abstractions; pluggable backings.
5
+ Author: Visual Collaboration Technologies Inc.
6
+ License-Expression: LicenseRef-Proprietary
7
+ Project-URL: Homepage, https://github.com/vcollab/vcti-python-tree
8
+ Project-URL: Repository, https://github.com/vcollab/vcti-python-tree
9
+ Project-URL: Changelog, https://github.com/vcollab/vcti-python-tree/blob/main/CHANGELOG.md
10
+ Keywords: tree,protocol,traversal,dfs,bfs,data-structure,generic-algorithms
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: <3.15,>=3.12
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Provides-Extra: test
23
+ Requires-Dist: pytest; extra == "test"
24
+ Requires-Dist: pytest-cov; extra == "test"
25
+ Provides-Extra: lint
26
+ Requires-Dist: ruff; extra == "lint"
27
+ Provides-Extra: typecheck
28
+ Requires-Dist: mypy; extra == "typecheck"
29
+ Dynamic: license-file
30
+
31
+ # vcti-tree
32
+
33
+ Tree protocols and generic algorithms for the vcti ecosystem.
34
+ Zero-dependency abstractions; pluggable backings.
35
+
36
+ ## Overview
37
+
38
+ `vcti-tree` defines what a tree is in the vcti ecosystem: three
39
+ Protocols (`ReadOnlyTree`, `MutableTree`, `LockableTree`)
40
+ parameterised in payload type `T` and handle type `H`, plus generic
41
+ algorithms — DFS and BFS walks, ancestor/depth/path queries,
42
+ locking guards — written against those Protocols. The Protocols
43
+ specify *semantics* (what a tree must support); they do not
44
+ prescribe storage. Any backing — numpy-array, pointer-based,
45
+ dict-based — that satisfies the Protocols gets the algorithms for
46
+ free. The package has no numpy or other heavy dependency; it is a
47
+ pure-Python abstraction layer that downstream tree packages depend
48
+ on.
49
+
50
+ ## Why this exists
51
+
52
+ Tree code in the Python ecosystem typically bundles storage,
53
+ mutation, and traversal into a single class — every new tree
54
+ implementation re-implements its own walker, its own navigation
55
+ helpers, its own delete semantics. This package brings across a
56
+ pattern that's well-established in C++: a small abstract interface
57
+ defining what a tree *is*, multiple concrete implementations
58
+ choosing their own storage, and generic algorithms written once
59
+ against the interface and reused across every backing. The result
60
+ is that adding a new tree implementation (numpy-backed,
61
+ pointer-backed, persistent, …) doesn't mean rewriting walkers and
62
+ guards — it means satisfying three small Protocols and inheriting
63
+ the entire algorithm library. See [docs/design.md](docs/design.md)
64
+ for the full background and the design decisions.
65
+
66
+ ## Installation
67
+
68
+ ```bash
69
+ pip install vcti-tree
70
+ ```
71
+
72
+ ### In `requirements.txt`
73
+
74
+ ```
75
+ vcti-tree>=1.0.0
76
+ ```
77
+
78
+ ### In `pyproject.toml` dependencies
79
+
80
+ ```toml
81
+ dependencies = [
82
+ "vcti-tree>=1.0.0",
83
+ ]
84
+ ```
85
+
86
+ ---
87
+
88
+ ## Quick Start
89
+
90
+ The protocols and algorithms are the API; you bring (or import) a
91
+ backing. `DictTree` is the bundled dict-backed reference
92
+ implementation — enough to build a tree and run the generic algorithms
93
+ against it:
94
+
95
+ ```python
96
+ from vcti.tree import DictTree, dfs_preorder, ancestors, depth
97
+
98
+ tree = DictTree[str]("root") # every tree has a root from the start
99
+ a = tree.create_child(tree.root_handle, "a")
100
+ b = tree.create_child(tree.root_handle, "b")
101
+ tree.create_child(a, "a1")
102
+
103
+ # Generic walks/queries work against any tree that satisfies the protocols.
104
+ print([tree.payload(h) for h in dfs_preorder(tree)]) # ['root', 'a', 'a1', 'b']
105
+ print([tree.payload(h) for h in ancestors(tree, a)]) # ['root']
106
+ print(depth(tree, a)) # 1
107
+ ```
108
+
109
+ The protocols (`ReadOnlyTree`, `MutableTree`, `LockableTree`) and the
110
+ rest of the algorithm set are importable from the same package root:
111
+
112
+ ```python
113
+ from vcti.tree import ReadOnlyTree, MutableTree, LockableTree
114
+ from vcti.tree import dfs_preorder, dfs_postorder, bfs
115
+ from vcti.tree import ancestors, depth, path, descendants
116
+ ```
117
+
118
+ ---
119
+
120
+ ## Dependencies
121
+
122
+ None. Standard library only.
@@ -0,0 +1,92 @@
1
+ # vcti-tree
2
+
3
+ Tree protocols and generic algorithms for the vcti ecosystem.
4
+ Zero-dependency abstractions; pluggable backings.
5
+
6
+ ## Overview
7
+
8
+ `vcti-tree` defines what a tree is in the vcti ecosystem: three
9
+ Protocols (`ReadOnlyTree`, `MutableTree`, `LockableTree`)
10
+ parameterised in payload type `T` and handle type `H`, plus generic
11
+ algorithms — DFS and BFS walks, ancestor/depth/path queries,
12
+ locking guards — written against those Protocols. The Protocols
13
+ specify *semantics* (what a tree must support); they do not
14
+ prescribe storage. Any backing — numpy-array, pointer-based,
15
+ dict-based — that satisfies the Protocols gets the algorithms for
16
+ free. The package has no numpy or other heavy dependency; it is a
17
+ pure-Python abstraction layer that downstream tree packages depend
18
+ on.
19
+
20
+ ## Why this exists
21
+
22
+ Tree code in the Python ecosystem typically bundles storage,
23
+ mutation, and traversal into a single class — every new tree
24
+ implementation re-implements its own walker, its own navigation
25
+ helpers, its own delete semantics. This package brings across a
26
+ pattern that's well-established in C++: a small abstract interface
27
+ defining what a tree *is*, multiple concrete implementations
28
+ choosing their own storage, and generic algorithms written once
29
+ against the interface and reused across every backing. The result
30
+ is that adding a new tree implementation (numpy-backed,
31
+ pointer-backed, persistent, …) doesn't mean rewriting walkers and
32
+ guards — it means satisfying three small Protocols and inheriting
33
+ the entire algorithm library. See [docs/design.md](docs/design.md)
34
+ for the full background and the design decisions.
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ pip install vcti-tree
40
+ ```
41
+
42
+ ### In `requirements.txt`
43
+
44
+ ```
45
+ vcti-tree>=1.0.0
46
+ ```
47
+
48
+ ### In `pyproject.toml` dependencies
49
+
50
+ ```toml
51
+ dependencies = [
52
+ "vcti-tree>=1.0.0",
53
+ ]
54
+ ```
55
+
56
+ ---
57
+
58
+ ## Quick Start
59
+
60
+ The protocols and algorithms are the API; you bring (or import) a
61
+ backing. `DictTree` is the bundled dict-backed reference
62
+ implementation — enough to build a tree and run the generic algorithms
63
+ against it:
64
+
65
+ ```python
66
+ from vcti.tree import DictTree, dfs_preorder, ancestors, depth
67
+
68
+ tree = DictTree[str]("root") # every tree has a root from the start
69
+ a = tree.create_child(tree.root_handle, "a")
70
+ b = tree.create_child(tree.root_handle, "b")
71
+ tree.create_child(a, "a1")
72
+
73
+ # Generic walks/queries work against any tree that satisfies the protocols.
74
+ print([tree.payload(h) for h in dfs_preorder(tree)]) # ['root', 'a', 'a1', 'b']
75
+ print([tree.payload(h) for h in ancestors(tree, a)]) # ['root']
76
+ print(depth(tree, a)) # 1
77
+ ```
78
+
79
+ The protocols (`ReadOnlyTree`, `MutableTree`, `LockableTree`) and the
80
+ rest of the algorithm set are importable from the same package root:
81
+
82
+ ```python
83
+ from vcti.tree import ReadOnlyTree, MutableTree, LockableTree
84
+ from vcti.tree import dfs_preorder, dfs_postorder, bfs
85
+ from vcti.tree import ancestors, depth, path, descendants
86
+ ```
87
+
88
+ ---
89
+
90
+ ## Dependencies
91
+
92
+ None. Standard library only.
@@ -0,0 +1,77 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "vcti-tree"
7
+ version = "1.0.0"
8
+ description = "Tree protocols and generic algorithms for the vcti ecosystem. Zero-dependency abstractions; pluggable backings."
9
+ readme = "README.md"
10
+ authors = [
11
+ {name = "Visual Collaboration Technologies Inc."}
12
+ ]
13
+ requires-python = ">=3.12,<3.15"
14
+ license = "LicenseRef-Proprietary"
15
+ license-files = ["LICENSE"]
16
+ keywords = ["tree", "protocol", "traversal", "dfs", "bfs", "data-structure", "generic-algorithms"]
17
+ classifiers = [
18
+ "Development Status :: 5 - Production/Stable",
19
+ "Intended Audience :: Developers",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Programming Language :: Python :: 3.14",
24
+ "Operating System :: OS Independent",
25
+ "Typing :: Typed",
26
+ ]
27
+ dependencies = []
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/vcollab/vcti-python-tree"
31
+ Repository = "https://github.com/vcollab/vcti-python-tree"
32
+ Changelog = "https://github.com/vcollab/vcti-python-tree/blob/main/CHANGELOG.md"
33
+
34
+ [tool.setuptools.packages.find]
35
+ where = ["src"]
36
+ include = ["vcti.tree", "vcti.tree.*"]
37
+
38
+ [tool.setuptools.package-data]
39
+ "vcti.tree" = ["py.typed"]
40
+
41
+ [project.optional-dependencies]
42
+ test = ["pytest", "pytest-cov"]
43
+ lint = ["ruff"]
44
+ typecheck = ["mypy"]
45
+
46
+ [tool.setuptools]
47
+ zip-safe = true
48
+
49
+ [tool.pytest.ini_options]
50
+ addopts = "--cov=vcti.tree --cov-report=term-missing --cov-fail-under=95"
51
+
52
+ [tool.mypy]
53
+ python_version = "3.12"
54
+ strict = true
55
+ files = ["src"]
56
+ namespace_packages = true
57
+ explicit_package_bases = true
58
+ mypy_path = ["src"]
59
+
60
+ [tool.coverage.run]
61
+ branch = true
62
+
63
+ [tool.coverage.report]
64
+ exclude_also = [
65
+ "raise NotImplementedError",
66
+ "if TYPE_CHECKING:",
67
+ "if __name__ == .__main__.:",
68
+ "@(abc\\.)?abstractmethod",
69
+ "\\.\\.\\.",
70
+ ]
71
+
72
+ [tool.ruff]
73
+ target-version = "py312"
74
+ line-length = 99
75
+
76
+ [tool.ruff.lint]
77
+ select = ["E", "F", "W", "I", "UP"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,41 @@
1
+ # Copyright Visual Collaboration Technologies Inc. All Rights Reserved.
2
+ # See LICENSE for details.
3
+ """vcti.tree — Tree protocols and generic algorithms for the vcti ecosystem."""
4
+
5
+ from importlib.metadata import version
6
+
7
+ from .dict_tree import DictTree, DictTreeHandle
8
+ from .locking import (
9
+ collect_deletable_subtree,
10
+ ensure_payload_unlocked,
11
+ ensure_structure_unlocked,
12
+ )
13
+ from .navigation import ancestors, depth, descendants, path
14
+ from .protocols import LockableTree, MutableTree, ReadOnlyTree
15
+ from .walking import bfs, dfs_postorder, dfs_preorder
16
+
17
+ __version__ = version("vcti-tree")
18
+
19
+ __all__ = [
20
+ "__version__",
21
+ # Protocols
22
+ "ReadOnlyTree",
23
+ "MutableTree",
24
+ "LockableTree",
25
+ # Walking
26
+ "dfs_preorder",
27
+ "dfs_postorder",
28
+ "bfs",
29
+ # Navigation
30
+ "ancestors",
31
+ "depth",
32
+ "path",
33
+ "descendants",
34
+ # Locking guards
35
+ "collect_deletable_subtree",
36
+ "ensure_structure_unlocked",
37
+ "ensure_payload_unlocked",
38
+ # Reference implementation
39
+ "DictTree",
40
+ "DictTreeHandle",
41
+ ]
@@ -0,0 +1,240 @@
1
+ # Copyright Visual Collaboration Technologies Inc. All Rights Reserved.
2
+ # See LICENSE for details.
3
+ """DictTree — minimal dict-backed reference implementation of LockableTree.
4
+
5
+ A simple in-memory tree backed by a Python dict of node records, used
6
+ internally for testing the generic algorithms in
7
+ :mod:`vcti.tree.walking`, :mod:`vcti.tree.navigation`, and
8
+ :mod:`vcti.tree.locking` without pulling in numpy or other heavy
9
+ dependencies. Also serves as a working example of how to implement
10
+ :class:`~vcti.tree.protocols.LockableTree` from scratch.
11
+
12
+ This is **not** the recommended production implementation — for
13
+ numpy-backed storage at file-structure-tree scale, use
14
+ ``vcti-nptree``'s ``ArrayTree``.
15
+
16
+ Handle representation
17
+ ---------------------
18
+
19
+ Handles are ``DictTreeHandle(tree_id, key)`` named tuples:
20
+
21
+ - ``tree_id`` is a process-global counter incremented on each
22
+ ``DictTree`` construction, so handles from different trees compare
23
+ unequal (the cross-tree inequality requirement of the protocol).
24
+ - ``key`` is an integer slot key auto-generated inside the tree.
25
+
26
+ The tuple wrapping is the minimum needed to satisfy cross-tree
27
+ inequality on top of bare keys.
28
+ """
29
+
30
+ from collections.abc import Hashable, Iterable, Iterator
31
+ from dataclasses import dataclass, field
32
+ from itertools import count
33
+ from typing import NamedTuple
34
+
35
+ from .locking import collect_deletable_subtree
36
+
37
+ # Process-global counter; one tag per DictTree instance.
38
+ _tree_id_counter = count(1)
39
+
40
+
41
+ class DictTreeHandle(NamedTuple):
42
+ """Opaque handle to a node in a :class:`DictTree`."""
43
+
44
+ tree_id: int
45
+ key: int
46
+
47
+
48
+ @dataclass
49
+ class _NodeRecord[T]:
50
+ """Internal storage for one node."""
51
+
52
+ parent: int | None # parent key; None for root
53
+ children: list[int] = field(default_factory=list)
54
+ payload: T | None = None
55
+ active: bool = True
56
+ structure_locked: bool = False
57
+ payload_locked: bool = False
58
+
59
+
60
+ class DictTree[T]:
61
+ """Dict-backed reference implementation of
62
+ :class:`~vcti.tree.protocols.LockableTree`.
63
+
64
+ Satisfies ``LockableTree[T, DictTreeHandle]`` via the structural-
65
+ typing protocol. Soft-delete model (matches the ``ArrayTree``
66
+ semantics): :meth:`delete_subtree` marks nodes inactive but leaves
67
+ them in the parent's children list; ``children()`` filters them out.
68
+
69
+ Locks are flat bitsets propagated across the whole active subtree
70
+ on each ``lock_*`` / ``unlock_*`` call (no refcount).
71
+ """
72
+
73
+ def __init__(self, root_data: T | None = None) -> None:
74
+ self._tree_id: int = next(_tree_id_counter)
75
+ self._next_key: int = 0
76
+ self._records: dict[int, _NodeRecord[T]] = {}
77
+ self._active_count: int = 0
78
+ # Allocate root at key 0 with parent=None.
79
+ root_key = self._allocate(parent=None, data=root_data)
80
+ self._root_key: int = root_key
81
+
82
+ # ---- handle (un)wrapping ----
83
+
84
+ def _unwrap(self, handle: DictTreeHandle) -> int:
85
+ if handle.tree_id != self._tree_id:
86
+ raise LookupError(
87
+ f"Handle {handle} is from a different tree (this tree's id={self._tree_id})"
88
+ )
89
+ if handle.key not in self._records:
90
+ raise KeyError(f"Handle {handle} refers to a key that was never allocated")
91
+ return handle.key
92
+
93
+ def _wrap(self, key: int) -> DictTreeHandle:
94
+ return DictTreeHandle(self._tree_id, key)
95
+
96
+ def _require_active(self, handle: DictTreeHandle) -> tuple[int, "_NodeRecord[T]"]:
97
+ """Unwrap ``handle`` and return ``(key, record)`` for a live node.
98
+
99
+ Raises:
100
+ LookupError: handle is from a different tree.
101
+ KeyError: handle's key was never allocated.
102
+ ValueError: the node has been soft-deleted.
103
+ """
104
+ key = self._unwrap(handle)
105
+ rec = self._records[key]
106
+ if not rec.active:
107
+ raise ValueError(f"Node {handle} has been deleted")
108
+ return key, rec
109
+
110
+ # ---- internal allocation ----
111
+
112
+ def _allocate(self, parent: int | None, data: T | None) -> int:
113
+ key = self._next_key
114
+ self._next_key += 1
115
+ self._records[key] = _NodeRecord(parent=parent, payload=data)
116
+ self._active_count += 1
117
+ return key
118
+
119
+ # ---- ReadOnlyTree ----
120
+
121
+ @property
122
+ def root_handle(self) -> DictTreeHandle:
123
+ return self._wrap(self._root_key)
124
+
125
+ def payload(self, handle: DictTreeHandle) -> T | None:
126
+ _, rec = self._require_active(handle)
127
+ return rec.payload
128
+
129
+ def parent(self, handle: DictTreeHandle) -> DictTreeHandle | None:
130
+ _, rec = self._require_active(handle)
131
+ return None if rec.parent is None else self._wrap(rec.parent)
132
+
133
+ def children(self, handle: DictTreeHandle) -> Iterable[DictTreeHandle]:
134
+ _, rec = self._require_active(handle)
135
+ # Walk the full child list, skip inactive — do not terminate early.
136
+ return [
137
+ self._wrap(child_key) for child_key in rec.children if self._records[child_key].active
138
+ ]
139
+
140
+ def __contains__(self, handle: object) -> bool:
141
+ if not isinstance(handle, DictTreeHandle):
142
+ return False
143
+ if handle.tree_id != self._tree_id:
144
+ return False
145
+ rec = self._records.get(handle.key)
146
+ return rec is not None and rec.active
147
+
148
+ def __iter__(self) -> Iterator[DictTreeHandle]:
149
+ for key, rec in self._records.items():
150
+ if rec.active:
151
+ yield self._wrap(key)
152
+
153
+ def __len__(self) -> int:
154
+ return self._active_count
155
+
156
+ # ---- MutableTree ----
157
+
158
+ def set_payload(self, handle: DictTreeHandle, data: T | None) -> None:
159
+ _, rec = self._require_active(handle)
160
+ if rec.payload_locked:
161
+ raise RuntimeError(f"Node {handle} is payload-locked")
162
+ rec.payload = data
163
+
164
+ def create_child(self, parent: DictTreeHandle, data: T | None = None) -> DictTreeHandle:
165
+ parent_key, parent_rec = self._require_active(parent)
166
+ if parent_rec.structure_locked:
167
+ raise RuntimeError(f"Parent {parent} is structure-locked")
168
+ child_key = self._allocate(parent=parent_key, data=data)
169
+ parent_rec.children.append(child_key)
170
+ return self._wrap(child_key)
171
+
172
+ def delete_subtree(self, handle: DictTreeHandle) -> None:
173
+ _, rec = self._require_active(handle)
174
+ if rec.parent is None:
175
+ raise RuntimeError("Cannot delete the root")
176
+ # Fail-fast collect via the shared protocol-level helper (raises if
177
+ # any node in the subtree is structure-locked), then deactivate the
178
+ # whole set atomically — no partial deletion on a locked subtree.
179
+ to_delete = collect_deletable_subtree(self, handle)
180
+ for h in to_delete:
181
+ self._records[h.key].active = False
182
+ self._active_count -= len(to_delete)
183
+
184
+ # ---- LockableTree ----
185
+
186
+ def is_structure_locked(self, handle: DictTreeHandle) -> bool:
187
+ _, rec = self._require_active(handle)
188
+ return rec.structure_locked
189
+
190
+ def is_payload_locked(self, handle: DictTreeHandle) -> bool:
191
+ _, rec = self._require_active(handle)
192
+ return rec.payload_locked
193
+
194
+ def lock_structure(self, handle: DictTreeHandle) -> None:
195
+ self._apply_flag(handle, "structure_locked", True)
196
+
197
+ def unlock_structure(self, handle: DictTreeHandle) -> None:
198
+ self._apply_flag(handle, "structure_locked", False)
199
+
200
+ def lock_payload(self, handle: DictTreeHandle) -> None:
201
+ self._apply_flag(handle, "payload_locked", True)
202
+
203
+ def unlock_payload(self, handle: DictTreeHandle) -> None:
204
+ self._apply_flag(handle, "payload_locked", False)
205
+
206
+ def _apply_flag(self, handle: DictTreeHandle, attr: str, value: bool) -> None:
207
+ """Set ``attr=value`` on ``handle`` and all active descendants.
208
+
209
+ Raises if ``handle`` itself is not a live node (so lock/unlock on a
210
+ deleted handle fails loudly, consistent with the rest of the surface
211
+ rather than silently no-op'ing). Inactive *descendants* are skipped:
212
+ propagation covers the active subtree only.
213
+ """
214
+ root_key, _ = self._require_active(handle)
215
+ # Walk the subtree skipping inactive nodes; full child-chain walk.
216
+ stack: list[int] = [root_key]
217
+ while stack:
218
+ k = stack.pop()
219
+ rec = self._records[k]
220
+ if not rec.active:
221
+ continue
222
+ setattr(rec, attr, value)
223
+ for child_key in reversed(rec.children):
224
+ stack.append(child_key)
225
+
226
+
227
+ # Static structural conformance check (mypy will see this).
228
+ def _check_protocol_conformance() -> None: # pragma: no cover
229
+ from .protocols import LockableTree, MutableTree, ReadOnlyTree
230
+
231
+ tree: DictTree[str] = DictTree("root")
232
+ _ro: ReadOnlyTree[str, DictTreeHandle] = tree
233
+ _mu: MutableTree[str, DictTreeHandle] = tree
234
+ _lo: LockableTree[str, DictTreeHandle] = tree
235
+ _ = _ro, _mu, _lo
236
+
237
+
238
+ def _is_hashable_handle() -> None: # pragma: no cover
239
+ # Sanity: DictTreeHandle must satisfy H: Hashable bound.
240
+ _: Hashable = DictTreeHandle(1, 0)
@@ -0,0 +1,78 @@
1
+ # Copyright Visual Collaboration Technologies Inc. All Rights Reserved.
2
+ # See LICENSE for details.
3
+ """Locking guards over any :class:`LockableTree`.
4
+
5
+ Two complementary helpers:
6
+
7
+ - :func:`collect_deletable_subtree` is the fused common-case for
8
+ ``delete_subtree`` implementations — single-pass walk + structure-lock
9
+ check, fail-fast on the first locked node.
10
+ - :func:`ensure_structure_unlocked` and :func:`ensure_payload_unlocked`
11
+ are predicate-only forms for arbitrary handle iterables (e.g.,
12
+ user-supplied selections) that don't need subtree walking.
13
+
14
+ All three operate against the :class:`LockableTree` protocol — never
15
+ against any concrete implementation.
16
+ """
17
+
18
+ from collections.abc import Hashable, Iterable
19
+
20
+ from .protocols import LockableTree
21
+ from .walking import dfs_preorder
22
+
23
+
24
+ def collect_deletable_subtree[T, H: Hashable](tree: LockableTree[T, H], root_handle: H) -> list[H]:
25
+ """Walk the subtree at ``root_handle`` DFS pre-order and verify
26
+ no node is structure-locked.
27
+
28
+ Returns the list of handles in DFS pre-order; callers can then
29
+ deactivate them atomically without rolling back.
30
+
31
+ Intentionally fused (walks + validates in a single pass) rather
32
+ than composing :func:`descendants` + :func:`ensure_structure_unlocked`,
33
+ so that an early-locked node fails fast without materialising the
34
+ rest of the subtree.
35
+
36
+ Raises:
37
+ RuntimeError: If any node in the subtree is structure-locked.
38
+ """
39
+ nodes: list[H] = []
40
+ for h in dfs_preorder(tree, root_handle):
41
+ if tree.is_structure_locked(h):
42
+ raise RuntimeError(
43
+ f"Cannot delete subtree at {root_handle}: node {h} is structure-locked"
44
+ )
45
+ nodes.append(h)
46
+ return nodes
47
+
48
+
49
+ def ensure_structure_unlocked[T, H: Hashable](
50
+ tree: LockableTree[T, H], handles: Iterable[H]
51
+ ) -> None:
52
+ """Raise on the first structure-locked handle in ``handles``.
53
+
54
+ Use this when the caller already has a handle list (e.g., a
55
+ user-supplied selection or a result from a separate walk) and
56
+ doesn't need subtree collection. For the subtree-delete common
57
+ case, prefer :func:`collect_deletable_subtree` which does both
58
+ in a single pass.
59
+
60
+ Raises:
61
+ RuntimeError: On the first structure-locked handle.
62
+ """
63
+ for h in handles:
64
+ if tree.is_structure_locked(h):
65
+ raise RuntimeError(f"Node {h} is structure-locked")
66
+
67
+
68
+ def ensure_payload_unlocked[T, H: Hashable](
69
+ tree: LockableTree[T, H], handles: Iterable[H]
70
+ ) -> None:
71
+ """Raise on the first payload-locked handle in ``handles``.
72
+
73
+ Raises:
74
+ RuntimeError: On the first payload-locked handle.
75
+ """
76
+ for h in handles:
77
+ if tree.is_payload_locked(h):
78
+ raise RuntimeError(f"Node {h} is payload-locked")