Coverage for python/pyairflowtester/dependency_intelligence/analyzers.py: 88%
175 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"""Analysis engines for dependency intelligence."""
3import logging
4from datetime import datetime
5from typing import Dict, List, Optional
7from .graph import DependencyGraphEngine
8from .models import (
9 BlastRadiusResult,
10 DependencyGraph,
11 DriftDetectionResult,
12 ImpactResult,
13 Node,
14 NodeSeverity,
15 NodeType,
16 RiskScoreResult,
17)
19logger = logging.getLogger(__name__)
22class ImpactAnalysisEngine:
23 """Analyze impact of changing a node on downstream dependencies."""
25 def __init__(self, graph: DependencyGraph):
26 self.graph = graph
27 self.engine = DependencyGraphEngine(graph)
29 def analyze(self, node_id: str, max_depth: Optional[int] = None) -> ImpactResult:
30 """
31 Analyze impact of changing a node.
33 Args:
34 node_id: Node to analyze
35 max_depth: Maximum traversal depth
37 Returns:
38 ImpactResult with affected nodes and metrics
39 """
40 if node_id not in self.graph.nodes:
41 return ImpactResult(
42 node_id=node_id,
43 impacted_nodes=[],
44 impact_depth=0,
45 impact_score=0.0,
46 )
48 # Get all downstream nodes
49 downstream = self.engine.get_downstream_nodes(node_id, max_depth)
51 # Calculate impact score based on criticality
52 impact_score = self._calculate_impact_score(downstream)
54 # Group by severity and type
55 by_severity = self._group_by_severity(downstream)
56 by_type = self._group_by_type(downstream)
58 # Calculate depth
59 depth = self._calculate_max_depth(node_id, downstream)
61 return ImpactResult(
62 node_id=node_id,
63 impacted_nodes=downstream,
64 impact_depth=depth,
65 impact_score=impact_score,
66 by_severity=by_severity,
67 by_type=by_type,
68 metadata={
69 "total_impacted": len(downstream),
70 "analyzed_at": datetime.utcnow().isoformat(),
71 },
72 )
74 def _calculate_impact_score(self, nodes: List[str]) -> float:
75 """Calculate impact score 0.0-1.0 based on criticality."""
76 if not nodes: 76 ↛ 77line 76 didn't jump to line 77 because the condition on line 76 was never true
77 return 0.0
79 critical_count = sum(
80 1
81 for n in nodes
82 if self.graph.nodes.get(n, Node("", "", NodeType.TASK)).severity
83 == NodeSeverity.CRITICAL
84 )
86 return min(1.0, critical_count / len(nodes))
88 def _group_by_severity(self, nodes: List[str]) -> Dict[NodeSeverity, List[str]]:
89 """Group nodes by severity."""
90 result = {}
91 for severity in NodeSeverity:
92 result[severity] = [
93 n
94 for n in nodes
95 if self.graph.nodes.get(n, Node("", "", NodeType.TASK)).severity == severity
96 ]
97 return {k: v for k, v in result.items() if v}
99 def _group_by_type(self, nodes: List[str]) -> Dict[NodeType, List[str]]:
100 """Group nodes by type."""
101 result = {}
102 for node_type in NodeType:
103 result[node_type] = [
104 n
105 for n in nodes
106 if self.graph.nodes.get(n, Node("", "", NodeType.TASK)).type == node_type
107 ]
108 return {k: v for k, v in result.items() if v}
110 def _calculate_max_depth(self, source: str, nodes: List[str]) -> int:
111 """Calculate maximum depth to any impacted node."""
112 if not nodes: 112 ↛ 113line 112 didn't jump to line 113 because the condition on line 112 was never true
113 return 0
115 max_d = 0
116 for node_id in nodes:
117 path = self.engine.get_path(source, node_id)
118 if path: 118 ↛ 116line 118 didn't jump to line 116 because the condition on line 118 was always true
119 max_d = max(max_d, len(path) - 1)
121 return max_d
124class BlastRadiusEngine:
125 """Analyze blast radius of changes."""
127 def __init__(self, graph: DependencyGraph):
128 self.graph = graph
129 self.engine = DependencyGraphEngine(graph)
131 def analyze(self, change_nodes: List[str]) -> BlastRadiusResult:
132 """
133 Analyze blast radius of multiple node changes.
135 Args:
136 change_nodes: List of nodes that changed
138 Returns:
139 BlastRadiusResult with affected nodes and risk assessment
140 """
141 # Get all affected nodes (union of downstream)
142 affected = set()
143 for node_id in change_nodes:
144 affected.update(self.engine.get_downstream_nodes(node_id))
146 # Calculate severity distribution
147 severity_dist = self._calculate_severity_distribution(list(affected))
149 # Determine risk level
150 risk_level = self._assess_risk_level(list(affected), severity_dist)
152 # Check if deployment is safe
153 deployable = self._is_deployable(list(affected), risk_level)
155 # Calculate blast depth
156 blast_depth = self._calculate_blast_depth(change_nodes, list(affected))
158 return BlastRadiusResult(
159 change_nodes=change_nodes,
160 affected_nodes=list(affected),
161 blast_radius=len(affected),
162 blast_depth=blast_depth,
163 severity_distribution=severity_dist,
164 risk_level=risk_level,
165 deployable=deployable,
166 metadata={
167 "analyzed_at": datetime.utcnow().isoformat(),
168 "change_count": len(change_nodes),
169 },
170 )
172 def _calculate_severity_distribution(self, nodes: List[str]) -> Dict[NodeSeverity, int]:
173 """Calculate distribution of node severities."""
174 dist = {s: 0 for s in NodeSeverity}
175 for node_id in nodes:
176 node = self.graph.nodes.get(node_id)
177 if node: 177 ↛ 175line 177 didn't jump to line 175 because the condition on line 177 was always true
178 dist[node.severity] += 1
179 return {k: v for k, v in dist.items() if v > 0}
181 def _assess_risk_level(self, nodes: List[str], severity_dist: Dict[NodeSeverity, int]) -> str:
182 """Assess risk level based on affected nodes."""
183 if severity_dist.get(NodeSeverity.CRITICAL, 0) > 0: 183 ↛ 184line 183 didn't jump to line 184 because the condition on line 183 was never true
184 return "critical"
185 elif severity_dist.get(NodeSeverity.HIGH, 0) > 3: 185 ↛ 186line 185 didn't jump to line 186 because the condition on line 185 was never true
186 return "high"
187 elif severity_dist.get(NodeSeverity.MEDIUM, 0) > 10: 187 ↛ 188line 187 didn't jump to line 188 because the condition on line 187 was never true
188 return "medium"
189 else:
190 return "low"
192 def _is_deployable(self, nodes: List[str], risk_level: str) -> bool:
193 """Determine if changes are safe to deploy."""
194 return risk_level not in ("critical", "high")
196 def _calculate_blast_depth(self, change_nodes: List[str], affected: List[str]) -> int:
197 """Calculate maximum depth of blast."""
198 if not affected:
199 return 0
201 max_depth = 0
202 for change_node in change_nodes:
203 for affected_node in affected:
204 path = self.engine.get_path(change_node, affected_node)
205 if path: 205 ↛ 203line 205 didn't jump to line 203 because the condition on line 205 was always true
206 max_depth = max(max_depth, len(path) - 1)
208 return max_depth
211class RiskScoringEngine:
212 """Calculate risk scores for nodes based on criticality and dependencies."""
214 def __init__(self, graph: DependencyGraph):
215 self.graph = graph
216 self.engine = DependencyGraphEngine(graph)
218 def score_node(self, node_id: str) -> RiskScoreResult:
219 """
220 Calculate risk score for a node.
222 Risk factors:
223 - Severity (0-2 points)
224 - Downstream count (0-3 points)
225 - Upstream count (0-2 points)
226 - Critical dependents (0-3 points)
228 Returns:
229 RiskScoreResult with score and breakdown
230 """
231 node = self.graph.nodes.get(node_id)
232 if not node: 232 ↛ 233line 232 didn't jump to line 233 because the condition on line 232 was never true
233 return RiskScoreResult(
234 node_id=node_id,
235 risk_score=0.0,
236 components={},
237 factors=[],
238 severity=NodeSeverity.LOW,
239 )
241 components = {}
242 factors = []
244 # Severity score (0-2)
245 severity_scores = {
246 NodeSeverity.CRITICAL: 2.0,
247 NodeSeverity.HIGH: 1.5,
248 NodeSeverity.MEDIUM: 1.0,
249 NodeSeverity.LOW: 0.5,
250 }
251 components["severity"] = severity_scores.get(node.severity, 0.0)
252 if node.severity in (NodeSeverity.CRITICAL, NodeSeverity.HIGH):
253 factors.append(f"High severity: {node.severity.value}")
255 # Downstream impact (0-3)
256 downstream = self.engine.get_downstream_nodes(node_id)
257 downstream_score = min(3.0, len(downstream) / 10.0)
258 components["downstream"] = downstream_score
259 if len(downstream) > 50: 259 ↛ 260line 259 didn't jump to line 260 because the condition on line 259 was never true
260 factors.append(f"High downstream impact: {len(downstream)} nodes")
262 # Upstream criticality (0-2)
263 upstream = self.engine.get_upstream_nodes(node_id)
264 critical_upstream = sum(
265 1
266 for u in upstream
267 if self.graph.nodes.get(u, Node("", "", NodeType.TASK)).severity
268 == NodeSeverity.CRITICAL
269 )
270 upstream_score = min(2.0, critical_upstream / 5.0)
271 components["upstream"] = upstream_score
272 if critical_upstream > 0:
273 factors.append(f"Depends on {critical_upstream} critical upstream nodes")
275 # Critical dependents (0-3)
276 critical_downstream = sum(
277 1
278 for d in downstream
279 if self.graph.nodes.get(d, Node("", "", NodeType.TASK)).severity
280 == NodeSeverity.CRITICAL
281 )
282 critical_score = min(3.0, critical_downstream / 5.0)
283 components["critical_dependents"] = critical_score
284 if critical_downstream > 0: 284 ↛ 285line 284 didn't jump to line 285 because the condition on line 284 was never true
285 factors.append(f"Has {critical_downstream} critical downstream nodes")
287 # Cycle involvement
288 cycles = self.engine.detect_cycles()
289 in_cycle = any(node_id in cycle for cycle in cycles)
290 cycle_score = 2.0 if in_cycle else 0.0
291 components["in_cycle"] = cycle_score
292 if in_cycle: 292 ↛ 293line 292 didn't jump to line 293 because the condition on line 292 was never true
293 factors.append("Node is part of a circular dependency")
295 # Calculate total score (0-10)
296 total_score = sum(components.values())
297 risk_score = min(10.0, total_score)
299 return RiskScoreResult(
300 node_id=node_id,
301 risk_score=risk_score,
302 components=components,
303 factors=factors,
304 severity=self._score_to_severity(risk_score),
305 metadata={
306 "calculated_at": datetime.utcnow().isoformat(),
307 "upstream_nodes": len(upstream),
308 "downstream_nodes": len(downstream),
309 },
310 )
312 def _score_to_severity(self, score: float) -> NodeSeverity:
313 """Convert risk score to severity level."""
314 if score >= 8.0: 314 ↛ 315line 314 didn't jump to line 315 because the condition on line 314 was never true
315 return NodeSeverity.CRITICAL
316 elif score >= 6.0: 316 ↛ 317line 316 didn't jump to line 317 because the condition on line 316 was never true
317 return NodeSeverity.HIGH
318 elif score >= 3.0: 318 ↛ 319line 318 didn't jump to line 319 because the condition on line 318 was never true
319 return NodeSeverity.MEDIUM
320 else:
321 return NodeSeverity.LOW
323 def score_all_nodes(self) -> Dict[str, RiskScoreResult]:
324 """Calculate risk scores for all nodes."""
325 return {node_id: self.score_node(node_id) for node_id in self.graph.nodes}
328class DriftDetectionEngine:
329 """Detect changes in dependencies (drift detection)."""
331 def __init__(
332 self,
333 current_graph: DependencyGraph,
334 previous_graph: Optional[DependencyGraph] = None,
335 ):
336 self.current_graph = current_graph
337 self.previous_graph = previous_graph or DependencyGraph()
339 def detect_drift(self) -> DriftDetectionResult:
340 """
341 Detect dependency drift between current and previous graphs.
343 Returns:
344 DriftDetectionResult with detected changes
345 """
346 drifts = []
347 affected_nodes = set()
348 details = []
350 # Detect added nodes
351 added_nodes = set(self.current_graph.nodes.keys()) - set(self.previous_graph.nodes.keys())
352 if added_nodes:
353 drifts.append(
354 {
355 "type": "nodes_added",
356 "count": len(added_nodes),
357 "nodes": list(added_nodes),
358 }
359 )
360 affected_nodes.update(added_nodes)
361 details.append(f"Added {len(added_nodes)} new nodes")
363 # Detect removed nodes
364 removed_nodes = set(self.previous_graph.nodes.keys()) - set(self.current_graph.nodes.keys())
365 if removed_nodes:
366 drifts.append(
367 {
368 "type": "nodes_removed",
369 "count": len(removed_nodes),
370 "nodes": list(removed_nodes),
371 }
372 )
373 affected_nodes.update(removed_nodes)
374 details.append(f"Removed {len(removed_nodes)} nodes")
376 # Detect added edges
377 prev_edges = {(e.source, e.target) for e in self.previous_graph.edges}
378 curr_edges = {(e.source, e.target) for e in self.current_graph.edges}
379 added_edges = curr_edges - prev_edges
381 if added_edges:
382 drifts.append(
383 {
384 "type": "edges_added",
385 "count": len(added_edges),
386 "edges": [{"source": s, "target": t} for s, t in sorted(added_edges)],
387 }
388 )
389 for source, target in added_edges:
390 affected_nodes.add(source)
391 affected_nodes.add(target)
392 details.append(f"Added {len(added_edges)} new dependencies")
394 # Detect removed edges
395 removed_edges = prev_edges - curr_edges
396 if removed_edges:
397 drifts.append(
398 {
399 "type": "edges_removed",
400 "count": len(removed_edges),
401 "edges": [{"source": s, "target": t} for s, t in sorted(removed_edges)],
402 }
403 )
404 for source, target in removed_edges:
405 affected_nodes.add(source)
406 affected_nodes.add(target)
407 details.append(f"Removed {len(removed_edges)} dependencies")
409 # Determine severity based on drift
410 drift_count = len(drifts)
411 if drift_count == 0:
412 severity = NodeSeverity.LOW
413 elif added_edges or removed_edges:
414 # Edge changes are more significant than node changes
415 severity = NodeSeverity.HIGH
416 elif removed_nodes: 416 ↛ 418line 416 didn't jump to line 418 because the condition on line 416 was never true
417 # Removed nodes are concerning
418 severity = NodeSeverity.MEDIUM
419 else:
420 severity = NodeSeverity.LOW
422 return DriftDetectionResult(
423 detected_drifts=drifts,
424 drift_count=drift_count,
425 affected_nodes=list(affected_nodes),
426 severity=severity,
427 details=details,
428 metadata={
429 "detected_at": datetime.utcnow().isoformat(),
430 "previous_nodes": len(self.previous_graph.nodes),
431 "current_nodes": len(self.current_graph.nodes),
432 "previous_edges": len(self.previous_graph.edges),
433 "current_edges": len(self.current_graph.edges),
434 },
435 )