aws-inventory-manager 0.2.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.

Potentially problematic release.


This version of aws-inventory-manager might be problematic. Click here for more details.

Files changed (65) hide show
  1. aws_inventory_manager-0.2.0.dist-info/METADATA +508 -0
  2. aws_inventory_manager-0.2.0.dist-info/RECORD +65 -0
  3. aws_inventory_manager-0.2.0.dist-info/WHEEL +5 -0
  4. aws_inventory_manager-0.2.0.dist-info/entry_points.txt +2 -0
  5. aws_inventory_manager-0.2.0.dist-info/licenses/LICENSE +21 -0
  6. aws_inventory_manager-0.2.0.dist-info/top_level.txt +1 -0
  7. src/__init__.py +3 -0
  8. src/aws/__init__.py +11 -0
  9. src/aws/client.py +128 -0
  10. src/aws/credentials.py +191 -0
  11. src/aws/rate_limiter.py +177 -0
  12. src/cli/__init__.py +5 -0
  13. src/cli/config.py +130 -0
  14. src/cli/main.py +1450 -0
  15. src/cost/__init__.py +5 -0
  16. src/cost/analyzer.py +226 -0
  17. src/cost/explorer.py +209 -0
  18. src/cost/reporter.py +237 -0
  19. src/delta/__init__.py +5 -0
  20. src/delta/calculator.py +180 -0
  21. src/delta/reporter.py +225 -0
  22. src/models/__init__.py +17 -0
  23. src/models/cost_report.py +87 -0
  24. src/models/delta_report.py +111 -0
  25. src/models/inventory.py +124 -0
  26. src/models/resource.py +99 -0
  27. src/models/snapshot.py +108 -0
  28. src/snapshot/__init__.py +6 -0
  29. src/snapshot/capturer.py +347 -0
  30. src/snapshot/filter.py +245 -0
  31. src/snapshot/inventory_storage.py +264 -0
  32. src/snapshot/resource_collectors/__init__.py +5 -0
  33. src/snapshot/resource_collectors/apigateway.py +140 -0
  34. src/snapshot/resource_collectors/backup.py +136 -0
  35. src/snapshot/resource_collectors/base.py +81 -0
  36. src/snapshot/resource_collectors/cloudformation.py +55 -0
  37. src/snapshot/resource_collectors/cloudwatch.py +109 -0
  38. src/snapshot/resource_collectors/codebuild.py +69 -0
  39. src/snapshot/resource_collectors/codepipeline.py +82 -0
  40. src/snapshot/resource_collectors/dynamodb.py +65 -0
  41. src/snapshot/resource_collectors/ec2.py +240 -0
  42. src/snapshot/resource_collectors/ecs.py +215 -0
  43. src/snapshot/resource_collectors/eks.py +200 -0
  44. src/snapshot/resource_collectors/elb.py +126 -0
  45. src/snapshot/resource_collectors/eventbridge.py +156 -0
  46. src/snapshot/resource_collectors/iam.py +188 -0
  47. src/snapshot/resource_collectors/kms.py +111 -0
  48. src/snapshot/resource_collectors/lambda_func.py +112 -0
  49. src/snapshot/resource_collectors/rds.py +109 -0
  50. src/snapshot/resource_collectors/route53.py +86 -0
  51. src/snapshot/resource_collectors/s3.py +105 -0
  52. src/snapshot/resource_collectors/secretsmanager.py +70 -0
  53. src/snapshot/resource_collectors/sns.py +68 -0
  54. src/snapshot/resource_collectors/sqs.py +72 -0
  55. src/snapshot/resource_collectors/ssm.py +160 -0
  56. src/snapshot/resource_collectors/stepfunctions.py +74 -0
  57. src/snapshot/resource_collectors/vpcendpoints.py +79 -0
  58. src/snapshot/resource_collectors/waf.py +159 -0
  59. src/snapshot/storage.py +259 -0
  60. src/utils/__init__.py +12 -0
  61. src/utils/export.py +87 -0
  62. src/utils/hash.py +60 -0
  63. src/utils/logging.py +63 -0
  64. src/utils/paths.py +51 -0
  65. src/utils/progress.py +41 -0
