Coverage for src/loman/nodekey.py: 100%
164 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-07 00:32 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-07 00:32 +0000
1"""Node key implementation for computation graph navigation."""
3import json
4import re
5from collections.abc import Hashable, Iterable
6from dataclasses import dataclass
7from typing import ClassVar, Optional, Union
9from loman.util import as_iterable
11Name = Union[str, "NodeKey", Hashable]
12Names = list[Name]
15class PathNotFoundError(Exception):
16 """Exception raised when a node path cannot be found."""
19def quote_part(part: Hashable) -> str:
20 """Quote a node key part for safe representation in paths."""
21 if isinstance(part, str):
22 if "/" in part:
23 return json.dumps(part)
24 else:
25 return part
26 return str(part)
29@dataclass(frozen=True, repr=False)
30class NodeKey:
31 """Immutable key for identifying nodes in the computation graph hierarchy."""
33 parts: tuple[Hashable, ...]
35 def __str__(self) -> str:
36 """Return string representation using path notation."""
37 return "/".join([quote_part(part) for part in self.parts])
39 @property
40 def name(self) -> Name:
41 """Get the name of this node (last part of the path)."""
42 if len(self.parts) == 0:
43 return ""
44 elif len(self.parts) == 1:
45 part: Name = self.parts[0]
46 return part
47 elif all(isinstance(part, str) for part in self.parts):
48 return "/".join(quote_part(part) for part in self.parts)
49 else:
50 return self
52 @property
53 def label(self) -> str:
54 """Get the label for this node (for display purposes)."""
55 if len(self.parts) == 0:
56 return ""
57 return str(self.parts[-1])
59 def drop_root(self, root: Optional["Name"]) -> Optional["NodeKey"]:
60 """Remove a root prefix from this node key if it matches."""
61 if root is None:
62 return self
63 root = to_nodekey(root)
64 n_root_parts = len(root.parts)
65 if self.is_descendent_of(root):
66 parts = self.parts[n_root_parts:]
67 return NodeKey(parts)
68 else:
69 return None
71 def join(self, *others: Name) -> "NodeKey":
72 """Join this node key with other names to create a new node key."""
73 result = self
74 for other in others:
75 if other is None:
76 continue
77 other = to_nodekey(other)
78 result = result.join_parts(*other.parts)
79 return result
81 def join_parts(self, *parts: Hashable) -> "NodeKey":
82 """Join this node key with raw parts to create a new node key."""
83 if len(parts) == 0:
84 return self
85 return NodeKey(self.parts + tuple(parts))
87 def __truediv__(self, other: Name) -> "NodeKey":
88 """Join this node key with other to create a new node key."""
89 return self.join(other)
91 def is_descendent_of(self, other: "NodeKey") -> bool:
92 """Check if this node key is a descendant of another node key."""
93 n_self_parts = len(self.parts)
94 n_other_parts = len(other.parts)
95 return n_self_parts > n_other_parts and self.parts[:n_other_parts] == other.parts
97 @property
98 def parent(self) -> "NodeKey":
99 """Get the parent node key."""
100 if len(self.parts) == 0:
101 raise PathNotFoundError()
102 return NodeKey(self.parts[:-1])
104 def prepend(self, nk: "NodeKey") -> "NodeKey":
105 """Prepend another node key to this one."""
106 return nk.join_parts(*self.parts)
108 def __repr__(self) -> str:
109 """Return string representation for debugging."""
110 path_str = str(self)
111 quoted_path_str = repr(path_str)
112 return f"{self.__class__.__name__}({quoted_path_str})"
114 def __eq__(self, other: object) -> bool:
115 """Check equality with another NodeKey."""
116 if other is None:
117 return False
118 if not isinstance(other, NodeKey):
119 return NotImplemented
120 return self.parts == other.parts
122 _ROOT: ClassVar["NodeKey | None"] = None
124 @classmethod
125 def root(cls) -> "NodeKey":
126 """Get the root node key."""
127 if cls._ROOT is None:
128 cls._ROOT = cls(())
129 return cls._ROOT
131 @property
132 def is_root(self) -> bool:
133 """Check if this is the root node key."""
134 return len(self.parts) == 0
136 @staticmethod
137 def common_parent(nodekey1: Name, nodekey2: Name) -> "NodeKey":
138 """Find the common parent of two node keys."""
139 nk1 = to_nodekey(nodekey1)
140 nk2 = to_nodekey(nodekey2)
141 parts: list[Hashable] = []
142 for p1, p2 in zip(nk1.parts, nk2.parts, strict=False):
143 if p1 != p2:
144 break
145 parts.append(p1)
146 return NodeKey(tuple(parts))
148 def ancestors(self) -> list["NodeKey"]:
149 """Get all ancestor node keys from root to parent."""
150 result = []
151 x = self
152 while True:
153 result.append(x)
154 if x.is_root:
155 break
156 x = x.parent
157 return result
160def names_to_node_keys(names: Name | Names) -> list[NodeKey]:
161 """Convert names to NodeKey objects."""
162 return [to_nodekey(name) for name in as_iterable(names)]
165def node_keys_to_names(node_keys: Iterable[NodeKey]) -> list[Name]:
166 """Convert NodeKey objects back to names."""
167 return [node_key.name for node_key in node_keys]
170PART = re.compile(r"([^/]*)/?")
173def _parse_nodekey(path_str: str, end: int) -> NodeKey:
174 """Parse a path string into a NodeKey starting from the given position."""
175 parts: list[str] = []
176 parts_append = parts.append
178 while path_str[end : end + 1] == "/":
179 end = end + 1
180 while True:
181 nextchar = path_str[end : end + 1]
182 if nextchar == "":
183 break
184 if nextchar == '"':
185 part, end = json.decoder.scanstring(path_str, end + 1) # type: ignore[attr-defined]
186 parts_append(part)
187 nextchar = path_str[end : end + 1]
188 if nextchar != "" and nextchar != "/":
189 msg = f"Expected end of string or '/' after quoted part, got {nextchar!r} in {path_str!r}"
190 raise ValueError(msg)
191 if nextchar != "":
192 end = end + 1
193 else:
194 chunk = PART.match(path_str, end)
195 # Defensive: PART = ([^/]*)/? matches the empty string, so match() never returns None here.
196 if chunk is None: # pragma: no cover
197 msg = f"Failed to match node key part at position {end} in {path_str!r}"
198 raise ValueError(msg)
199 end = chunk.end()
200 (part,) = chunk.groups()
201 parts_append(part)
203 # Defensive: the loop above only breaks once the string is fully consumed, so end always equals its length.
204 if end != len(path_str): # pragma: no cover
205 msg = f"Unexpected trailing content at position {end} in {path_str!r}"
206 raise ValueError(msg)
208 return NodeKey(tuple(parts))
211def parse_nodekey(path_str: str) -> NodeKey:
212 """Parse a string representation into a NodeKey."""
213 return _parse_nodekey(path_str, 0)
216def to_nodekey(name: Name) -> NodeKey:
217 """Convert a name to a NodeKey object."""
218 if isinstance(name, str):
219 return parse_nodekey(name)
220 elif isinstance(name, NodeKey):
221 return name
222 elif isinstance(name, object):
223 return NodeKey((name,))
224 else: # pragma: no cover
225 msg = f"Unexpected error creating node key for name {name}"
226 raise TypeError(msg)
229def nodekey_join(*names: Name) -> NodeKey:
230 """Join multiple names into a single NodeKey."""
231 return NodeKey.root().join(*names)
234def _match_pattern_recursive(pattern: NodeKey, target: NodeKey, p_idx: int, t_idx: int) -> bool:
235 """Recursively match pattern parts against target parts.
237 Args:
238 pattern: The pattern NodeKey to match against
239 target: The target NodeKey to match
240 p_idx: Current index in pattern parts
241 t_idx: Current index in target parts
243 Returns:
244 bool: True if pattern matches target, False otherwise
245 """
246 if p_idx == len(pattern.parts) and t_idx == len(target.parts):
247 return True
248 if p_idx == len(pattern.parts):
249 return False
250 if t_idx == len(target.parts):
251 return all(p == "**" for p in pattern.parts[p_idx:])
253 if pattern.parts[p_idx] == "**":
254 return _match_pattern_recursive(pattern, target, p_idx + 1, t_idx) or _match_pattern_recursive(
255 pattern, target, p_idx, t_idx + 1
256 )
257 elif pattern.parts[p_idx] == "*":
258 return _match_pattern_recursive(pattern, target, p_idx + 1, t_idx + 1)
259 else:
260 if pattern.parts[p_idx] == target.parts[t_idx]:
261 return _match_pattern_recursive(pattern, target, p_idx + 1, t_idx + 1)
262 return False
265def is_pattern(nodekey: NodeKey) -> bool:
266 """Check if a node key contains wildcard patterns."""
267 return any(isinstance(part, str) and ("*" in part or "**" in part) for part in nodekey.parts)
270def match_pattern(pattern: NodeKey, target: NodeKey) -> bool:
271 """Match a pattern against a target NodeKey.
273 Supports wildcards:
274 * - matches exactly one part
275 ** - matches zero or more parts
277 Args:
278 pattern: The pattern to match against
279 target: The target to match
281 Returns:
282 bool: True if pattern matches target, False otherwise
283 """
284 return _match_pattern_recursive(pattern, target, 0, 0)