Coverage for python/pyairflowtester/dependency_intelligence/graph.py: 95%
243 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-19 20:43 +0530
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-19 20:43 +0530
1"""Core dependency graph engine with traversal and analysis algorithms."""
3import hashlib
4import logging
5from collections import deque
6from typing import Any, Dict, List, Optional, Set
8from .cache import TieredCache
9from .models import (
10 DependencyGraph,
11 NodeType,
12)
14logger = logging.getLogger(__name__)
17class DependencyGraphEngine:
18 """Core graph engine with algorithms for traversal and analysis."""
20 def __init__(self, graph: DependencyGraph, cache: Optional[TieredCache] = None):
21 """
22 Args:
23 graph: The dependency graph to analyze.
24 cache: Optional TieredCache (see cache.py) for persisting
25 expensive analyses (cycle detection, strongly connected
26 components) across calls -- and, with an L3 SqliteCache,
27 across separate process invocations, which the in-process
28 `_cycles_cache` below can never do. Cache keys are content
29 hashes of the graph's nodes/edges, so a changed graph
30 naturally misses rather than serving stale results.
31 """
32 self.graph = graph
33 self.cache = cache
34 self._upstream_cache: Dict[str, Set[str]] = {}
35 self._downstream_cache: Dict[str, Set[str]] = {}
36 self._cycles_cache: Optional[List[List[str]]] = None
37 self._cache_valid = True
39 def _graph_content_hash(self) -> str:
40 """Stable hash of the graph's current nodes/edges, used as a cache
41 key prefix so a modified graph never serves another graph's -- or
42 its own earlier, since-changed -- cached analysis results."""
43 node_ids = sorted(self.graph.nodes.keys())
44 edge_pairs = sorted((e.source, e.target) for e in self.graph.edges)
45 digest_input = "|".join(node_ids) + "::" + "|".join(f"{s}>{t}" for s, t in edge_pairs)
46 return hashlib.sha256(digest_input.encode("utf-8")).hexdigest()[:16]
48 def invalidate_cache(self):
49 """Invalidate all caches after graph modification."""
50 self._upstream_cache.clear()
51 self._downstream_cache.clear()
52 self._cycles_cache = None
53 self._cache_valid = False
55 # Traversal algorithms
57 def get_upstream_nodes(self, node_id: str, max_depth: Optional[int] = None) -> List[str]:
58 """
59 Get all upstream nodes (dependencies) of a node using BFS.
61 Args:
62 node_id: The node to analyze
63 max_depth: Optional maximum traversal depth
65 Returns:
66 List of upstream node IDs
67 """
68 if node_id not in self.graph.nodes:
69 return []
71 # Check cache
72 if node_id in self._upstream_cache and max_depth is None:
73 return list(self._upstream_cache[node_id])
75 visited = set()
76 queue = deque([(node_id, 0)])
77 upstream = set()
79 while queue:
80 current_id, depth = queue.popleft()
82 # Check depth limit
83 if max_depth is not None and depth >= max_depth:
84 continue
86 # Get incoming edges (dependencies)
87 for edge in self.graph.get_edges_to(current_id):
88 source_id = edge.source
90 if source_id not in visited: 90 ↛ 87line 90 didn't jump to line 87 because the condition on line 90 was always true
91 visited.add(source_id)
92 upstream.add(source_id)
93 queue.append((source_id, depth + 1))
95 # Cache if no depth limit
96 if max_depth is None:
97 self._upstream_cache[node_id] = upstream
99 return sorted(list(upstream))
101 def get_downstream_nodes(self, node_id: str, max_depth: Optional[int] = None) -> List[str]:
102 """
103 Get all downstream nodes (dependents) of a node using BFS.
105 Args:
106 node_id: The node to analyze
107 max_depth: Optional maximum traversal depth
109 Returns:
110 List of downstream node IDs
111 """
112 if node_id not in self.graph.nodes:
113 return []
115 # Check cache
116 if node_id in self._downstream_cache and max_depth is None:
117 return list(self._downstream_cache[node_id])
119 visited = set()
120 queue = deque([(node_id, 0)])
121 downstream = set()
123 while queue:
124 current_id, depth = queue.popleft()
126 # Check depth limit
127 if max_depth is not None and depth >= max_depth:
128 continue
130 # Get outgoing edges (dependents)
131 for edge in self.graph.get_edges_from(current_id):
132 target_id = edge.target
134 if target_id not in visited: 134 ↛ 131line 134 didn't jump to line 131 because the condition on line 134 was always true
135 visited.add(target_id)
136 downstream.add(target_id)
137 queue.append((target_id, depth + 1))
139 # Cache if no depth limit
140 if max_depth is None:
141 self._downstream_cache[node_id] = downstream
143 return sorted(list(downstream))
145 def get_reach(self, node_id: str) -> Dict[str, int]:
146 """
147 Get reachability from a node with distances.
149 Returns:
150 Dict of {node_id: distance}
151 """
152 if node_id not in self.graph.nodes: 152 ↛ 153line 152 didn't jump to line 153 because the condition on line 152 was never true
153 return {}
155 visited = {node_id: 0}
156 queue = deque([(node_id, 0)])
158 while queue:
159 current_id, distance = queue.popleft()
161 for edge in self.graph.get_edges_from(current_id):
162 target_id = edge.target
164 if target_id not in visited: 164 ↛ 161line 164 didn't jump to line 161 because the condition on line 164 was always true
165 visited[target_id] = distance + 1
166 queue.append((target_id, distance + 1))
168 return visited
170 def get_path(self, source_id: str, target_id: str) -> Optional[List[str]]:
171 """
172 Find shortest path between two nodes using BFS.
174 Returns:
175 List of node IDs from source to target, or None if no path
176 """
177 if source_id not in self.graph.nodes or target_id not in self.graph.nodes: 177 ↛ 178line 177 didn't jump to line 178 because the condition on line 177 was never true
178 return None
180 if source_id == target_id:
181 return [source_id]
183 visited = {source_id}
184 queue = deque([(source_id, [source_id])])
186 while queue:
187 current_id, path = queue.popleft()
189 for edge in self.graph.get_edges_from(current_id):
190 neighbor_id = edge.target
192 if neighbor_id == target_id:
193 return path + [neighbor_id]
195 if neighbor_id not in visited: 195 ↛ 189line 195 didn't jump to line 189 because the condition on line 195 was always true
196 visited.add(neighbor_id)
197 queue.append((neighbor_id, path + [neighbor_id]))
199 return None
201 # Cycle detection
203 def detect_cycles(self) -> List[List[str]]:
204 """
205 Detect all cycles in the dependency graph using DFS.
207 Returns:
208 List of cycles, where each cycle is a list of node IDs
209 """
210 if self._cycles_cache is not None:
211 return self._cycles_cache
213 cache_key = None
214 if self.cache is not None:
215 cache_key = f"cycles:{self._graph_content_hash()}"
216 cached = self.cache.get(cache_key)
217 if cached is not None:
218 self._cycles_cache = cached
219 return cached
221 cycles = []
222 visited = set()
223 rec_stack = set()
224 path_stack = []
226 def dfs(node_id: str):
227 visited.add(node_id)
228 rec_stack.add(node_id)
229 path_stack.append(node_id)
231 for edge in self.graph.get_edges_from(node_id):
232 neighbor_id = edge.target
234 if neighbor_id not in visited:
235 dfs(neighbor_id)
236 elif neighbor_id in rec_stack: 236 ↛ 231line 236 didn't jump to line 231 because the condition on line 236 was always true
237 # Found a cycle
238 cycle_start_idx = path_stack.index(neighbor_id)
239 cycle = path_stack[cycle_start_idx:] + [neighbor_id]
240 cycles.append(cycle)
242 path_stack.pop()
243 rec_stack.discard(node_id)
245 # Run DFS from all unvisited nodes
246 for node_id in self.graph.nodes:
247 if node_id not in visited:
248 dfs(node_id)
250 # Cache result
251 self._cycles_cache = cycles
252 if self.cache is not None and cache_key is not None:
253 self.cache.set(cache_key, cycles, ttl_seconds=600)
254 return cycles
256 def has_cycle(self) -> bool:
257 """Check if graph has any cycles."""
258 return len(self.detect_cycles()) > 0
260 # Orphan detection
262 def detect_orphans(self) -> Dict[str, List[str]]:
263 """
264 Detect orphaned nodes (no incoming/outgoing edges).
266 Returns:
267 Dict with 'sources' (no incoming) and 'sinks' (no outgoing)
268 """
269 sources = [] # No incoming edges
270 sinks = [] # No outgoing edges
272 for node_id, node in self.graph.nodes.items():
273 has_incoming = any(e.target == node_id for e in self.graph.edges)
274 has_outgoing = any(e.source == node_id for e in self.graph.edges)
276 if not has_incoming:
277 sources.append(node_id)
278 if not has_outgoing:
279 sinks.append(node_id)
281 return {
282 "sources": sorted(sources),
283 "sinks": sorted(sinks),
284 "isolated": sorted([n for n in sources if n in sinks]),
285 }
287 def detect_disconnected_components(self) -> List[Set[str]]:
288 """
289 Find disconnected components in the graph.
291 Returns:
292 List of sets, each containing node IDs in one component
293 """
294 visited = set()
295 components = []
297 def dfs(node_id: str, component: Set[str]):
298 visited.add(node_id)
299 component.add(node_id)
301 # Check both incoming and outgoing edges
302 for edge in self.graph.edges:
303 if edge.source == node_id and edge.target not in visited:
304 dfs(edge.target, component)
305 elif edge.target == node_id and edge.source not in visited: 305 ↛ 306line 305 didn't jump to line 306 because the condition on line 305 was never true
306 dfs(edge.source, component)
308 for node_id in self.graph.nodes:
309 if node_id not in visited:
310 component = set()
311 dfs(node_id, component)
312 components.append(component)
314 return components
316 # Advanced analysis
318 def get_critical_path(self) -> List[str]:
319 """
320 Find the longest path in the DAG (critical path).
321 For cyclic graphs, returns longest acyclic path.
322 """
323 if self.has_cycle(): 323 ↛ 324line 323 didn't jump to line 324 because the condition on line 323 was never true
324 logger.warning("Graph has cycles; returning longest acyclic path")
326 max_path = []
327 visited = set()
329 def dfs(node_id: str, path: List[str]) -> List[str]:
330 nonlocal max_path
331 visited.add(node_id)
333 if len(path) > len(max_path):
334 max_path = path[:]
336 for edge in self.graph.get_edges_from(node_id):
337 if edge.target not in visited: 337 ↛ 336line 337 didn't jump to line 336 because the condition on line 337 was always true
338 path.append(edge.target)
339 dfs(edge.target, path)
340 path.pop()
342 visited.discard(node_id)
344 for node_id in self.graph.nodes:
345 path = [node_id]
346 dfs(node_id, path)
348 return max_path
350 def get_strongly_connected_components(self) -> List[Set[str]]:
351 """
352 Find strongly connected components using Tarjan's algorithm.
353 For dependency graphs, identifies circular dependencies.
354 """
355 cache_key = None
356 if self.cache is not None: 356 ↛ 362line 356 didn't jump to line 362 because the condition on line 356 was always true
357 cache_key = f"sccs:{self._graph_content_hash()}"
358 cached = self.cache.get(cache_key)
359 if cached is not None:
360 return [set(scc) for scc in cached]
362 index = 0
363 stack = []
364 indices = {}
365 lowlinks = {}
366 on_stack = set()
367 sccs = []
369 def strongconnect(node_id: str):
370 nonlocal index
371 indices[node_id] = index
372 lowlinks[node_id] = index
373 index += 1
374 stack.append(node_id)
375 on_stack.add(node_id)
377 for edge in self.graph.get_edges_from(node_id):
378 target_id = edge.target
379 if target_id not in indices:
380 strongconnect(target_id)
381 lowlinks[node_id] = min(lowlinks[node_id], lowlinks[target_id])
382 elif target_id in on_stack: 382 ↛ 377line 382 didn't jump to line 377 because the condition on line 382 was always true
383 lowlinks[node_id] = min(lowlinks[node_id], indices[target_id])
385 if lowlinks[node_id] == indices[node_id]:
386 scc = set()
387 while True:
388 node = stack.pop()
389 on_stack.discard(node)
390 scc.add(node)
391 if node == node_id:
392 break
393 sccs.append(scc)
395 for node_id in self.graph.nodes:
396 if node_id not in indices:
397 strongconnect(node_id)
399 if self.cache is not None and cache_key is not None: 399 ↛ 401line 399 didn't jump to line 401 because the condition on line 399 was always true
400 self.cache.set(cache_key, [sorted(scc) for scc in sccs], ttl_seconds=600)
401 return sccs
403 def get_node_centrality(self) -> Dict[str, float]:
404 """
405 Calculate degree centrality for each node.
407 Returns:
408 Dict of {node_id: centrality_score}
409 """
410 max_degree = len(self.graph.nodes) - 1
411 if max_degree == 0: 411 ↛ 412line 411 didn't jump to line 412 because the condition on line 411 was never true
412 return {n: 0.0 for n in self.graph.nodes}
414 centrality = {}
415 for node_id in self.graph.nodes:
416 upstream = len(self.get_upstream_nodes(node_id))
417 downstream = len(self.get_downstream_nodes(node_id))
418 total_degree = upstream + downstream
419 centrality[node_id] = total_degree / (2 * max_degree)
421 return centrality
423 def filter_by_type(self, node_type: NodeType) -> List[str]:
424 """Get all nodes of a specific type."""
425 return [n.id for n in self.graph.nodes.values() if n.type == node_type]
427 def filter_by_owner(self, owner: str) -> List[str]:
428 """Get all nodes owned by a specific owner."""
429 return [n.id for n in self.graph.nodes.values() if n.owner == owner]
431 def get_stats(self) -> Dict[str, Any]:
432 """Get comprehensive graph statistics."""
433 cycles = self.detect_cycles()
434 orphans = self.detect_orphans()
435 components = self.detect_disconnected_components()
436 centrality = self.get_node_centrality()
438 return {
439 "node_count": len(self.graph.nodes),
440 "edge_count": len(self.graph.edges),
441 "cycle_count": len(cycles),
442 "has_cycles": len(cycles) > 0,
443 "component_count": len(components),
444 "is_connected": len(components) <= 1,
445 "orphaned_sources": len(orphans["sources"]),
446 "orphaned_sinks": len(orphans["sinks"]),
447 "orphaned_isolated": len(orphans["isolated"]),
448 "average_degree": (
449 sum(
450 len(self.get_upstream_nodes(n)) + len(self.get_downstream_nodes(n))
451 for n in self.graph.nodes
452 )
453 / len(self.graph.nodes)
454 if self.graph.nodes
455 else 0
456 ),
457 "avg_centrality": sum(centrality.values()) / len(centrality) if centrality else 0,
458 "max_centrality": max(centrality.values()) if centrality else 0,
459 "node_types": self.graph.get_node_count_by_type(),
460 }