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
src/cost/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Cost analysis module for tracking baseline vs non-baseline costs."""
2
+
3
+ from typing import List
4
+
5
+ __all__: List[str] = []
src/cost/analyzer.py ADDED
@@ -0,0 +1,226 @@
1
+ """Cost analyzer for inventory snapshots."""
2
+
3
+ import logging
4
+ from concurrent.futures import ThreadPoolExecutor
5
+ from datetime import datetime, timedelta
6
+ from typing import Any, Dict, Optional, Set
7
+
8
+ from ..models.cost_report import CostBreakdown, CostReport
9
+ from ..models.snapshot import Snapshot
10
+ from .explorer import CostExplorerClient
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class CostAnalyzer:
16
+ """Analyze costs for inventory snapshots."""
17
+
18
+ def __init__(self, cost_explorer: CostExplorerClient):
19
+ """Initialize cost analyzer.
20
+
21
+ Args:
22
+ cost_explorer: Cost Explorer client instance
23
+ """
24
+ self.cost_explorer = cost_explorer
25
+
26
+ def analyze(
27
+ self,
28
+ snapshot: Snapshot,
29
+ start_date: Optional[datetime] = None,
30
+ end_date: Optional[datetime] = None,
31
+ granularity: str = "MONTHLY",
32
+ has_deltas: bool = False,
33
+ delta_report: Optional[Any] = None,
34
+ ) -> CostReport:
35
+ """Analyze costs and separate snapshot resources.
36
+
37
+ This implementation uses a simplified heuristic approach:
38
+ 1. Get total costs by service
39
+ 2. Estimate baseline portion based on resource counts in snapshot
40
+ 3. Remaining costs are attributed to non-baseline resources
41
+
42
+ Note: For precise cost attribution, AWS would need to provide
43
+ per-resource cost data, which Cost Explorer doesn't directly expose.
44
+ This gives a good approximation based on service-level costs.
45
+
46
+ Args:
47
+ snapshot: The baseline snapshot
48
+ start_date: Start date for cost analysis (default: snapshot date)
49
+ end_date: End date for cost analysis (default: today)
50
+ granularity: Cost granularity - DAILY or MONTHLY
51
+
52
+ Returns:
53
+ CostReport with baseline and non-baseline cost breakdown
54
+ """
55
+ # Default date range: from snapshot creation to today
56
+ if not start_date:
57
+ start_date = snapshot.created_at
58
+ # Remove timezone for Cost Explorer API (uses dates only, no time)
59
+ start_date = start_date.replace(tzinfo=None)
60
+
61
+ if not end_date:
62
+ end_date = datetime.now()
63
+
64
+ # Ensure both dates are timezone-naive for comparison
65
+ if hasattr(start_date, "tzinfo") and start_date.tzinfo is not None:
66
+ start_date = start_date.replace(tzinfo=None)
67
+ if hasattr(end_date, "tzinfo") and end_date.tzinfo is not None:
68
+ end_date = end_date.replace(tzinfo=None)
69
+
70
+ # Ensure start_date is before end_date
71
+ # AWS Cost Explorer requires at least 1 day difference
72
+ if start_date >= end_date:
73
+ # If dates are the same or inverted, set end_date to start_date + 1 day
74
+ end_date = start_date + timedelta(days=1)
75
+
76
+ logger.debug(f"Analyzing costs from {start_date.strftime('%Y-%m-%d')} " f"to {end_date.strftime('%Y-%m-%d')}")
77
+
78
+ # Execute data completeness check and cost retrieval in parallel
79
+ with ThreadPoolExecutor(max_workers=2) as executor:
80
+ # Submit both tasks
81
+ completeness_future = executor.submit(self.cost_explorer.check_data_completeness, end_date)
82
+ costs_future = executor.submit(self.cost_explorer.get_costs_by_service, start_date, end_date, granularity)
83
+
84
+ # Wait for both to complete
85
+ is_complete, data_through, lag_days = completeness_future.result()
86
+ service_costs = costs_future.result()
87
+
88
+ # If no deltas (no resource changes), ALL costs are baseline
89
+ if not has_deltas:
90
+ logger.debug("No resource changes detected - all costs are from snapshot resources")
91
+ baseline_costs = service_costs.copy()
92
+ non_baseline_costs: Dict[str, float] = {}
93
+ baseline_total = sum(baseline_costs.values())
94
+ non_baseline_total = 0.0
95
+ total_cost = baseline_total
96
+ baseline_pct = 100.0
97
+ non_baseline_pct = 0.0
98
+ else:
99
+ # There are deltas - we can't accurately split costs without per-resource pricing
100
+ # Show total only with a note that we can't split accurately
101
+ logger.debug("Resource changes detected - showing total costs only")
102
+ baseline_costs = service_costs.copy()
103
+ non_baseline_costs = {}
104
+ baseline_total = sum(baseline_costs.values())
105
+ non_baseline_total = 0.0
106
+ total_cost = baseline_total
107
+ baseline_pct = 100.0
108
+ non_baseline_pct = 0.0
109
+ # Note: We could enhance this in the future to track specific resource costs
110
+ # For now, we show total and list the delta resources separately
111
+
112
+ # Create cost breakdowns
113
+ baseline_breakdown = CostBreakdown(
114
+ total=baseline_total,
115
+ by_service=baseline_costs,
116
+ percentage=baseline_pct,
117
+ )
118
+
119
+ non_baseline_breakdown = CostBreakdown(
120
+ total=non_baseline_total,
121
+ by_service=non_baseline_costs,
122
+ percentage=non_baseline_pct,
123
+ )
124
+
125
+ # Create cost report
126
+ report = CostReport(
127
+ generated_at=datetime.now(),
128
+ baseline_snapshot_name=snapshot.name,
129
+ period_start=start_date,
130
+ period_end=end_date,
131
+ baseline_costs=baseline_breakdown,
132
+ non_baseline_costs=non_baseline_breakdown,
133
+ total_cost=total_cost,
134
+ data_complete=is_complete,
135
+ data_through=data_through,
136
+ lag_days=lag_days,
137
+ )
138
+
139
+ logger.info(
140
+ f"Cost analysis complete: Baseline=${baseline_total:.2f}, "
141
+ f"Non-baseline=${non_baseline_total:.2f}, Total=${total_cost:.2f}"
142
+ )
143
+
144
+ return report
145
+
146
+ def _get_baseline_service_mapping(self, snapshot: Snapshot) -> Set[str]:
147
+ """Get set of AWS service names that have baseline resources.
148
+
149
+ Maps our resource types (e.g., 'AWS::EC2::Instance') to Cost Explorer
150
+ service names (e.g., 'Amazon Elastic Compute Cloud - Compute').
151
+
152
+ Args:
153
+ snapshot: Baseline snapshot
154
+
155
+ Returns:
156
+ Set of AWS service names from Cost Explorer
157
+ """
158
+ # Mapping from our resource types to Cost Explorer service names
159
+ service_name_map = {
160
+ "AWS::EC2::Instance": "Amazon Elastic Compute Cloud - Compute",
161
+ "AWS::EC2::Volume": "Amazon Elastic Compute Cloud - Compute",
162
+ "AWS::EC2::VPC": "Amazon Elastic Compute Cloud - Compute",
163
+ "AWS::EC2::SecurityGroup": "Amazon Elastic Compute Cloud - Compute",
164
+ "AWS::EC2::Subnet": "Amazon Elastic Compute Cloud - Compute",
165
+ "AWS::EC2::VPCEndpoint::Interface": "Amazon Elastic Compute Cloud - Compute",
166
+ "AWS::EC2::VPCEndpoint::Gateway": "Amazon Elastic Compute Cloud - Compute",
167
+ "AWS::Lambda::Function": "AWS Lambda",
168
+ "AWS::Lambda::LayerVersion": "AWS Lambda",
169
+ "AWS::S3::Bucket": "Amazon Simple Storage Service",
170
+ "AWS::RDS::DBInstance": "Amazon Relational Database Service",
171
+ "AWS::RDS::DBCluster": "Amazon Relational Database Service",
172
+ "AWS::IAM::Role": "AWS Identity and Access Management",
173
+ "AWS::IAM::User": "AWS Identity and Access Management",
174
+ "AWS::IAM::Policy": "AWS Identity and Access Management",
175
+ "AWS::IAM::Group": "AWS Identity and Access Management",
176
+ "AWS::CloudWatch::Alarm": "Amazon CloudWatch",
177
+ "AWS::CloudWatch::CompositeAlarm": "Amazon CloudWatch",
178
+ "AWS::Logs::LogGroup": "Amazon CloudWatch",
179
+ "AWS::SNS::Topic": "Amazon Simple Notification Service",
180
+ "AWS::SQS::Queue": "Amazon Simple Queue Service",
181
+ "AWS::DynamoDB::Table": "Amazon DynamoDB",
182
+ "AWS::ElasticLoadBalancing::LoadBalancer": "Elastic Load Balancing",
183
+ "AWS::ElasticLoadBalancingV2::LoadBalancer::Application": "Elastic Load Balancing",
184
+ "AWS::ElasticLoadBalancingV2::LoadBalancer::Network": "Elastic Load Balancing",
185
+ "AWS::ElasticLoadBalancingV2::LoadBalancer::Gateway": "Elastic Load Balancing",
186
+ "AWS::CloudFormation::Stack": "AWS CloudFormation",
187
+ "AWS::ApiGateway::RestApi": "Amazon API Gateway",
188
+ "AWS::ApiGatewayV2::Api::HTTP": "Amazon API Gateway",
189
+ "AWS::ApiGatewayV2::Api::WebSocket": "Amazon API Gateway",
190
+ "AWS::Events::EventBus": "Amazon EventBridge",
191
+ "AWS::Events::Rule": "Amazon EventBridge",
192
+ "AWS::SecretsManager::Secret": "AWS Secrets Manager",
193
+ "AWS::KMS::Key": "AWS Key Management Service",
194
+ "AWS::SSM::Parameter": "AWS Systems Manager",
195
+ "AWS::SSM::Document": "AWS Systems Manager",
196
+ "AWS::Route53::HostedZone": "Amazon Route 53",
197
+ "AWS::ECS::Cluster": "Amazon EC2 Container Service",
198
+ "AWS::ECS::Service": "Amazon EC2 Container Service",
199
+ "AWS::ECS::TaskDefinition": "Amazon EC2 Container Service",
200
+ "AWS::StepFunctions::StateMachine": "AWS Step Functions",
201
+ "AWS::WAFv2::WebACL::Regional": "AWS WAF",
202
+ "AWS::WAFv2::WebACL::CloudFront": "AWS WAF",
203
+ "AWS::EKS::Cluster": "Amazon Elastic Kubernetes Service",
204
+ "AWS::EKS::Nodegroup": "Amazon Elastic Kubernetes Service",
205
+ "AWS::EKS::FargateProfile": "Amazon Elastic Kubernetes Service",
206
+ "AWS::CodePipeline::Pipeline": "AWS CodePipeline",
207
+ "AWS::CodeBuild::Project": "AWS CodeBuild",
208
+ "AWS::Backup::BackupPlan": "AWS Backup",
209
+ "AWS::Backup::BackupVault": "AWS Backup",
210
+ }
211
+
212
+ baseline_services = set()
213
+
214
+ # Get unique resource types from snapshot
215
+ resource_types = set()
216
+ for resource in snapshot.resources:
217
+ resource_types.add(resource.resource_type)
218
+
219
+ # Map to Cost Explorer service names
220
+ for resource_type in resource_types:
221
+ if resource_type in service_name_map:
222
+ baseline_services.add(service_name_map[resource_type])
223
+
224
+ logger.debug(f"Baseline services: {baseline_services}")
225
+
226
+ return baseline_services
src/cost/explorer.py ADDED
@@ -0,0 +1,209 @@
1
+ """Cost Explorer integration for retrieving cost data."""
2
+
3
+ import logging
4
+ from datetime import datetime, timedelta
5
+ from typing import Dict, List, Optional, Tuple
6
+
7
+ import boto3
8
+ from botocore.exceptions import ClientError
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class CostExplorerClient:
14
+ """Wrapper for AWS Cost Explorer API."""
15
+
16
+ def __init__(self, profile_name: Optional[str] = None):
17
+ """Initialize Cost Explorer client.
18
+
19
+ Args:
20
+ profile_name: AWS profile name (optional)
21
+ """
22
+ if profile_name:
23
+ session = boto3.Session(profile_name=profile_name)
24
+ self.client = session.client("ce", region_name="us-east-1") # Cost Explorer is global
25
+ else:
26
+ self.client = boto3.client("ce", region_name="us-east-1")
27
+
28
+ def get_cost_and_usage(
29
+ self,
30
+ start_date: datetime,
31
+ end_date: datetime,
32
+ granularity: str = "MONTHLY",
33
+ metrics: Optional[List[str]] = None,
34
+ group_by: Optional[List[Dict[str, str]]] = None,
35
+ filter_expression: Optional[Dict] = None,
36
+ ) -> Dict:
37
+ """Get cost and usage data from Cost Explorer.
38
+
39
+ Args:
40
+ start_date: Start date for cost data (inclusive)
41
+ end_date: End date for cost data (exclusive)
42
+ granularity: Time granularity - DAILY or MONTHLY
43
+ metrics: Cost metrics to retrieve (default: UnblendedCost)
44
+ group_by: Dimensions to group by (e.g., SERVICE, REGION)
45
+ filter_expression: Filter to apply to cost data
46
+
47
+ Returns:
48
+ Cost and usage data from Cost Explorer API
49
+
50
+ Raises:
51
+ CostExplorerError: If Cost Explorer is not enabled or API call fails
52
+ """
53
+ if metrics is None:
54
+ metrics = ["UnblendedCost"]
55
+
56
+ try:
57
+ params = {
58
+ "TimePeriod": {
59
+ "Start": start_date.strftime("%Y-%m-%d"),
60
+ "End": end_date.strftime("%Y-%m-%d"),
61
+ },
62
+ "Granularity": granularity,
63
+ "Metrics": metrics,
64
+ }
65
+
66
+ if group_by:
67
+ params["GroupBy"] = group_by # type: ignore[assignment]
68
+
69
+ if filter_expression:
70
+ params["Filter"] = filter_expression
71
+
72
+ logger.info(
73
+ f"Retrieving cost data from {start_date.strftime('%Y-%m-%d')} " f"to {end_date.strftime('%Y-%m-%d')}"
74
+ )
75
+
76
+ response = self.client.get_cost_and_usage(**params)
77
+
78
+ logger.info(f"Retrieved {len(response.get('ResultsByTime', []))} time periods")
79
+
80
+ return response # type: ignore[return-value]
81
+
82
+ except ClientError as e:
83
+ error_code = e.response.get("Error", {}).get("Code", "Unknown")
84
+
85
+ if error_code == "AccessDeniedException":
86
+ raise CostExplorerError(
87
+ "Access denied to Cost Explorer. Ensure your IAM user/role has the "
88
+ "'ce:GetCostAndUsage' permission."
89
+ )
90
+ elif error_code == "DataUnavailableException":
91
+ raise CostExplorerError(
92
+ "Cost data is not yet available for the specified time period. "
93
+ "Cost Explorer data typically has a 24-48 hour delay."
94
+ )
95
+ else:
96
+ raise CostExplorerError(f"Cost Explorer API error: {e}")
97
+
98
+ except Exception as e:
99
+ logger.error(f"Unexpected error retrieving cost data: {e}")
100
+ raise CostExplorerError(f"Failed to retrieve cost data: {e}")
101
+
102
+ def get_costs_by_service(
103
+ self,
104
+ start_date: datetime,
105
+ end_date: datetime,
106
+ granularity: str = "MONTHLY",
107
+ ) -> Dict[str, float]:
108
+ """Get total costs grouped by AWS service.
109
+
110
+ Args:
111
+ start_date: Start date for cost data
112
+ end_date: End date for cost data
113
+ granularity: Time granularity
114
+
115
+ Returns:
116
+ Dictionary mapping service name to total cost
117
+ """
118
+ response = self.get_cost_and_usage(
119
+ start_date=start_date,
120
+ end_date=end_date,
121
+ granularity=granularity,
122
+ group_by=[{"Type": "DIMENSION", "Key": "SERVICE"}],
123
+ )
124
+
125
+ service_costs: Dict[str, float] = {}
126
+
127
+ for time_period in response.get("ResultsByTime", []):
128
+ for group in time_period.get("Groups", []):
129
+ service_name = group["Keys"][0]
130
+ cost = float(group["Metrics"]["UnblendedCost"]["Amount"])
131
+
132
+ if service_name in service_costs:
133
+ service_costs[service_name] += cost
134
+ else:
135
+ service_costs[service_name] = cost
136
+
137
+ return service_costs
138
+
139
+ def get_total_cost(
140
+ self,
141
+ start_date: datetime,
142
+ end_date: datetime,
143
+ ) -> float:
144
+ """Get total cost for the specified period.
145
+
146
+ Args:
147
+ start_date: Start date for cost data
148
+ end_date: End date for cost data
149
+
150
+ Returns:
151
+ Total cost amount
152
+ """
153
+ response = self.get_cost_and_usage(
154
+ start_date=start_date,
155
+ end_date=end_date,
156
+ granularity="MONTHLY",
157
+ )
158
+
159
+ total_cost = 0.0
160
+
161
+ for time_period in response.get("ResultsByTime", []):
162
+ cost = float(time_period["Total"]["UnblendedCost"]["Amount"])
163
+ total_cost += cost
164
+
165
+ return total_cost
166
+
167
+ def check_data_completeness(
168
+ self,
169
+ end_date: datetime,
170
+ ) -> Tuple[bool, Optional[datetime], int]:
171
+ """Check if cost data is complete up to the specified date.
172
+
173
+ Cost Explorer typically has a 24-48 hour delay in data availability.
174
+
175
+ Args:
176
+ end_date: The date to check data completeness for
177
+
178
+ Returns:
179
+ Tuple of (is_complete, data_available_through, lag_days)
180
+ """
181
+ # Cost Explorer data typically lags 1-2 days
182
+ today = datetime.now().date()
183
+ end_date_only = end_date.date()
184
+
185
+ # Calculate lag
186
+ lag_days = (today - end_date_only).days
187
+
188
+ # Data is considered incomplete if less than 2 days old
189
+ is_complete = lag_days >= 2
190
+
191
+ # Estimate data available through date
192
+ if lag_days < 2:
193
+ data_available_through = datetime.combine(today - timedelta(days=2), datetime.min.time())
194
+ else:
195
+ data_available_through = end_date
196
+
197
+ logger.info(
198
+ f"Cost data completeness: {'Complete' if is_complete else 'Incomplete'}, "
199
+ f"available through {data_available_through.strftime('%Y-%m-%d')}, "
200
+ f"lag: {lag_days} days"
201
+ )
202
+
203
+ return is_complete, data_available_through, lag_days
204
+
205
+
206
+ class CostExplorerError(Exception):
207
+ """Exception raised for Cost Explorer errors."""
208
+
209
+ pass
src/cost/reporter.py ADDED
@@ -0,0 +1,237 @@
1
+ """Cost 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.cost_report import CostReport
10
+
11
+
12
+ class CostReporter:
13
+ """Format and display cost reports."""
14
+
15
+ def __init__(self, console: Optional[Console] = None):
16
+ """Initialize cost 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: CostReport, show_services: bool = True, has_deltas: bool = False) -> None:
24
+ """Display cost report to console.
25
+
26
+ Args:
27
+ report: CostReport to display
28
+ show_services: Whether to show service-level breakdown
29
+ has_deltas: Whether there are resource changes (deltas)
30
+ """
31
+ # Header
32
+ self.console.print()
33
+ self.console.print(
34
+ Panel(
35
+ f"[bold]Cost Analysis Report[/bold]\n"
36
+ f"Snapshot: {report.baseline_snapshot_name}\n"
37
+ f"Period: {report.period_start.strftime('%Y-%m-%d')} to {report.period_end.strftime('%Y-%m-%d')}\n"
38
+ f"Generated: {report.generated_at.strftime('%Y-%m-%d %H:%M:%S UTC')}",
39
+ style="cyan",
40
+ )
41
+ )
42
+ self.console.print()
43
+
44
+ # Data completeness warning
45
+ if not report.data_complete and report.data_through:
46
+ self.console.print(
47
+ f"⚠️ [yellow]Note: Cost data has {report.lag_days} day lag. "
48
+ f"Data available through {report.data_through.strftime('%Y-%m-%d')}[/yellow]\n"
49
+ )
50
+
51
+ # If no deltas, show simplified view
52
+ if not has_deltas:
53
+ self.console.print(
54
+ "✓ [green]No resource changes detected - all costs are from snapshot resources[/green]\n"
55
+ )
56
+ self._display_snapshot_costs(report)
57
+ else:
58
+ # Summary table with baseline/non-baseline split
59
+ self._display_summary(report)
60
+
61
+ # Service breakdown
62
+ if show_services and report.baseline_costs.by_service:
63
+ self.console.print()
64
+ self._display_service_breakdown(report, has_deltas)
65
+
66
+ def _display_snapshot_costs(self, report: CostReport) -> None:
67
+ """Display snapshot costs (no splitting since there are no changes)."""
68
+ table = Table(title="Snapshot Costs", show_header=True, header_style="bold cyan")
69
+ table.add_column("Total Cost", justify="right", style="bold green", width=20)
70
+ table.add_row(f"${report.baseline_costs.total:,.2f}")
71
+ self.console.print(table)
72
+
73
+ def _display_summary(self, report: CostReport) -> None:
74
+ """Display cost summary."""
75
+ table = Table(title="Cost Summary", show_header=True, header_style="bold magenta")
76
+ table.add_column("Category", style="cyan", width=25)
77
+ table.add_column("Amount (USD)", justify="right", style="green", width=15)
78
+ table.add_column("Percentage", justify="right", width=12)
79
+ table.add_column("Visual", width=30)
80
+
81
+ # Baseline costs
82
+ baseline_bar = self._create_progress_bar(report.baseline_percentage, color="blue")
83
+ table.add_row(
84
+ '💰 Baseline ("Dial Tone")',
85
+ f"${report.baseline_costs.total:,.2f}",
86
+ f"{report.baseline_percentage:.1f}%",
87
+ baseline_bar,
88
+ )
89
+
90
+ # Non-baseline costs
91
+ non_baseline_bar = self._create_progress_bar(report.non_baseline_percentage, color="yellow")
92
+ table.add_row(
93
+ "📊 Non-Baseline (Projects)",
94
+ f"${report.non_baseline_costs.total:,.2f}",
95
+ f"{report.non_baseline_percentage:.1f}%",
96
+ non_baseline_bar,
97
+ )
98
+
99
+ # Separator
100
+ table.add_row("━" * 25, "━" * 15, "━" * 12, "━" * 30, style="dim")
101
+
102
+ # Total
103
+ table.add_row("[bold]Total", f"[bold]${report.total_cost:,.2f}", "[bold]100.0%", "")
104
+
105
+ self.console.print(table)
106
+
107
+ def _display_service_breakdown(self, report: CostReport, has_deltas: bool = False) -> None:
108
+ """Display service-level cost breakdown."""
109
+ # Get top services
110
+ top_baseline = report.get_top_services(limit=10, baseline=True)
111
+
112
+ if top_baseline:
113
+ title = "Costs by Service" if not has_deltas else "Top Baseline Services"
114
+ self.console.print(f"[bold cyan]{title}:[/bold cyan]")
115
+ baseline_table = Table(show_header=True, box=None, padding=(0, 2))
116
+ baseline_table.add_column("Service", style="white")
117
+ baseline_table.add_column("Cost", justify="right", style="green")
118
+ baseline_table.add_column("% of Total", justify="right", style="dim")
119
+
120
+ for service, cost in top_baseline.items():
121
+ pct = (cost / report.baseline_costs.total * 100) if report.baseline_costs.total > 0 else 0
122
+ baseline_table.add_row(self._shorten_service_name(service), f"${cost:,.2f}", f"{pct:.1f}%")
123
+
124
+ self.console.print(baseline_table)
125
+ self.console.print()
126
+
127
+ # Only show non-baseline section if there are actual deltas
128
+ if has_deltas:
129
+ top_non_baseline = report.get_top_services(limit=5, baseline=False)
130
+ if top_non_baseline:
131
+ self.console.print("[bold yellow]Top Non-Baseline Services:[/bold yellow]")
132
+ non_baseline_table = Table(show_header=True, box=None, padding=(0, 2))
133
+ non_baseline_table.add_column("Service", style="white")
134
+ non_baseline_table.add_column("Cost", justify="right", style="green")
135
+ non_baseline_table.add_column("% of Non-Baseline", justify="right", style="dim")
136
+
137
+ for service, cost in top_non_baseline.items():
138
+ pct = (cost / report.non_baseline_costs.total * 100) if report.non_baseline_costs.total > 0 else 0
139
+ non_baseline_table.add_row(self._shorten_service_name(service), f"${cost:,.2f}", f"{pct:.1f}%")
140
+
141
+ self.console.print(non_baseline_table)
142
+
143
+ def _create_progress_bar(self, percentage: float, color: str = "green") -> str:
144
+ """Create a text-based progress bar.
145
+
146
+ Args:
147
+ percentage: Percentage value (0-100)
148
+ color: Color for the bar
149
+
150
+ Returns:
151
+ Formatted progress bar string
152
+ """
153
+ width = 20
154
+ filled = int((percentage / 100) * width)
155
+ bar = "█" * filled + "░" * (width - filled)
156
+ return f"[{color}]{bar}[/{color}]"
157
+
158
+ def _shorten_service_name(self, service_name: str) -> str:
159
+ """Shorten AWS service names for display.
160
+
161
+ Args:
162
+ service_name: Full AWS service name
163
+
164
+ Returns:
165
+ Shortened service name
166
+ """
167
+ # Common abbreviations
168
+ replacements = {
169
+ "Amazon Elastic Compute Cloud - Compute": "EC2",
170
+ "Amazon Simple Storage Service": "S3",
171
+ "AWS Lambda": "Lambda",
172
+ "Amazon Relational Database Service": "RDS",
173
+ "AWS Identity and Access Management": "IAM",
174
+ "Amazon Virtual Private Cloud": "VPC",
175
+ "Amazon CloudWatch": "CloudWatch",
176
+ "Amazon Simple Notification Service": "SNS",
177
+ "Amazon Simple Queue Service": "SQS",
178
+ "Amazon DynamoDB": "DynamoDB",
179
+ }
180
+
181
+ return replacements.get(service_name, service_name)
182
+
183
+ def export_json(self, report: CostReport, filepath: str) -> None:
184
+ """Export cost report to JSON file.
185
+
186
+ Args:
187
+ report: CostReport to export
188
+ filepath: Destination file path
189
+ """
190
+ from ..utils.export import export_to_json
191
+
192
+ export_to_json(report.to_dict(), filepath)
193
+ self.console.print(f"[green]✓ Cost report exported to {filepath}[/green]")
194
+
195
+ def export_csv(self, report: CostReport, filepath: str) -> None:
196
+ """Export cost report to CSV file.
197
+
198
+ Args:
199
+ report: CostReport to export
200
+ filepath: Destination file path
201
+ """
202
+ from ..utils.export import export_to_csv
203
+
204
+ # Flatten into rows - one row per service
205
+ rows = []
206
+
207
+ # Baseline services
208
+ for service, cost in report.baseline_costs.by_service.items():
209
+ pct = (cost / report.baseline_costs.total * 100) if report.baseline_costs.total > 0 else 0
210
+ rows.append(
211
+ {
212
+ "category": "baseline",
213
+ "service": service,
214
+ "cost": cost,
215
+ "percentage_of_category": pct,
216
+ "percentage_of_total": (cost / report.total_cost * 100) if report.total_cost > 0 else 0,
217
+ }
218
+ )
219
+
220
+ # Non-baseline services
221
+ for service, cost in report.non_baseline_costs.by_service.items():
222
+ pct = (cost / report.non_baseline_costs.total * 100) if report.non_baseline_costs.total > 0 else 0
223
+ rows.append(
224
+ {
225
+ "category": "non_baseline",
226
+ "service": service,
227
+ "cost": cost,
228
+ "percentage_of_category": pct,
229
+ "percentage_of_total": (cost / report.total_cost * 100) if report.total_cost > 0 else 0,
230
+ }
231
+ )
232
+
233
+ if rows:
234
+ export_to_csv(rows, filepath)
235
+ self.console.print(f"[green]✓ Cost report exported to {filepath}[/green]")
236
+ else:
237
+ self.console.print("[yellow]⚠ No cost data to export[/yellow]")
src/delta/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Delta tracking module for comparing snapshots to current state."""
2
+
3
+ from typing import List
4
+
5
+ __all__: List[str] = []