aws-cost-calculator-cli 1.0.3__py3-none-any.whl → 1.4.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.
@@ -0,0 +1,323 @@
1
+ """
2
+ Drill-down cost analysis module.
3
+ Allows filtering by service, account, and usage type for detailed cost investigation.
4
+ """
5
+ from datetime import datetime, timedelta
6
+ from collections import defaultdict
7
+
8
+
9
+ def get_filtered_costs(ce_client, accounts, time_start, time_end, granularity='DAILY',
10
+ service_filter=None, account_filter=None, usage_type_filter=None):
11
+ """
12
+ Get costs with optional filters, grouped by the next level of detail.
13
+
14
+ Args:
15
+ ce_client: boto3 Cost Explorer client
16
+ accounts: List of account IDs
17
+ time_start: datetime for start
18
+ time_end: datetime for end
19
+ granularity: 'WEEKLY' or 'MONTHLY'
20
+ service_filter: Optional service name to filter
21
+ account_filter: Optional account ID to filter
22
+ usage_type_filter: Optional usage type to filter
23
+
24
+ Returns:
25
+ dict: {dimension_value: total_cost}
26
+ """
27
+ # Build filter
28
+ filters = []
29
+
30
+ # Account filter (either from account_filter or all accounts)
31
+ if account_filter:
32
+ filters.append({
33
+ "Dimensions": {
34
+ "Key": "LINKED_ACCOUNT",
35
+ "Values": [account_filter]
36
+ }
37
+ })
38
+ else:
39
+ filters.append({
40
+ "Dimensions": {
41
+ "Key": "LINKED_ACCOUNT",
42
+ "Values": accounts
43
+ }
44
+ })
45
+
46
+ # Service filter
47
+ if service_filter:
48
+ filters.append({
49
+ "Dimensions": {
50
+ "Key": "SERVICE",
51
+ "Values": [service_filter]
52
+ }
53
+ })
54
+
55
+ # Usage type filter
56
+ if usage_type_filter:
57
+ filters.append({
58
+ "Dimensions": {
59
+ "Key": "USAGE_TYPE",
60
+ "Values": [usage_type_filter]
61
+ }
62
+ })
63
+
64
+ # Determine what to group by (next level of detail)
65
+ if usage_type_filter:
66
+ # Already at usage type level, group by region
67
+ group_by_key = 'REGION'
68
+ elif service_filter and account_filter:
69
+ # Have service and account, show usage types
70
+ group_by_key = 'USAGE_TYPE'
71
+ elif service_filter:
72
+ # Have service, show accounts
73
+ group_by_key = 'LINKED_ACCOUNT'
74
+ elif account_filter:
75
+ # Have account, show services
76
+ group_by_key = 'SERVICE'
77
+ else:
78
+ # No filters, show services (same as trends)
79
+ group_by_key = 'SERVICE'
80
+
81
+ # Build final filter
82
+ if len(filters) > 1:
83
+ final_filter = {"And": filters}
84
+ else:
85
+ final_filter = filters[0]
86
+
87
+ response = ce_client.get_cost_and_usage(
88
+ TimePeriod={
89
+ 'Start': time_start.strftime('%Y-%m-%d'),
90
+ 'End': time_end.strftime('%Y-%m-%d')
91
+ },
92
+ Granularity=granularity,
93
+ Filter=final_filter,
94
+ Metrics=['NetAmortizedCost'],
95
+ GroupBy=[
96
+ {'Type': 'DIMENSION', 'Key': group_by_key}
97
+ ]
98
+ )
99
+
100
+ costs = defaultdict(float)
101
+ for result in response['ResultsByTime']:
102
+ for group in result['Groups']:
103
+ dimension_value = group['Keys'][0]
104
+ cost = float(group['Metrics']['NetAmortizedCost']['Amount'])
105
+ costs[dimension_value] += cost
106
+
107
+ return costs, group_by_key
108
+
109
+
110
+ def compare_periods(prev_costs, curr_costs):
111
+ """
112
+ Compare two periods and find increases/decreases.
113
+
114
+ Returns:
115
+ list of dicts with dimension, prev_cost, curr_cost, change, pct_change
116
+ """
117
+ changes = []
118
+
119
+ # Get all dimensions from both periods
120
+ all_dimensions = set(prev_costs.keys()) | set(curr_costs.keys())
121
+
122
+ for dimension in all_dimensions:
123
+ prev_cost = prev_costs.get(dimension, 0)
124
+ curr_cost = curr_costs.get(dimension, 0)
125
+
126
+ change = curr_cost - prev_cost
127
+ pct_change = (change / prev_cost * 100) if prev_cost > 0 else (100 if curr_cost > 0 else 0)
128
+
129
+ # Only include if change is significant (>$10 and >5%)
130
+ if abs(change) > 10 and abs(pct_change) > 5:
131
+ changes.append({
132
+ 'dimension': dimension,
133
+ 'prev_cost': prev_cost,
134
+ 'curr_cost': curr_cost,
135
+ 'change': change,
136
+ 'pct_change': pct_change
137
+ })
138
+
139
+ return changes
140
+
141
+
142
+ def analyze_drill_down(ce_client, accounts, num_weeks=4, service_filter=None,
143
+ account_filter=None, usage_type_filter=None):
144
+ """
145
+ Analyze cost trends with drill-down filters.
146
+
147
+ Args:
148
+ ce_client: boto3 Cost Explorer client
149
+ accounts: List of account IDs
150
+ num_weeks: Number of weeks to analyze (default: 4)
151
+ service_filter: Optional service name to filter
152
+ account_filter: Optional account ID to filter
153
+ usage_type_filter: Optional usage type to filter
154
+
155
+ Returns:
156
+ dict with weekly comparisons and metadata
157
+ """
158
+ today = datetime.now()
159
+
160
+ # Calculate week boundaries (Monday to Sunday)
161
+ days_since_sunday = (today.weekday() + 1) % 7
162
+ most_recent_sunday = today - timedelta(days=days_since_sunday)
163
+
164
+ weeks = []
165
+ for i in range(num_weeks):
166
+ week_end = most_recent_sunday - timedelta(weeks=i)
167
+ week_start = week_end - timedelta(days=7)
168
+ weeks.append({
169
+ 'start': week_start,
170
+ 'end': week_end,
171
+ 'label': f"Week of {week_start.strftime('%b %d')}"
172
+ })
173
+
174
+ # Reverse so oldest is first
175
+ weeks.reverse()
176
+
177
+ # Get costs for each week
178
+ weekly_costs = []
179
+ group_by_key = None
180
+ for week in weeks:
181
+ costs, group_by_key = get_filtered_costs(
182
+ ce_client, accounts, week['start'], week['end'],
183
+ service_filter=service_filter,
184
+ account_filter=account_filter,
185
+ usage_type_filter=usage_type_filter
186
+ )
187
+ weekly_costs.append({
188
+ 'week': week,
189
+ 'costs': costs
190
+ })
191
+
192
+ # Compare consecutive weeks
193
+ comparisons = []
194
+ for i in range(1, len(weekly_costs)):
195
+ prev = weekly_costs[i-1]
196
+ curr = weekly_costs[i]
197
+
198
+ changes = compare_periods(prev['costs'], curr['costs'])
199
+
200
+ # Sort by absolute change
201
+ changes.sort(key=lambda x: abs(x['change']), reverse=True)
202
+ increases = [c for c in changes if c['change'] > 0][:10]
203
+ decreases = [c for c in changes if c['change'] < 0][:10]
204
+
205
+ comparisons.append({
206
+ 'prev_week': prev['week'],
207
+ 'curr_week': curr['week'],
208
+ 'increases': increases,
209
+ 'decreases': decreases,
210
+ 'total_increase': sum(c['change'] for c in increases),
211
+ 'total_decrease': sum(c['change'] for c in decreases)
212
+ })
213
+
214
+ # Reverse so most recent is first
215
+ comparisons.reverse()
216
+
217
+ return {
218
+ 'weeks': weeks,
219
+ 'comparisons': comparisons,
220
+ 'group_by': group_by_key,
221
+ 'filters': {
222
+ 'service': service_filter,
223
+ 'account': account_filter,
224
+ 'usage_type': usage_type_filter
225
+ }
226
+ }
227
+
228
+
229
+ def format_drill_down_markdown(drill_data):
230
+ """
231
+ Format drill-down data as markdown.
232
+
233
+ Returns:
234
+ str: Markdown formatted report
235
+ """
236
+ lines = []
237
+ lines.append("# AWS Cost Drill-Down Report")
238
+ lines.append(f"**Generated:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
239
+ lines.append("")
240
+
241
+ # Show active filters
242
+ filters = drill_data['filters']
243
+ lines.append("## Filters Applied")
244
+ lines.append("")
245
+ if filters['service']:
246
+ lines.append(f"- **Service:** {filters['service']}")
247
+ if filters['account']:
248
+ lines.append(f"- **Account:** {filters['account']}")
249
+ if filters['usage_type']:
250
+ lines.append(f"- **Usage Type:** {filters['usage_type']}")
251
+ if not any(filters.values()):
252
+ lines.append("- No filters (showing all services)")
253
+ lines.append("")
254
+
255
+ # Show what dimension we're grouping by
256
+ group_by = drill_data['group_by']
257
+ dimension_label = {
258
+ 'SERVICE': 'Service',
259
+ 'LINKED_ACCOUNT': 'Account',
260
+ 'USAGE_TYPE': 'Usage Type',
261
+ 'REGION': 'Region'
262
+ }.get(group_by, group_by)
263
+
264
+ lines.append(f"## Grouped By: {dimension_label}")
265
+ lines.append("")
266
+ lines.append("---")
267
+ lines.append("")
268
+
269
+ for comparison in drill_data['comparisons']:
270
+ prev_week = comparison['prev_week']
271
+ curr_week = comparison['curr_week']
272
+
273
+ lines.append(f"## {prev_week['label']} → {curr_week['label']}")
274
+ lines.append("")
275
+
276
+ # Top increases
277
+ if comparison['increases']:
278
+ lines.append("### 🔴 Top 10 Increases")
279
+ lines.append("")
280
+ lines.append(f"| {dimension_label} | Previous | Current | Change | % |")
281
+ lines.append("|---------|----------|---------|--------|---|")
282
+
283
+ for item in comparison['increases']:
284
+ dimension = item['dimension'][:60]
285
+ prev = f"${item['prev_cost']:,.2f}"
286
+ curr = f"${item['curr_cost']:,.2f}"
287
+ change = f"${item['change']:,.2f}"
288
+ pct = f"{item['pct_change']:+.1f}%"
289
+
290
+ lines.append(f"| {dimension} | {prev} | {curr} | {change} | {pct} |")
291
+
292
+ # Add total row
293
+ total_increase = comparison.get('total_increase', 0)
294
+ lines.append(f"| **TOTAL** | | | **${total_increase:,.2f}** | |")
295
+
296
+ lines.append("")
297
+
298
+ # Top decreases
299
+ if comparison['decreases']:
300
+ lines.append("### 🟢 Top 10 Decreases")
301
+ lines.append("")
302
+ lines.append(f"| {dimension_label} | Previous | Current | Change | % |")
303
+ lines.append("|---------|----------|---------|--------|---|")
304
+
305
+ for item in comparison['decreases']:
306
+ dimension = item['dimension'][:60]
307
+ prev = f"${item['prev_cost']:,.2f}"
308
+ curr = f"${item['curr_cost']:,.2f}"
309
+ change = f"${item['change']:,.2f}"
310
+ pct = f"{item['pct_change']:+.1f}%"
311
+
312
+ lines.append(f"| {dimension} | {prev} | {curr} | {change} | {pct} |")
313
+
314
+ # Add total row
315
+ total_decrease = comparison.get('total_decrease', 0)
316
+ lines.append(f"| **TOTAL** | | | **${total_decrease:,.2f}** | |")
317
+
318
+ lines.append("")
319
+
320
+ lines.append("---")
321
+ lines.append("")
322
+
323
+ return "\n".join(lines)
@@ -0,0 +1,159 @@
1
+ """
2
+ Executor that routes to either API or local execution.
3
+ """
4
+ import boto3
5
+ import click
6
+ from cost_calculator.api_client import is_api_configured, call_lambda_api
7
+
8
+
9
+ def get_credentials_dict(config):
10
+ """
11
+ Extract credentials from config in format needed for API.
12
+
13
+ Returns:
14
+ dict with access_key, secret_key, session_token
15
+ """
16
+ if 'aws_profile' in config:
17
+ # Get temporary credentials from SSO session
18
+ session = boto3.Session(profile_name=config['aws_profile'])
19
+ credentials = session.get_credentials()
20
+ frozen_creds = credentials.get_frozen_credentials()
21
+
22
+ return {
23
+ 'access_key': frozen_creds.access_key,
24
+ 'secret_key': frozen_creds.secret_key,
25
+ 'session_token': frozen_creds.token
26
+ }
27
+ else:
28
+ # Use static credentials
29
+ creds = config['credentials']
30
+ result = {
31
+ 'access_key': creds['aws_access_key_id'],
32
+ 'secret_key': creds['aws_secret_access_key']
33
+ }
34
+ if 'aws_session_token' in creds:
35
+ result['session_token'] = creds['aws_session_token']
36
+ return result
37
+
38
+
39
+ def execute_trends(config, weeks):
40
+ """
41
+ Execute trends analysis via API or locally.
42
+
43
+ Returns:
44
+ dict: trends data
45
+ """
46
+ accounts = config['accounts']
47
+
48
+ if is_api_configured():
49
+ # Use API
50
+ click.echo("Using Lambda API...")
51
+ credentials = get_credentials_dict(config)
52
+ return call_lambda_api('trends', credentials, accounts, weeks=weeks)
53
+ else:
54
+ # Use local execution
55
+ click.echo("Using local execution...")
56
+ from cost_calculator.trends import analyze_trends
57
+
58
+ # Initialize boto3 client
59
+ if 'aws_profile' in config:
60
+ session = boto3.Session(profile_name=config['aws_profile'])
61
+ else:
62
+ creds = config['credentials']
63
+ session_kwargs = {
64
+ 'aws_access_key_id': creds['aws_access_key_id'],
65
+ 'aws_secret_access_key': creds['aws_secret_access_key'],
66
+ 'region_name': creds.get('region', 'us-east-1')
67
+ }
68
+ if 'aws_session_token' in creds:
69
+ session_kwargs['aws_session_token'] = creds['aws_session_token']
70
+ session = boto3.Session(**session_kwargs)
71
+
72
+ ce_client = session.client('ce', region_name='us-east-1')
73
+ return analyze_trends(ce_client, accounts, weeks)
74
+
75
+
76
+ def execute_monthly(config, months):
77
+ """
78
+ Execute monthly analysis via API or locally.
79
+
80
+ Returns:
81
+ dict: monthly data
82
+ """
83
+ accounts = config['accounts']
84
+
85
+ if is_api_configured():
86
+ # Use API
87
+ click.echo("Using Lambda API...")
88
+ credentials = get_credentials_dict(config)
89
+ return call_lambda_api('monthly', credentials, accounts, months=months)
90
+ else:
91
+ # Use local execution
92
+ click.echo("Using local execution...")
93
+ from cost_calculator.monthly import analyze_monthly_trends
94
+
95
+ # Initialize boto3 client
96
+ if 'aws_profile' in config:
97
+ session = boto3.Session(profile_name=config['aws_profile'])
98
+ else:
99
+ creds = config['credentials']
100
+ session_kwargs = {
101
+ 'aws_access_key_id': creds['aws_access_key_id'],
102
+ 'aws_secret_access_key': creds['aws_secret_access_key'],
103
+ 'region_name': creds.get('region', 'us-east-1')
104
+ }
105
+ if 'aws_session_token' in creds:
106
+ session_kwargs['aws_session_token'] = creds['aws_session_token']
107
+ session = boto3.Session(**session_kwargs)
108
+
109
+ ce_client = session.client('ce', region_name='us-east-1')
110
+ return analyze_monthly_trends(ce_client, accounts, months)
111
+
112
+
113
+ def execute_drill(config, weeks, service_filter=None, account_filter=None, usage_type_filter=None):
114
+ """
115
+ Execute drill-down analysis via API or locally.
116
+
117
+ Returns:
118
+ dict: drill data
119
+ """
120
+ accounts = config['accounts']
121
+
122
+ if is_api_configured():
123
+ # Use API
124
+ click.echo("Using Lambda API...")
125
+ credentials = get_credentials_dict(config)
126
+ kwargs = {'weeks': weeks}
127
+ if service_filter:
128
+ kwargs['service'] = service_filter
129
+ if account_filter:
130
+ kwargs['account'] = account_filter
131
+ if usage_type_filter:
132
+ kwargs['usage_type'] = usage_type_filter
133
+ return call_lambda_api('drill', credentials, accounts, **kwargs)
134
+ else:
135
+ # Use local execution
136
+ click.echo("Using local execution...")
137
+ from cost_calculator.drill import analyze_drill_down
138
+
139
+ # Initialize boto3 client
140
+ if 'aws_profile' in config:
141
+ session = boto3.Session(profile_name=config['aws_profile'])
142
+ else:
143
+ creds = config['credentials']
144
+ session_kwargs = {
145
+ 'aws_access_key_id': creds['aws_access_key_id'],
146
+ 'aws_secret_access_key': creds['aws_secret_access_key'],
147
+ 'region_name': creds.get('region', 'us-east-1')
148
+ }
149
+ if 'aws_session_token' in creds:
150
+ session_kwargs['aws_session_token'] = creds['aws_session_token']
151
+ session = boto3.Session(**session_kwargs)
152
+
153
+ ce_client = session.client('ce', region_name='us-east-1')
154
+ return analyze_drill_down(
155
+ ce_client, accounts, weeks,
156
+ service_filter=service_filter,
157
+ account_filter=account_filter,
158
+ usage_type_filter=usage_type_filter
159
+ )