vcti-tree 1.0.0__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.
- vcti/tree/__init__.py +41 -0
- vcti/tree/dict_tree.py +240 -0
- vcti/tree/locking.py +78 -0
- vcti/tree/navigation.py +57 -0
- vcti/tree/protocols.py +180 -0
- vcti/tree/py.typed +0 -0
- vcti/tree/walking.py +74 -0
- vcti_tree-1.0.0.dist-info/METADATA +122 -0
- vcti_tree-1.0.0.dist-info/RECORD +13 -0
- vcti_tree-1.0.0.dist-info/WHEEL +5 -0
- vcti_tree-1.0.0.dist-info/licenses/LICENSE +8 -0
- vcti_tree-1.0.0.dist-info/top_level.txt +1 -0
- vcti_tree-1.0.0.dist-info/zip-safe +1 -0
vcti/tree/__init__.py
ADDED
|
@@ -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
|
+
]
|
vcti/tree/dict_tree.py
ADDED
|
@@ -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)
|
vcti/tree/locking.py
ADDED
|
@@ -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")
|
vcti/tree/navigation.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Copyright Visual Collaboration Technologies Inc. All Rights Reserved.
|
|
2
|
+
# See LICENSE for details.
|
|
3
|
+
"""Relationship-oriented navigation helpers over any :class:`ReadOnlyTree`.
|
|
4
|
+
|
|
5
|
+
These are the "answers" layer: given a handle, return its ancestors,
|
|
6
|
+
depth, path, or descendants as concrete handles. The iterator-oriented
|
|
7
|
+
walks live in :mod:`vcti.tree.walking`; this module composes them with
|
|
8
|
+
the parent-chain query exposed by the protocol.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from collections.abc import Hashable, Iterator
|
|
12
|
+
|
|
13
|
+
from .protocols import ReadOnlyTree
|
|
14
|
+
from .walking import dfs_preorder
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def ancestors[T, H: Hashable](tree: ReadOnlyTree[T, H], handle: H) -> Iterator[H]:
|
|
18
|
+
"""Yield ancestors of ``handle`` from parent up to (and including) root.
|
|
19
|
+
|
|
20
|
+
Yields nothing if ``handle`` is the root.
|
|
21
|
+
"""
|
|
22
|
+
nid = tree.parent(handle)
|
|
23
|
+
while nid is not None:
|
|
24
|
+
yield nid
|
|
25
|
+
nid = tree.parent(nid)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def depth[T, H: Hashable](tree: ReadOnlyTree[T, H], handle: H) -> int:
|
|
29
|
+
"""Distance from ``handle`` to the root. ``depth(root_handle) == 0``."""
|
|
30
|
+
return sum(1 for _ in ancestors(tree, handle))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def path[T, H: Hashable](tree: ReadOnlyTree[T, H], handle: H) -> list[H]:
|
|
34
|
+
"""List of handles from root to ``handle`` (inclusive).
|
|
35
|
+
|
|
36
|
+
For the root, returns ``[root_handle]``.
|
|
37
|
+
"""
|
|
38
|
+
result = [handle]
|
|
39
|
+
result.extend(ancestors(tree, handle))
|
|
40
|
+
result.reverse()
|
|
41
|
+
return result
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def descendants[T, H: Hashable](
|
|
45
|
+
tree: ReadOnlyTree[T, H], handle: H, *, include_self: bool = False
|
|
46
|
+
) -> Iterator[H]:
|
|
47
|
+
"""DFS pre-order descendants of ``handle``.
|
|
48
|
+
|
|
49
|
+
With ``include_self=False`` (the default), ``handle`` itself is not
|
|
50
|
+
yielded; the first yielded item is the first child (if any).
|
|
51
|
+
With ``include_self=True``, ``handle`` is yielded first.
|
|
52
|
+
"""
|
|
53
|
+
walker = dfs_preorder(tree, handle)
|
|
54
|
+
first = next(walker, None)
|
|
55
|
+
if include_self and first is not None:
|
|
56
|
+
yield first
|
|
57
|
+
yield from walker
|
vcti/tree/protocols.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# Copyright Visual Collaboration Technologies Inc. All Rights Reserved.
|
|
2
|
+
# See LICENSE for details.
|
|
3
|
+
"""Tree protocols — ReadOnlyTree, MutableTree, LockableTree.
|
|
4
|
+
|
|
5
|
+
Defines what a tree IS in the vcti ecosystem, parameterised in payload
|
|
6
|
+
type ``T`` and handle type ``H``. The protocols specify semantics (what
|
|
7
|
+
a tree must support); they do not prescribe storage. Any backing that
|
|
8
|
+
satisfies the protocols gets the generic algorithms in
|
|
9
|
+
:mod:`vcti.tree.walking`, :mod:`vcti.tree.navigation`, and
|
|
10
|
+
:mod:`vcti.tree.locking` for free.
|
|
11
|
+
|
|
12
|
+
See the module-level docs for each protocol for the contract details
|
|
13
|
+
(root invariant, active-membership semantics, cross-tree handle
|
|
14
|
+
inequality requirement, handle validity rules).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from collections.abc import Hashable, Iterable, Iterator
|
|
18
|
+
from typing import Protocol, runtime_checkable
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@runtime_checkable
|
|
22
|
+
class ReadOnlyTree[T, H: Hashable](Protocol):
|
|
23
|
+
"""A tree of T-valued nodes addressed by handles of type H.
|
|
24
|
+
|
|
25
|
+
Every Tree has a root from construction onward. Implementations
|
|
26
|
+
decide how the root is allocated and how active membership is
|
|
27
|
+
tracked, but the protocol-level contract requires:
|
|
28
|
+
|
|
29
|
+
- ``tree.root_handle`` always returns a handle to a live node.
|
|
30
|
+
- ``parent(tree.root_handle)`` returns ``None``.
|
|
31
|
+
- ``len(tree) >= 1`` always; the empty-tree state does not exist.
|
|
32
|
+
- Handles obtained from different tree instances must compare
|
|
33
|
+
unequal (implementations satisfy this via tagged handles, node
|
|
34
|
+
object identity, or key wrapping — protocol does not prescribe
|
|
35
|
+
the mechanism).
|
|
36
|
+
- ``__contains__``, ``__iter__``, and ``children`` must agree on
|
|
37
|
+
what "currently active" means. The representation
|
|
38
|
+
(soft-delete bit, hard-delete, tombstones, generation counters,
|
|
39
|
+
etc.) is implementation choice; the obligation is
|
|
40
|
+
self-consistency.
|
|
41
|
+
|
|
42
|
+
The iterable returned by :meth:`children` should be treated as
|
|
43
|
+
transient: unless an implementation documents stronger
|
|
44
|
+
guarantees, mutating the tree may invalidate any previously
|
|
45
|
+
obtained child iterable. Materialise first
|
|
46
|
+
(``list(tree.children(h))``) if mutation during traversal is
|
|
47
|
+
required.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def root_handle(self) -> H:
|
|
52
|
+
"""The handle of the root node. Always valid."""
|
|
53
|
+
...
|
|
54
|
+
|
|
55
|
+
def payload(self, handle: H) -> T | None:
|
|
56
|
+
"""Return the payload at the handle.
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
Implementation-specific exceptions if the handle is not
|
|
60
|
+
currently a live node (out of range, soft-deleted, from
|
|
61
|
+
another tree, or held across a structural invalidation
|
|
62
|
+
event). The convention is to raise ``LookupError`` or a
|
|
63
|
+
subclass for handle-validity failures.
|
|
64
|
+
"""
|
|
65
|
+
...
|
|
66
|
+
|
|
67
|
+
def parent(self, handle: H) -> H | None:
|
|
68
|
+
"""Return the parent handle, or ``None`` for the root."""
|
|
69
|
+
...
|
|
70
|
+
|
|
71
|
+
def children(self, handle: H) -> Iterable[H]:
|
|
72
|
+
"""Return an iterable over the active children of ``handle``."""
|
|
73
|
+
...
|
|
74
|
+
|
|
75
|
+
def __contains__(self, handle: object) -> bool:
|
|
76
|
+
"""Report current active membership.
|
|
77
|
+
|
|
78
|
+
``handle in tree`` is ``True`` iff ``handle`` refers to a
|
|
79
|
+
currently active node, and ``False`` in every other case —
|
|
80
|
+
including non-handle inputs, foreign-tree handles, and
|
|
81
|
+
stale handles. This method does not raise.
|
|
82
|
+
"""
|
|
83
|
+
...
|
|
84
|
+
|
|
85
|
+
def __iter__(self) -> Iterator[H]:
|
|
86
|
+
"""Iterate over active handles in implementation-defined order."""
|
|
87
|
+
...
|
|
88
|
+
|
|
89
|
+
def __len__(self) -> int:
|
|
90
|
+
"""Count of currently active nodes. Always ``>= 1``."""
|
|
91
|
+
...
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@runtime_checkable
|
|
95
|
+
class MutableTree[T, H: Hashable](ReadOnlyTree[T, H], Protocol):
|
|
96
|
+
"""A :class:`ReadOnlyTree` that supports mutation.
|
|
97
|
+
|
|
98
|
+
``create_child`` is the only operation that adds a node, and the
|
|
99
|
+
new node is always allocated as a child of an existing one — the
|
|
100
|
+
root is established by the constructor (implementation-specific)
|
|
101
|
+
and cannot be added or removed through the protocol.
|
|
102
|
+
|
|
103
|
+
``delete_subtree`` removes a handle and all its descendants
|
|
104
|
+
atomically. There is no protocol-level distinction between
|
|
105
|
+
leaf-delete and subtree-delete; callers that want leaf-only
|
|
106
|
+
semantics should check ``tree.children(handle)`` first.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def set_payload(self, handle: H, data: T | None) -> None:
|
|
110
|
+
"""Write the payload at the handle."""
|
|
111
|
+
...
|
|
112
|
+
|
|
113
|
+
def create_child(self, parent: H, data: T | None = None) -> H:
|
|
114
|
+
"""Allocate a new child of ``parent`` and return its handle."""
|
|
115
|
+
...
|
|
116
|
+
|
|
117
|
+
def delete_subtree(self, handle: H) -> None:
|
|
118
|
+
"""Remove ``handle`` and all its descendants.
|
|
119
|
+
|
|
120
|
+
Calling this on the root handle must raise (the root cannot
|
|
121
|
+
be removed). The exact exception type is implementation
|
|
122
|
+
choice; the *behaviour* is protocol-required.
|
|
123
|
+
"""
|
|
124
|
+
...
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@runtime_checkable
|
|
128
|
+
class LockableTree[T, H: Hashable](MutableTree[T, H], Protocol):
|
|
129
|
+
"""A :class:`MutableTree` with per-node structure/payload locks.
|
|
130
|
+
|
|
131
|
+
Lock semantics:
|
|
132
|
+
|
|
133
|
+
- **Subtree-propagating.** ``lock_structure(h)`` sets the lock on
|
|
134
|
+
``h`` and on every active descendant; ``unlock_structure(h)``
|
|
135
|
+
clears it on the same set.
|
|
136
|
+
- **Flat bitset (no refcount).** Unlocking an outer subtree clears
|
|
137
|
+
the lock on any nested independently-locked subtrees too.
|
|
138
|
+
- **Guards are enforced in mutators.** ``STRUCTURE_LOCKED`` blocks
|
|
139
|
+
``create_child(locked_node, ...)``, ``delete_subtree(locked_node)``,
|
|
140
|
+
and ``delete_subtree`` of any ancestor whose subtree contains a
|
|
141
|
+
structure-locked node (atomic — no partial deletion).
|
|
142
|
+
``PAYLOAD_LOCKED`` blocks ``set_payload(locked_node, value)``.
|
|
143
|
+
- **Predicates are pure reads.** ``is_structure_locked`` /
|
|
144
|
+
``is_payload_locked`` do not mutate and do not raise *because of*
|
|
145
|
+
lock state — a locked node is a valid read, returning ``True``.
|
|
146
|
+
- **Handle validity applies uniformly.** Every method here other
|
|
147
|
+
than the membership test follows the same handle-validity contract
|
|
148
|
+
as :class:`ReadOnlyTree`: the predicates and the ``lock_*`` /
|
|
149
|
+
``unlock_*`` mutators raise on a stale, foreign, or inactive
|
|
150
|
+
(deleted) handle rather than silently returning stale state or
|
|
151
|
+
no-op'ing. Only ``__contains__`` tolerates a bad handle (it
|
|
152
|
+
returns ``False``).
|
|
153
|
+
- Locks do **not** prevent in-place mutation of payload objects
|
|
154
|
+
(``tree.payload(h).data = ...``). That is the payload type's
|
|
155
|
+
concern, not the tree's.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
def is_structure_locked(self, handle: H) -> bool:
|
|
159
|
+
"""Whether the structure lock is set on ``handle``."""
|
|
160
|
+
...
|
|
161
|
+
|
|
162
|
+
def is_payload_locked(self, handle: H) -> bool:
|
|
163
|
+
"""Whether the payload lock is set on ``handle``."""
|
|
164
|
+
...
|
|
165
|
+
|
|
166
|
+
def lock_structure(self, handle: H) -> None:
|
|
167
|
+
"""Lock structure on ``handle`` and all active descendants."""
|
|
168
|
+
...
|
|
169
|
+
|
|
170
|
+
def unlock_structure(self, handle: H) -> None:
|
|
171
|
+
"""Unlock structure on ``handle`` and all active descendants."""
|
|
172
|
+
...
|
|
173
|
+
|
|
174
|
+
def lock_payload(self, handle: H) -> None:
|
|
175
|
+
"""Lock payload on ``handle`` and all active descendants."""
|
|
176
|
+
...
|
|
177
|
+
|
|
178
|
+
def unlock_payload(self, handle: H) -> None:
|
|
179
|
+
"""Unlock payload on ``handle`` and all active descendants."""
|
|
180
|
+
...
|
vcti/tree/py.typed
ADDED
|
File without changes
|
vcti/tree/walking.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Copyright Visual Collaboration Technologies Inc. All Rights Reserved.
|
|
2
|
+
# See LICENSE for details.
|
|
3
|
+
"""Walking algorithms over any :class:`ReadOnlyTree`.
|
|
4
|
+
|
|
5
|
+
Free functions following Python's ``sorted(iterable)`` / ``itertools.*``
|
|
6
|
+
tradition. They allocate their own stack/queue and call back into the
|
|
7
|
+
tree only through :attr:`~ReadOnlyTree.root_handle` and
|
|
8
|
+
:meth:`~ReadOnlyTree.children` — they do not touch any implementation
|
|
9
|
+
internals.
|
|
10
|
+
|
|
11
|
+
All three walks start at ``start`` if provided, otherwise at
|
|
12
|
+
``tree.root_handle``, and yield handles lazily as the walk progresses.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from collections import deque
|
|
16
|
+
from collections.abc import Hashable, Iterator
|
|
17
|
+
|
|
18
|
+
from .protocols import ReadOnlyTree
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def dfs_preorder[T, H: Hashable](tree: ReadOnlyTree[T, H], start: H | None = None) -> Iterator[H]:
|
|
22
|
+
"""DFS pre-order traversal from ``start``.
|
|
23
|
+
|
|
24
|
+
Yields ``start`` first, then each of its subtrees in left-to-right
|
|
25
|
+
order, each subtree recursively in pre-order. Defaults to the
|
|
26
|
+
tree's root.
|
|
27
|
+
"""
|
|
28
|
+
nid = tree.root_handle if start is None else start
|
|
29
|
+
stack: list[H] = [nid]
|
|
30
|
+
while stack:
|
|
31
|
+
n = stack.pop()
|
|
32
|
+
yield n
|
|
33
|
+
# Push children in reverse so leftmost is popped first.
|
|
34
|
+
children = list(tree.children(n))
|
|
35
|
+
for c in reversed(children):
|
|
36
|
+
stack.append(c)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def dfs_postorder[T, H: Hashable](tree: ReadOnlyTree[T, H], start: H | None = None) -> Iterator[H]:
|
|
40
|
+
"""DFS post-order traversal from ``start``.
|
|
41
|
+
|
|
42
|
+
Yields each subtree in left-to-right post-order (children before
|
|
43
|
+
parent). Defaults to the tree's root.
|
|
44
|
+
|
|
45
|
+
Implemented with an explicit stack of ``(handle, expanded)`` pairs
|
|
46
|
+
rather than Python recursion, so deep trees don't hit the Python
|
|
47
|
+
recursion limit.
|
|
48
|
+
"""
|
|
49
|
+
nid = tree.root_handle if start is None else start
|
|
50
|
+
stack: list[tuple[H, bool]] = [(nid, False)]
|
|
51
|
+
while stack:
|
|
52
|
+
n, expanded = stack.pop()
|
|
53
|
+
if expanded:
|
|
54
|
+
yield n
|
|
55
|
+
else:
|
|
56
|
+
stack.append((n, True))
|
|
57
|
+
children = list(tree.children(n))
|
|
58
|
+
for c in reversed(children):
|
|
59
|
+
stack.append((c, False))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def bfs[T, H: Hashable](tree: ReadOnlyTree[T, H], start: H | None = None) -> Iterator[H]:
|
|
63
|
+
"""Breadth-first (level-order) traversal from ``start``.
|
|
64
|
+
|
|
65
|
+
Yields ``start`` first, then all nodes at depth 1, then depth 2,
|
|
66
|
+
and so on. Defaults to the tree's root.
|
|
67
|
+
"""
|
|
68
|
+
nid = tree.root_handle if start is None else start
|
|
69
|
+
queue: deque[H] = deque([nid])
|
|
70
|
+
while queue:
|
|
71
|
+
n = queue.popleft()
|
|
72
|
+
yield n
|
|
73
|
+
for c in tree.children(n):
|
|
74
|
+
queue.append(c)
|
|
@@ -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,13 @@
|
|
|
1
|
+
vcti/tree/__init__.py,sha256=86WHsJNF74N78KpDivrIeiY9sFV_Y9wbpBJ9TFWCvdE,1039
|
|
2
|
+
vcti/tree/dict_tree.py,sha256=NvOVElVOhBWheKMeF0sgMSTe7z1Zr8ALcZ_7ka6j96E,9029
|
|
3
|
+
vcti/tree/locking.py,sha256=cblkzVJrEQbFNdBtUy9PDqRXPpwwM5UZjjiAEN6U0rE,2778
|
|
4
|
+
vcti/tree/navigation.py,sha256=q-zwZ2VpaxDZAR7O0Pehc9xF0i3jGXYQRE0b2DmmQ14,1911
|
|
5
|
+
vcti/tree/protocols.py,sha256=U1maKg_eQqMasScWeK4-pR7tVePAJp_G4PN_ApTtcak,7287
|
|
6
|
+
vcti/tree/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
vcti/tree/walking.py,sha256=ouEas9CNGkarClkFDoUxVz6OjjrItMkStDl7dyLHOO8,2604
|
|
8
|
+
vcti_tree-1.0.0.dist-info/licenses/LICENSE,sha256=gqRj-E4YRsT7mZ52W76LG6aTTFv6iEOK9QR_fV5EdrI,369
|
|
9
|
+
vcti_tree-1.0.0.dist-info/METADATA,sha256=CWZcQn00x59NKTYF6nb3EzoztkVwC46wooqPPaM-xew,4283
|
|
10
|
+
vcti_tree-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
11
|
+
vcti_tree-1.0.0.dist-info/top_level.txt,sha256=Jl6AIAI3Xhru_BFQAhD_13VeXLmZQd9BqBNUaAKNgKs,5
|
|
12
|
+
vcti_tree-1.0.0.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
13
|
+
vcti_tree-1.0.0.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
vcti
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|