@@ -0,0 +1,180 @@
1
+ """Delta calculator for comparing snapshots."""
2
+
3
+ import logging
4
+ from datetime import datetime, timezone
5
+ from typing import List, Optional
6
+
7
+ from ..models.delta_report import DeltaReport, ResourceChange
8
+ from ..models.resource import Resource
9
+ from ..models.snapshot import Snapshot
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class DeltaCalculator:
15
+ """Calculate differences between two snapshots."""
16
+
17
+ def __init__(self, reference_snapshot: Snapshot, current_snapshot: Snapshot):
18
+ """Initialize delta calculator.
19
+
20
+ Args:
21
+ reference_snapshot: The reference snapshot to compare against
22
+ current_snapshot: The current snapshot
23
+ """
24
+ self.reference = reference_snapshot
25
+ self.current = current_snapshot
26
+
27
+ # Index resources by ARN for fast lookup
28
+ self.reference_index = {r.arn: r for r in reference_snapshot.resources}
29
+ self.current_index = {r.arn: r for r in current_snapshot.resources}
30
+
31
+ def calculate(
32
+ self,
33
+ resource_type_filter: Optional[List[str]] = None,
34
+ region_filter: Optional[List[str]] = None,
35
+ ) -> DeltaReport:
36
+ """Calculate delta between reference and current snapshots.
37
+
38
+ Args:
39
+ resource_type_filter: Optional list of resource types to include
40
+ region_filter: Optional list of regions to include
41
+
42
+ Returns:
43
+ DeltaReport with added, deleted, and modified resources
44
+ """
45
+ logger.info("Calculating delta between reference and current snapshots")
46
+
47
+ added_resources = []
48
+ deleted_resources = []
49
+ modified_resources = []
50
+
51
+ reference_arns = set(self.reference_index.keys())
52
+ current_arns = set(self.current_index.keys())
53
+
54
+ # Find added resources (in current but not in reference)
55
+ added_arns = current_arns - reference_arns
56
+ for arn in added_arns:
57
+ resource = self.current_index[arn]
58
+ if self._matches_filters(resource, resource_type_filter, region_filter):
59
+ added_resources.append(resource)
60
+
61
+ # Find deleted resources (in reference but not in current)
62
+ deleted_arns = reference_arns - current_arns
63
+ for arn in deleted_arns:
64
+ resource = self.reference_index[arn]
65
+ if self._matches_filters(resource, resource_type_filter, region_filter):
66
+ deleted_resources.append(resource)
67
+
68
+ # Find modified resources (in both but with different config)
69
+ common_arns = reference_arns & current_arns
70
+ for arn in common_arns:
71
+ reference_resource = self.reference_index[arn]
72
+ current_resource = self.current_index[arn]
73
+
74
+ if self._matches_filters(current_resource, resource_type_filter, region_filter):
75
+ # Compare config hashes to detect modifications
76
+ if reference_resource.config_hash != current_resource.config_hash:
77
+ change = ResourceChange(
78
+ resource=current_resource,
79
+ baseline_resource=reference_resource,
80
+ change_type="modified",
81
+ old_config_hash=reference_resource.config_hash,
82
+ new_config_hash=current_resource.config_hash,
83
+ )
84
+ modified_resources.append(change)
85
+
86
+ # Create delta report
87
+ report = DeltaReport(
88
+ generated_at=datetime.now(timezone.utc),
89
+ baseline_snapshot_name=self.reference.name,
90
+ current_snapshot_name=self.current.name,
91
+ added_resources=added_resources,
92
+ deleted_resources=deleted_resources,
93
+ modified_resources=modified_resources,
94
+ baseline_resource_count=len(self.reference.resources),
95
+ current_resource_count=len(self.current.resources),
96
+ )
97
+
98
+ logger.info(
99
+ f"Delta calculated: {len(added_resources)} added, "
100
+ f"{len(deleted_resources)} deleted, {len(modified_resources)} modified"
101
+ )
102
+
103
+ return report
104
+
105
+ def _matches_filters(
106
+ self,
107
+ resource: Resource,
108
+ resource_type_filter: Optional[List[str]],
109
+ region_filter: Optional[List[str]],
110
+ ) -> bool:
111
+ """Check if resource matches the specified filters.
112
+
113
+ Args:
114
+ resource: Resource to check
115
+ resource_type_filter: Optional list of resource types to include
116
+ region_filter: Optional list of regions to include
117
+
118
+ Returns:
119
+ True if resource matches all filters
120
+ """
121
+ # Check resource type filter
122
+ if resource_type_filter:
123
+ if resource.resource_type not in resource_type_filter:
124
+ return False
125
+
126
+ # Check region filter
127
+ if region_filter:
128
+ if resource.region not in region_filter:
129
+ return False
130
+
131
+ return True
132
+
133
+
134
+ def compare_to_current_state(
135
+ reference_snapshot: Snapshot,
136
+ profile_name: Optional[str] = None,
137
+ regions: Optional[List[str]] = None,
138
+ resource_type_filter: Optional[List[str]] = None,
139
+ region_filter: Optional[List[str]] = None,
140
+ ) -> DeltaReport:
141
+ """Compare reference snapshot to current AWS state.
142
+
143
+ This is a convenience function that captures current state and calculates delta.
144
+
145
+ Args:
146
+ reference_snapshot: The reference snapshot to compare against
147
+ profile_name: AWS profile name (optional)
148
+ regions: Regions to scan (defaults to reference snapshot regions)
149
+ resource_type_filter: Optional list of resource types to include in delta
150
+ region_filter: Optional list of regions to include in delta
151
+
152
+ Returns:
153
+ DeltaReport with changes
154
+ """
155
+ from ..aws.credentials import get_account_id
156
+ from ..snapshot.capturer import create_snapshot
157
+
158
+ # Use reference snapshot regions if not specified
159
+ if not regions:
160
+ regions = reference_snapshot.regions
161
+
162
+ # Get account ID
163
+ account_id = get_account_id(profile_name)
164
+
165
+ # Capture current state (don't save it, just use for comparison)
166
+ logger.info("Capturing current AWS state for comparison...")
167
+ current_snapshot = create_snapshot(
168
+ name=f"temp-current-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}",
169
+ regions=regions,
170
+ account_id=account_id,
171
+ profile_name=profile_name,
172
+ set_active=False,
173
+ )
174
+
175
+ # Calculate delta
176
+ calculator = DeltaCalculator(reference_snapshot, current_snapshot)
177
+ return calculator.calculate(
178
+ resource_type_filter=resource_type_filter,
179
+ region_filter=region_filter,
180
+ )
src/delta/reporter.py ADDED
@@ -0,0 +1,225 @@
1
+ """Delta report formatting and display."""
2
+
3
+ from typing import Optional
4
+
5
+ from rich.console import Console
6
+ from rich.panel import Panel
7
+ from rich.table import Table
8
+
9
+ from ..models.delta_report import DeltaReport
10
+
11
+
12
+ class DeltaReporter:
13
+ """Format and display delta reports."""
14
+
15
+ def __init__(self, console: Optional[Console] = None):
16
+ """Initialize delta reporter.
17
+
18
+ Args:
19
+ console: Rich console instance (creates new one if not provided)
20
+ """
21
+ self.console = console or Console()
22
+
23
+ def display(self, report: DeltaReport, show_details: bool = False) -> None:
24
+ """Display delta report to console.
25
+
26
+ Args:
27
+ report: DeltaReport to display
28
+ show_details: Whether to show detailed resource information
29
+ """
30
+ # Header
31
+ self.console.print()
32
+ self.console.print(
33
+ Panel(
34
+ f"[bold]Resource Delta Report[/bold]\n"
35
+ f"Reference Snapshot: {report.baseline_snapshot_name}\n"
36
+ f"Generated: {report.generated_at.strftime('%Y-%m-%d %H:%M:%S UTC')}",
37
+ style="cyan",
38
+ )
39
+ )
40
+ self.console.print()
41
+
42
+ # Check if there are any changes
43
+ if not report.has_changes:
44
+ self.console.print(
45
+ "[green]✓ No changes detected - environment matches reference snapshot[/green]", style="bold"
46
+ )
47
+ self.console.print()
48
+ self.console.print(f"Total resources: {report.baseline_resource_count}")
49
+ return
50
+
51
+ # Summary statistics
52
+ self._display_summary(report)
53
+
54
+ # Group changes by service
55
+ grouped = report.group_by_service()
56
+
57
+ # Display changes by service
58
+ for service_type in sorted(grouped.keys()):
59
+ changes = grouped[service_type]
60
+ if any(changes.values()):
61
+ self._display_service_changes(service_type, changes, show_details)
62
+
63
+ def _display_summary(self, report: DeltaReport) -> None:
64
+ """Display summary statistics."""
65
+ table = Table(title="Summary", show_header=True, header_style="bold magenta")
66
+ table.add_column("Change Type", style="cyan", width=15)
67
+ table.add_column("Count", justify="right", style="yellow", width=10)
68
+
69
+ added_count = len(report.added_resources)
70
+ deleted_count = len(report.deleted_resources)
71
+ modified_count = len(report.modified_resources)
72
+ unchanged_count = report.unchanged_count
73
+
74
+ if added_count > 0:
75
+ table.add_row("➕ Added", f"[green]{added_count}[/green]")
76
+ if deleted_count > 0:
77
+ table.add_row("➖ Deleted", f"[red]{deleted_count}[/red]")
78
+ if modified_count > 0:
79
+ table.add_row("🔄 Modified", f"[yellow]{modified_count}[/yellow]")
80
+ if unchanged_count > 0:
81
+ table.add_row("✓ Unchanged", str(unchanged_count))
82
+
83
+ table.add_row("━" * 15, "━" * 10, style="dim")
84
+ table.add_row("[bold]Total Changes", f"[bold]{report.total_changes}")
85
+
86
+ self.console.print(table)
87
+ self.console.print()
88
+
89
+ def _display_service_changes(self, service_type: str, changes: dict, show_details: bool) -> None:
90
+ """Display changes for a specific service type.
91
+
92
+ Args:
93
+ service_type: AWS resource type (e.g., 'AWS::EC2::Instance')
94
+ changes: Dictionary with 'added', 'deleted', 'modified' lists
95
+ show_details: Whether to show detailed information
96
+ """
97
+ # Count total changes for this service
98
+ total = len(changes["added"]) + len(changes["deleted"]) + len(changes["modified"])
99
+ if total == 0:
100
+ return
101
+
102
+ self.console.print(f"[bold cyan]{service_type}[/bold cyan] ({total} changes)")
103
+
104
+ # Create table for this service
105
+ table = Table(show_header=True, box=None, padding=(0, 2))
106
+ table.add_column("Change", width=8)
107
+ table.add_column("Name", style="white")
108
+ table.add_column("Region", width=15)
109
+ table.add_column("ARN" if show_details else "Tags", style="dim")
110
+
111
+ # Added resources
112
+ for resource in changes["added"]:
113
+ tags_str = self._format_tags(resource.tags)
114
+ arn_or_tags = resource.arn if show_details else tags_str
115
+ table.add_row("[green]➕ Add[/green]", resource.name, resource.region, arn_or_tags)
116
+
117
+ # Deleted resources
118
+ for resource in changes["deleted"]:
119
+ tags_str = self._format_tags(resource.tags)
120
+ arn_or_tags = resource.arn if show_details else tags_str
121
+ table.add_row("[red]➖ Del[/red]", resource.name, resource.region, arn_or_tags)
122
+
123
+ # Modified resources
124
+ for change in changes["modified"]:
125
+ tags_str = self._format_tags(change.resource.tags)
126
+ arn_or_tags = change.resource.arn if show_details else tags_str
127
+ table.add_row("[yellow]🔄 Mod[/yellow]", change.resource.name, change.resource.region, arn_or_tags)
128
+
129
+ self.console.print(table)
130
+ self.console.print()
131
+
132
+ def _format_tags(self, tags: dict) -> str:
133
+ """Format tags dictionary as a string.
134
+
135
+ Args:
136
+ tags: Dictionary of tag key-value pairs
137
+
138
+ Returns:
139
+ Formatted string like "Env=prod, App=web"
140
+ """
141
+ if not tags:
142
+ return "-"
143
+
144
+ # Show up to 3 most important tags
145
+ important_keys = ["Name", "Environment", "Project", "Team", "Application"]
146
+ selected_tags = []
147
+
148
+ # First add important tags if present
149
+ for key in important_keys:
150
+ if key in tags:
151
+ selected_tags.append(f"{key}={tags[key]}")
152
+
153
+ # Add other tags up to limit
154
+ for key, value in tags.items():
155
+ if key not in important_keys and len(selected_tags) < 3:
156
+ selected_tags.append(f"{key}={value}")
157
+
158
+ return ", ".join(selected_tags[:3])
159
+
160
+ def export_json(self, report: DeltaReport, filepath: str) -> None:
161
+ """Export delta report to JSON file.
162
+
163
+ Args:
164
+ report: DeltaReport to export
165
+ filepath: Destination file path
166
+ """
167
+ from ..utils.export import export_to_json
168
+
169
+ export_to_json(report.to_dict(), filepath)
170
+ self.console.print(f"[green]✓ Delta report exported to {filepath}[/green]")
171
+
172
+ def export_csv(self, report: DeltaReport, filepath: str) -> None:
173
+ """Export delta report to CSV file.
174
+
175
+ Args:
176
+ report: DeltaReport to export
177
+ filepath: Destination file path
178
+ """
179
+ from ..utils.export import export_to_csv
180
+
181
+ # Flatten the report into rows
182
+ rows = []
183
+
184
+ for resource in report.added_resources:
185
+ rows.append(
186
+ {
187
+ "change_type": "added",
188
+ "resource_type": resource.resource_type,
189
+ "name": resource.name,
190
+ "arn": resource.arn,
191
+ "region": resource.region,
192
+ "tags": str(resource.tags),
193
+ "created_at": resource.created_at.isoformat() if resource.created_at else None,
194
+ }
195
+ )
196
+
197
+ for resource in report.deleted_resources:
198
+ rows.append(
199
+ {
200
+ "change_type": "deleted",
201
+ "resource_type": resource.resource_type,
202
+ "name": resource.name,
203
+ "arn": resource.arn,
204
+ "region": resource.region,
205
+ "tags": str(resource.tags),
206
+ "created_at": resource.created_at.isoformat() if resource.created_at else None,
207
+ }
208
+ )
209
+
210
+ for change in report.modified_resources:
211
+ rows.append(
212
+ {
213
+ "change_type": "modified",
214
+ "resource_type": change.resource.resource_type,
215
+ "name": change.resource.name,
216
+ "arn": change.resource.arn,
217
+ "region": change.resource.region,
218
+ "tags": str(change.resource.tags),
219
+ "old_hash": change.old_config_hash,
220
+ "new_hash": change.new_config_hash,
221
+ }
222
+ )
223
+
224
+ export_to_csv(rows, filepath)
225
+ self.console.print(f"[green]✓ Delta report exported to {filepath}[/green]")
src/models/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """Data models for AWS Baseline Snapshot tool."""
2
+
3
+ from .cost_report import CostBreakdown, CostReport
4
+ from .delta_report import DeltaReport, ResourceChange
5
+ from .inventory import Inventory
6
+ from .resource import Resource
7
+ from .snapshot import Snapshot
8
+
9
+ __all__ = [
10
+ "Snapshot",
11
+ "Resource",
12
+ "DeltaReport",
13
+ "ResourceChange",
14
+ "CostReport",
15
+ "CostBreakdown",
16
+ "Inventory",
17
+ ]
@@ -0,0 +1,87 @@
1
+ """Cost report models for cost analysis and tracking."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+ from typing import Any, Dict, Optional
6
+
7
+
8
+ @dataclass
9
+ class CostBreakdown:
10
+ """Represents cost breakdown for baseline or non-baseline resources."""
11
+
12
+ total: float
13
+ by_service: Dict[str, float] = field(default_factory=dict)
14
+ percentage: float = 0.0
15
+
16
+ def to_dict(self) -> Dict[str, Any]:
17
+ """Convert to dictionary for serialization."""
18
+ return {
19
+ "total": self.total,
20
+ "by_service": self.by_service,
21
+ "percentage": self.percentage,
22
+ }
23
+
24
+
25
+ @dataclass
26
+ class CostReport:
27
+ """Represents cost analysis separating baseline vs non-baseline costs."""
28
+
29
+ generated_at: datetime
30
+ baseline_snapshot_name: str
31
+ period_start: datetime
32
+ period_end: datetime
33
+ baseline_costs: CostBreakdown
34
+ non_baseline_costs: CostBreakdown
35
+ total_cost: float
36
+ data_complete: bool = True
37
+ data_through: Optional[datetime] = None
38
+ lag_days: int = 0
39
+
40
+ def to_dict(self) -> Dict[str, Any]:
41
+ """Convert to dictionary for serialization."""
42
+ return {
43
+ "generated_at": self.generated_at.isoformat(),
44
+ "baseline_snapshot_name": self.baseline_snapshot_name,
45
+ "period_start": self.period_start.isoformat(),
46
+ "period_end": self.period_end.isoformat(),
47
+ "baseline_costs": self.baseline_costs.to_dict(),
48
+ "non_baseline_costs": self.non_baseline_costs.to_dict(),
49
+ "total_cost": self.total_cost,
50
+ "data_complete": self.data_complete,
51
+ "data_through": self.data_through.isoformat() if self.data_through else None,
52
+ "lag_days": self.lag_days,
53
+ "summary": {
54
+ "baseline_total": self.baseline_costs.total,
55
+ "baseline_percentage": self.baseline_costs.percentage,
56
+ "non_baseline_total": self.non_baseline_costs.total,
57
+ "non_baseline_percentage": self.non_baseline_costs.percentage,
58
+ "total": self.total_cost,
59
+ },
60
+ }
61
+
62
+ @property
63
+ def baseline_percentage(self) -> float:
64
+ """Get baseline cost percentage."""
65
+ return self.baseline_costs.percentage
66
+
67
+ @property
68
+ def non_baseline_percentage(self) -> float:
69
+ """Get non-baseline cost percentage."""
70
+ return self.non_baseline_costs.percentage
71
+
72
+ def get_top_services(self, limit: int = 5, baseline: bool = True) -> Dict[str, float]:
73
+ """Get top N services by cost.
74
+
75
+ Args:
76
+ limit: Number of top services to return
77
+ baseline: If True, return baseline services; if False, non-baseline
78
+
79
+ Returns:
80
+ Dictionary of service name to cost, sorted by cost descending
81
+ """
82
+ services = self.baseline_costs.by_service if baseline else self.non_baseline_costs.by_service
83
+
84
+ # Sort by cost descending
85
+ sorted_services = sorted(services.items(), key=lambda x: x[1], reverse=True)
86
+
87
+ return dict(sorted_services[:limit])
@@ -0,0 +1,111 @@
1
+ """Delta report models for tracking resource changes."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+ from typing import Any, Dict, List, Optional
6
+
7
+
8
+ @dataclass
9
+ class ResourceChange:
10
+ """Represents a modified resource in a delta report."""
11
+
12
+ resource: Any # Current Resource instance
13
+ baseline_resource: Any # Reference Resource instance (keeping field name for compatibility)
14
+ change_type: str # 'modified'
15
+ old_config_hash: str
16
+ new_config_hash: str
17
+ changes_summary: Optional[str] = None
18
+
19
+ def to_dict(self) -> Dict[str, Any]:
20
+ """Convert to dictionary for serialization."""
21
+ return {
22
+ "arn": self.resource.arn,
23
+ "resource_type": self.resource.resource_type,
24
+ "name": self.resource.name,
25
+ "region": self.resource.region,
26
+ "change_type": self.change_type,
27
+ "tags": self.resource.tags,
28
+ "old_config_hash": self.old_config_hash,
29
+ "new_config_hash": self.new_config_hash,
30
+ "changes_summary": self.changes_summary,
31
+ }
32
+
33
+
34
+ @dataclass
35
+ class DeltaReport:
36
+ """Represents differences between two snapshots."""
37
+
38
+ generated_at: datetime
39
+ baseline_snapshot_name: str # Reference snapshot name (keeping field name for compatibility)
40
+ current_snapshot_name: str
41
+ added_resources: List[Any] = field(default_factory=list) # List[Resource]
42
+ deleted_resources: List[Any] = field(default_factory=list) # List[Resource]
43
+ modified_resources: List[ResourceChange] = field(default_factory=list)
44
+ baseline_resource_count: int = 0 # Reference snapshot count (keeping field name for compatibility)
45
+ current_resource_count: int = 0
46
+
47
+ def to_dict(self) -> Dict[str, Any]:
48
+ """Convert to dictionary for serialization."""
49
+ return {
50
+ "generated_at": self.generated_at.isoformat(),
51
+ "baseline_snapshot_name": self.baseline_snapshot_name,
52
+ "current_snapshot_name": self.current_snapshot_name,
53
+ "added_resources": [r.to_dict() for r in self.added_resources],
54
+ "deleted_resources": [r.to_dict() for r in self.deleted_resources],
55
+ "modified_resources": [r.to_dict() for r in self.modified_resources],
56
+ "baseline_resource_count": self.baseline_resource_count,
57
+ "current_resource_count": self.current_resource_count,
58
+ "summary": {
59
+ "added": len(self.added_resources),
60
+ "deleted": len(self.deleted_resources),
61
+ "modified": len(self.modified_resources),
62
+ "unchanged": self.unchanged_count,
63
+ "total_changes": self.total_changes,
64
+ },
65
+ }
66
+
67
+ @property
68
+ def total_changes(self) -> int:
69
+ """Total number of changes detected."""
70
+ return len(self.added_resources) + len(self.deleted_resources) + len(self.modified_resources)
71
+
72
+ @property
73
+ def unchanged_count(self) -> int:
74
+ """Number of unchanged resources."""
75
+ # Resources that existed in reference snapshot and still exist unchanged
76
+ return self.baseline_resource_count - len(self.deleted_resources) - len(self.modified_resources)
77
+
78
+ @property
79
+ def has_changes(self) -> bool:
80
+ """Whether any changes were detected."""
81
+ return self.total_changes > 0
82
+
83
+ def group_by_service(self) -> Dict[str, Dict[str, List]]:
84
+ """Group changes by service type.
85
+
86
+ Returns:
87
+ Dictionary mapping service type to changes dict with 'added', 'deleted', 'modified' lists
88
+ """
89
+ from typing import Any, Dict, List
90
+
91
+ grouped: Dict[str, Dict[str, List[Any]]] = {}
92
+
93
+ for resource in self.added_resources:
94
+ service = resource.resource_type
95
+ if service not in grouped:
96
+ grouped[service] = {"added": [], "deleted": [], "modified": []}
97
+ grouped[service]["added"].append(resource)
98
+
99
+ for resource in self.deleted_resources:
100
+ service = resource.resource_type
101
+ if service not in grouped:
102
+ grouped[service] = {"added": [], "deleted": [], "modified": []}
103
+ grouped[service]["deleted"].append(resource)
104
+
105
+ for change in self.modified_resources:
106
+ service = change.resource.resource_type
107
+ if service not in grouped:
108
+ grouped[service] = {"added": [], "deleted": [], "modified": []}
109
+ grouped[service]["modified"].append(change)
110
+
111
+ return grouped