tofui 1.5.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.
tofui/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """
2
+ tofUI - Beautiful Infrastructure Plans
3
+
4
+ A Python package for generating beautiful, interactive HTML reports from openTofu and terraform JSON plans.
5
+ """
6
+
7
+ __version__ = "1.5.0"
8
+ __author__ = "tofUI"
9
+ __description__ = "Beautiful OpenTofu and Terraform Infrastructure Plans"
10
+
11
+ from .parser import TerraformPlanParser
12
+ from .generator import HTMLGenerator
13
+ from .analyzer import PlanAnalyzer
14
+
15
+ __all__ = ['TerraformPlanParser', 'HTMLGenerator', 'PlanAnalyzer']
tofui/__main__.py ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ tofUI __main__.py
4
+
5
+ Entry point for python -m tofui execution.
6
+ """
7
+
8
+ from .cli import main
9
+
10
+ if __name__ == "__main__":
11
+ main()
tofui/analyzer.py ADDED
@@ -0,0 +1,543 @@
1
+ """
2
+ Terraform Plan Analyzer
3
+
4
+ Analyzes parsed terraform plan data to extract meaningful insights and prepare data for HTML generation.
5
+ """
6
+
7
+ from typing import Dict, List, Any, Set, Tuple
8
+ from dataclasses import dataclass
9
+ from collections import defaultdict
10
+ import json
11
+
12
+ from .parser import TerraformPlan, ResourceChange, ActionType
13
+
14
+
15
+ @dataclass
16
+ class PropertyChange:
17
+ """Represents a change to a specific property of a resource"""
18
+ property_path: str
19
+ before_value: Any
20
+ after_value: Any
21
+ is_sensitive: bool = False
22
+ is_computed: bool = False
23
+
24
+ @property
25
+ def is_addition(self) -> bool:
26
+ return self.before_value is None and self.after_value is not None
27
+
28
+ @property
29
+ def is_removal(self) -> bool:
30
+ return self.before_value is not None and self.after_value is None
31
+
32
+ @property
33
+ def is_modification(self) -> bool:
34
+ return self.before_value is not None and self.after_value is not None
35
+
36
+
37
+ @dataclass
38
+ class AnalyzedResourceChange:
39
+ """Enhanced resource change with detailed property analysis"""
40
+ resource_change: ResourceChange
41
+ property_changes: List[PropertyChange]
42
+
43
+ @property
44
+ def address(self) -> str:
45
+ return self.resource_change.address
46
+
47
+ @property
48
+ def type(self) -> str:
49
+ return self.resource_change.type
50
+
51
+ @property
52
+ def action(self) -> ActionType:
53
+ return self.resource_change.action
54
+
55
+ @property
56
+ def has_property_changes(self) -> bool:
57
+ return len(self.property_changes) > 0
58
+
59
+ @property
60
+ def has_dependency_changes(self) -> bool:
61
+ """Check if this resource is being changed due to dependencies"""
62
+ return len(self.resource_change.replace_paths) > 0
63
+
64
+ @property
65
+ def dependency_reason(self) -> str:
66
+ """Get a human-readable explanation of dependency-driven changes"""
67
+ if not self.has_dependency_changes:
68
+ return ""
69
+
70
+ # Extract the first element from each replace_path
71
+ paths = []
72
+ for path in self.resource_change.replace_paths:
73
+ if path: # Make sure the path is not empty
74
+ paths.append(path[0])
75
+ else:
76
+ paths.append("unknown")
77
+
78
+ if len(paths) == 1:
79
+ return f"Recreated due to dependency change in: {paths[0]}"
80
+ else:
81
+ return f"Recreated due to dependency changes in: {', '.join(paths)}"
82
+
83
+
84
+ @dataclass
85
+ class ResourceGroup:
86
+ """Group of resources of the same type"""
87
+ resource_type: str
88
+ changes: List[AnalyzedResourceChange]
89
+
90
+ @property
91
+ def count(self) -> int:
92
+ return len(self.changes)
93
+
94
+ @property
95
+ def action_counts(self) -> Dict[ActionType, int]:
96
+ counts = defaultdict(int)
97
+ for change in self.changes:
98
+ counts[change.action] += 1
99
+ return dict(counts)
100
+
101
+
102
+ @dataclass
103
+ class PlanAnalysis:
104
+ """Complete analysis of a terraform plan"""
105
+ plan: TerraformPlan
106
+ resource_groups: List[ResourceGroup]
107
+ all_property_names: Set[str]
108
+ action_counts: Dict[ActionType, int]
109
+
110
+ @property
111
+ def has_changes(self) -> bool:
112
+ return self.plan.summary.has_changes
113
+
114
+ @property
115
+ def total_resources(self) -> int:
116
+ return len(self.plan.resource_changes)
117
+
118
+
119
+ class PlanAnalyzer:
120
+ """Analyzes terraform plans to extract meaningful insights"""
121
+
122
+ def __init__(self):
123
+ self._max_depth = 10 # Prevent infinite recursion in nested objects
124
+
125
+ def analyze(self, plan: TerraformPlan) -> PlanAnalysis:
126
+ """Analyze a terraform plan and return detailed insights"""
127
+
128
+ # Filter out read operations and no-op operations - only show resources with actual changes
129
+ filtered_changes = [
130
+ rc for rc in plan.resource_changes
131
+ if rc.action not in [ActionType.READ, ActionType.NO_OP]
132
+ ]
133
+
134
+ # Analyze each resource change in detail
135
+ analyzed_changes = []
136
+ all_property_names = set()
137
+
138
+ for resource_change in filtered_changes:
139
+ analyzed_change = self._analyze_resource_change(resource_change)
140
+ analyzed_changes.append(analyzed_change)
141
+
142
+ # Collect all property names for filtering UI
143
+ for prop_change in analyzed_change.property_changes:
144
+ all_property_names.add(prop_change.property_path.split('.')[0])
145
+
146
+ # Group resources by type
147
+ resource_groups = self._group_resources_by_type(analyzed_changes)
148
+
149
+ # Calculate action counts
150
+ action_counts = self._calculate_action_counts(analyzed_changes)
151
+
152
+ return PlanAnalysis(
153
+ plan=plan,
154
+ resource_groups=resource_groups,
155
+ all_property_names=all_property_names,
156
+ action_counts=action_counts
157
+ )
158
+
159
+ def _analyze_resource_change(self, resource_change: ResourceChange) -> AnalyzedResourceChange:
160
+ """Analyze a single resource change to extract property-level changes"""
161
+
162
+ if resource_change.action == ActionType.CREATE:
163
+ # For creates, all 'after' values are additions
164
+ property_changes = self._extract_properties_from_dict(
165
+ resource_change.after or {},
166
+ "",
167
+ {},
168
+ resource_change.after_sensitive or []
169
+ )
170
+ elif resource_change.action == ActionType.DELETE:
171
+ # For deletes, all 'before' values are removals
172
+ property_changes = self._extract_properties_from_dict(
173
+ resource_change.before or {},
174
+ "",
175
+ resource_change.before or {},
176
+ resource_change.before_sensitive or [],
177
+ is_removal=True
178
+ )
179
+ else:
180
+ # For updates, compare before and after
181
+ property_changes = self._compare_objects(
182
+ resource_change.before or {},
183
+ resource_change.after or {},
184
+ "",
185
+ resource_change.before_sensitive or [],
186
+ resource_change.after_sensitive or [],
187
+ resource_change.after_unknown or {}
188
+ )
189
+
190
+ return AnalyzedResourceChange(
191
+ resource_change=resource_change,
192
+ property_changes=property_changes
193
+ )
194
+
195
+ def _extract_properties_from_dict(
196
+ self,
197
+ obj: Dict[str, Any],
198
+ prefix: str,
199
+ before_obj: Dict[str, Any],
200
+ sensitive_structure: Any,
201
+ is_removal: bool = False,
202
+ depth: int = 0
203
+ ) -> List[PropertyChange]:
204
+ """Extract property changes from a dictionary object"""
205
+
206
+ if depth > self._max_depth:
207
+ return []
208
+
209
+ changes = []
210
+
211
+ # Sort keys alphabetically for consistent display order
212
+ for key in sorted(obj.keys()):
213
+ value = obj[key]
214
+ current_path = f"{prefix}.{key}" if prefix else key
215
+
216
+ # Get the sensitive structure for this key
217
+ sensitive_for_key = self._get_sensitive_for_key(sensitive_structure, key)
218
+
219
+ # Check if this specific value is marked as sensitive
220
+ is_sensitive = self._is_value_sensitive(value, sensitive_for_key)
221
+
222
+ # Skip empty values unless they're sensitive (sensitive values should always be shown)
223
+ if not is_sensitive and self._should_skip_empty_value(value):
224
+ continue
225
+
226
+ if isinstance(value, dict) and not is_sensitive:
227
+ # Recursively process nested objects
228
+ before_value = before_obj.get(key, {}) if before_obj else {}
229
+ nested_changes = self._extract_properties_from_dict(
230
+ value,
231
+ current_path,
232
+ before_value if isinstance(before_value, dict) else {},
233
+ sensitive_for_key,
234
+ is_removal,
235
+ depth + 1
236
+ )
237
+ changes.extend(nested_changes)
238
+ else:
239
+ # Create property change for this value
240
+ if is_removal:
241
+ changes.append(PropertyChange(
242
+ property_path=current_path,
243
+ before_value=value,
244
+ after_value=None,
245
+ is_sensitive=is_sensitive
246
+ ))
247
+ else:
248
+ changes.append(PropertyChange(
249
+ property_path=current_path,
250
+ before_value=before_obj.get(key) if before_obj else None,
251
+ after_value=value,
252
+ is_sensitive=is_sensitive
253
+ ))
254
+
255
+ return changes
256
+
257
+ def _get_sensitive_for_key(self, sensitive_structure: Any, key: str) -> Any:
258
+ """
259
+ Extract the sensitive structure for a specific key.
260
+
261
+ Terraform's sensitive_values structure mirrors the actual values structure.
262
+ """
263
+ if not sensitive_structure:
264
+ return None
265
+
266
+ if isinstance(sensitive_structure, dict):
267
+ return sensitive_structure.get(key)
268
+ elif isinstance(sensitive_structure, list):
269
+ # For lists, we can't map by key, so return None
270
+ return None
271
+ else:
272
+ return None
273
+
274
+ def _should_skip_empty_value(self, value: Any) -> bool:
275
+ """
276
+ Check if a value should be skipped because it's empty/null.
277
+
278
+ Skip display of:
279
+ - Empty arrays: []
280
+ - Empty objects: {}
281
+ - Null/None values
282
+ - Empty strings (after JSON formatting)
283
+ """
284
+ if value is None:
285
+ return True
286
+ if isinstance(value, list) and len(value) == 0:
287
+ return True
288
+ if isinstance(value, dict) and len(value) == 0:
289
+ return True
290
+ if isinstance(value, str) and value.strip() == "":
291
+ return True
292
+ return False
293
+
294
+ def _is_value_sensitive(self, value: Any, sensitive_structure: Any) -> bool:
295
+ """
296
+ Check if a specific value should be marked as sensitive based on Terraform's structure.
297
+
298
+ The key insight: Terraform's sensitive_values contains the STRUCTURE but only
299
+ marks actual sensitive leaf values as True. Empty objects/arrays mean the
300
+ structure exists but contains no sensitive values.
301
+ """
302
+ if not sensitive_structure:
303
+ return False
304
+
305
+ # If the sensitive structure is explicitly True, then this value is sensitive
306
+ if sensitive_structure is True:
307
+ return True
308
+
309
+ # If it's an empty dict {}, empty list [], or any other structure,
310
+ # it means the container exists but the values inside are NOT sensitive
311
+ if isinstance(sensitive_structure, (dict, list)):
312
+ if not sensitive_structure: # Empty dict or list
313
+ return False
314
+ # For non-empty structures, we need to check if ANY child is True
315
+ # But for the current level, the container itself is not sensitive
316
+ return False
317
+
318
+ # Any other value (False, None, etc.) means not sensitive
319
+ return False
320
+
321
+ # def _is_path_sensitive(self, path: str, sensitive_paths: List[str]) -> bool:
322
+ # """
323
+ # Legacy method - kept for backward compatibility but should not be used
324
+ # with the new sensitive structure handling.
325
+ # """
326
+ # if not sensitive_paths:
327
+ # return False
328
+
329
+ # # Direct path match
330
+ # if path in sensitive_paths:
331
+ # return True
332
+
333
+ # return False
334
+
335
+ def _compare_objects(
336
+ self,
337
+ before: Dict[str, Any],
338
+ after: Dict[str, Any],
339
+ prefix: str,
340
+ before_sensitive: Any,
341
+ after_sensitive: Any,
342
+ after_unknown: Dict[str, Any] = None, # Add this parameter
343
+ depth: int = 0
344
+ ) -> List[PropertyChange]:
345
+ """Compare two objects and extract the differences"""
346
+
347
+ if depth > self._max_depth:
348
+ return []
349
+
350
+ changes = []
351
+
352
+ # Get all keys from both objects and sort them alphabetically
353
+ all_keys = set(before.keys()) | set(after.keys())
354
+
355
+ for key in sorted(all_keys):
356
+ current_path = f"{prefix}.{key}" if prefix else key
357
+ before_value = before.get(key)
358
+ after_value = after.get(key)
359
+
360
+ # Get sensitive structures for this key from both before and after
361
+ before_sensitive_for_key = self._get_sensitive_for_key(before_sensitive, key)
362
+ after_sensitive_for_key = self._get_sensitive_for_key(after_sensitive, key)
363
+
364
+ # Check if either value is marked as sensitive
365
+ is_sensitive = (
366
+ self._is_value_sensitive(before_value, before_sensitive_for_key)
367
+ or self._is_value_sensitive(after_value, after_sensitive_for_key)
368
+ )
369
+
370
+ # Skip empty values unless they're sensitive or there's an actual change
371
+ if not is_sensitive and before_value == after_value and self._should_skip_empty_value(after_value):
372
+ continue
373
+
374
+ if before_value == after_value:
375
+ continue
376
+
377
+ is_computed = self._is_property_unknown(current_path, after_unknown)
378
+
379
+ if is_computed:
380
+ changes.append(PropertyChange(
381
+ property_path=current_path,
382
+ before_value=before_value,
383
+ after_value=None,
384
+ is_sensitive=is_sensitive,
385
+ is_computed=True
386
+ ))
387
+ continue
388
+
389
+ elif isinstance(before_value, dict) and isinstance(after_value, dict) and not is_sensitive:
390
+ nested_changes = self._compare_objects(
391
+ before_value,
392
+ after_value,
393
+ current_path,
394
+ before_sensitive_for_key,
395
+ after_sensitive_for_key,
396
+ self._get_nested_after_unknown(after_unknown, key), # Add this
397
+ depth + 1
398
+ )
399
+ changes.extend(nested_changes)
400
+ else:
401
+ changes.append(PropertyChange(
402
+ property_path=current_path,
403
+ before_value=before_value,
404
+ after_value=after_value,
405
+ is_sensitive=is_sensitive
406
+ ))
407
+
408
+ return changes
409
+
410
+ def _group_resources_by_type(self, analyzed_changes: List[AnalyzedResourceChange]) -> List[ResourceGroup]:
411
+ """Group analyzed resource changes by resource type"""
412
+ groups = defaultdict(list)
413
+
414
+ for change in analyzed_changes:
415
+ groups[change.type].append(change)
416
+
417
+ # Convert to ResourceGroup objects and sort by type name
418
+ resource_groups = [
419
+ ResourceGroup(resource_type=resource_type, changes=changes)
420
+ for resource_type, changes in groups.items()
421
+ ]
422
+
423
+ resource_groups.sort(key=lambda g: g.resource_type)
424
+ return resource_groups
425
+
426
+ def _is_property_unknown(self, property_path: str, after_unknown: Dict[str, Any]) -> bool:
427
+ """
428
+ Check if a property path is marked as unknown (known after apply) in the after_unknown structure.
429
+
430
+ Args:
431
+ property_path: The full property path (e.g., "tags.Name" or "container_definitions")
432
+ after_unknown: The after_unknown structure from the resource change
433
+
434
+ Returns:
435
+ bool: True if the property will be known after apply
436
+ """
437
+ if not after_unknown:
438
+ return False
439
+
440
+ # Check for direct path match
441
+ if after_unknown.get(property_path) is True:
442
+ return True
443
+
444
+ # Check for parent path match (for nested properties)
445
+ path_parts = property_path.split('.')
446
+ for i in range(len(path_parts)):
447
+ parent_path = '.'.join(path_parts[:i+1])
448
+ if after_unknown.get(parent_path) is True:
449
+ return True
450
+
451
+ return False
452
+
453
+ def _get_nested_after_unknown(self, after_unknown: Dict[str, Any], key: str) -> Dict[str, Any]:
454
+ """
455
+ Get the nested after_unknown structure for a specific key.
456
+
457
+ Args:
458
+ after_unknown: The parent after_unknown structure
459
+ key: The key to look up
460
+
461
+ Returns:
462
+ Dict[str, Any]: The nested after_unknown structure for the key
463
+ """
464
+ if not after_unknown:
465
+ return {}
466
+
467
+ # If the after_unknown structure contains nested objects for this key, return it
468
+ nested = after_unknown.get(key, {})
469
+ if isinstance(nested, dict):
470
+ return nested
471
+
472
+ # Otherwise return empty dict
473
+ return {}
474
+
475
+ def _calculate_action_counts(self, analyzed_changes: List[AnalyzedResourceChange]) -> Dict[ActionType, int]:
476
+ """Calculate counts of each action type"""
477
+ counts = defaultdict(int)
478
+
479
+ for change in analyzed_changes:
480
+ counts[change.action] += 1
481
+
482
+ return dict(counts)
483
+
484
+ def format_value_for_display(self, value: Any) -> tuple[str, str]:
485
+ """
486
+ Format a value for display in the HTML report.
487
+
488
+ Returns:
489
+ tuple: (formatted_value, display_mode)
490
+ - formatted_value: The formatted string to display
491
+ - display_mode: 'simple', 'long_simple', 'complex', or 'empty'
492
+ """
493
+ # Treat true empties as empty
494
+ if value is None or value == "":
495
+ return "", "empty"
496
+
497
+ # Native containers
498
+ if isinstance(value, (dict, list)):
499
+ if not value: # {} or []
500
+ return "", "empty"
501
+ json_str = json.dumps(value, indent=2, ensure_ascii=False, default=str)
502
+ return json_str, "complex"
503
+
504
+ # Strings (may contain JSON)
505
+ if isinstance(value, str):
506
+ s = value.strip()
507
+
508
+ # String forms of empties / null
509
+ if s in ("", "{}", "[]", "null", "None"):
510
+ return "", "empty"
511
+
512
+ # Try to parse JSON-in-strings
513
+ if (s.startswith("{") and s.endswith("}")) or (s.startswith("[") and s.endswith("]")):
514
+ try:
515
+ parsed = json.loads(s)
516
+ # If parsed to empty container or null → empty
517
+ if parsed is None:
518
+ return "", "empty"
519
+ if isinstance(parsed, (dict, list)) and not parsed:
520
+ return "", "empty"
521
+ if isinstance(parsed, (dict, list)):
522
+ return json.dumps(parsed, indent=2, ensure_ascii=False), "complex"
523
+ except Exception:
524
+ pass # not actually JSON—fall through
525
+
526
+ # Non-JSON strings: normal handling
527
+ escaped = (value
528
+ .replace("&", "&")
529
+ .replace("<", "&lt;")
530
+ .replace(">", "&gt;"))
531
+ if "\n" in escaped:
532
+ return escaped, "complex"
533
+ if len(escaped) > 100:
534
+ return escaped, "long_simple"
535
+ return escaped, "simple"
536
+
537
+ # Fallback scalars
538
+ s = str(value)
539
+ if s in ("", "None"):
540
+ return "", "empty"
541
+ if len(s) > 100:
542
+ return s, "long_simple"
543
+ return s, "simple"