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 +15 -0
- tofui/__main__.py +11 -0
- tofui/analyzer.py +543 -0
- tofui/apply_parser.py +412 -0
- tofui/cli.py +2148 -0
- tofui/generator.py +3252 -0
- tofui/parser.py +278 -0
- tofui/publisher.py +361 -0
- tofui-1.5.0.dist-info/METADATA +172 -0
- tofui-1.5.0.dist-info/RECORD +14 -0
- tofui-1.5.0.dist-info/WHEEL +5 -0
- tofui-1.5.0.dist-info/entry_points.txt +2 -0
- tofui-1.5.0.dist-info/licenses/LICENSE +21 -0
- tofui-1.5.0.dist-info/top_level.txt +1 -0
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
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("<", "<")
|
|
530
|
+
.replace(">", ">"))
|
|
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"
|