aws-cost-calculator-cli 1.0.3__py3-none-any.whl → 1.5.2__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,241 @@
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, or None if profile is 'dummy'
15
+ """
16
+ if 'aws_profile' in config:
17
+ # Skip credential loading for dummy profile (API-only mode)
18
+ if config['aws_profile'] == 'dummy':
19
+ return None
20
+
21
+ # Get temporary credentials from SSO session
22
+ try:
23
+ session = boto3.Session(profile_name=config['aws_profile'])
24
+ credentials = session.get_credentials()
25
+ frozen_creds = credentials.get_frozen_credentials()
26
+
27
+ return {
28
+ 'access_key': frozen_creds.access_key,
29
+ 'secret_key': frozen_creds.secret_key,
30
+ 'session_token': frozen_creds.token
31
+ }
32
+ except Exception:
33
+ # If profile not found, return None (API will handle)
34
+ return None
35
+ else:
36
+ # Use static credentials
37
+ creds = config.get('credentials', {})
38
+ if not creds:
39
+ return None
40
+
41
+ result = {
42
+ 'access_key': creds['aws_access_key_id'],
43
+ 'secret_key': creds['aws_secret_access_key']
44
+ }
45
+ if 'aws_session_token' in creds:
46
+ result['session_token'] = creds['aws_session_token']
47
+ return result
48
+
49
+
50
+ def execute_trends(config, weeks):
51
+ """
52
+ Execute trends analysis via API or locally.
53
+
54
+ Returns:
55
+ dict: trends data
56
+ """
57
+ accounts = config['accounts']
58
+
59
+ if is_api_configured():
60
+ # Use API
61
+ click.echo("Using Lambda API...")
62
+ credentials = get_credentials_dict(config)
63
+ return call_lambda_api('trends', credentials, accounts, weeks=weeks)
64
+ else:
65
+ # Use local execution
66
+ click.echo("Using local execution...")
67
+ from cost_calculator.trends import analyze_trends
68
+
69
+ # Initialize boto3 client
70
+ if 'aws_profile' in config:
71
+ session = boto3.Session(profile_name=config['aws_profile'])
72
+ else:
73
+ creds = config['credentials']
74
+ session_kwargs = {
75
+ 'aws_access_key_id': creds['aws_access_key_id'],
76
+ 'aws_secret_access_key': creds['aws_secret_access_key'],
77
+ 'region_name': creds.get('region', 'us-east-1')
78
+ }
79
+ if 'aws_session_token' in creds:
80
+ session_kwargs['aws_session_token'] = creds['aws_session_token']
81
+ session = boto3.Session(**session_kwargs)
82
+
83
+ ce_client = session.client('ce', region_name='us-east-1')
84
+ return analyze_trends(ce_client, accounts, weeks)
85
+
86
+
87
+ def execute_monthly(config, months):
88
+ """
89
+ Execute monthly analysis via API or locally.
90
+
91
+ Returns:
92
+ dict: monthly data
93
+ """
94
+ accounts = config['accounts']
95
+
96
+ if is_api_configured():
97
+ # Use API
98
+ click.echo("Using Lambda API...")
99
+ credentials = get_credentials_dict(config)
100
+ return call_lambda_api('monthly', credentials, accounts, months=months)
101
+ else:
102
+ # Use local execution
103
+ click.echo("Using local execution...")
104
+ from cost_calculator.monthly import analyze_monthly_trends
105
+
106
+ # Initialize boto3 client
107
+ if 'aws_profile' in config:
108
+ session = boto3.Session(profile_name=config['aws_profile'])
109
+ else:
110
+ creds = config['credentials']
111
+ session_kwargs = {
112
+ 'aws_access_key_id': creds['aws_access_key_id'],
113
+ 'aws_secret_access_key': creds['aws_secret_access_key'],
114
+ 'region_name': creds.get('region', 'us-east-1')
115
+ }
116
+ if 'aws_session_token' in creds:
117
+ session_kwargs['aws_session_token'] = creds['aws_session_token']
118
+ session = boto3.Session(**session_kwargs)
119
+
120
+ ce_client = session.client('ce', region_name='us-east-1')
121
+ return analyze_monthly_trends(ce_client, accounts, months)
122
+
123
+
124
+ def execute_drill(config, weeks, service_filter=None, account_filter=None, usage_type_filter=None):
125
+ """
126
+ Execute drill-down analysis via API or locally.
127
+
128
+ Returns:
129
+ dict: drill data
130
+ """
131
+ accounts = config['accounts']
132
+
133
+ if is_api_configured():
134
+ # Use API
135
+ click.echo("Using Lambda API...")
136
+ credentials = get_credentials_dict(config)
137
+ kwargs = {'weeks': weeks}
138
+ if service_filter:
139
+ kwargs['service'] = service_filter
140
+ if account_filter:
141
+ kwargs['account'] = account_filter
142
+ if usage_type_filter:
143
+ kwargs['usage_type'] = usage_type_filter
144
+ return call_lambda_api('drill', credentials, accounts, **kwargs)
145
+ else:
146
+ # Use local execution
147
+ click.echo("Using local execution...")
148
+ from cost_calculator.drill import analyze_drill_down
149
+
150
+ # Initialize boto3 client
151
+ if 'aws_profile' in config:
152
+ session = boto3.Session(profile_name=config['aws_profile'])
153
+ else:
154
+ creds = config['credentials']
155
+ session_kwargs = {
156
+ 'aws_access_key_id': creds['aws_access_key_id'],
157
+ 'aws_secret_access_key': creds['aws_secret_access_key'],
158
+ 'region_name': creds.get('region', 'us-east-1')
159
+ }
160
+ if 'aws_session_token' in creds:
161
+ session_kwargs['aws_session_token'] = creds['aws_session_token']
162
+ session = boto3.Session(**session_kwargs)
163
+
164
+ ce_client = session.client('ce', region_name='us-east-1')
165
+ return analyze_drill_down(
166
+ ce_client, accounts, weeks,
167
+ service_filter=service_filter,
168
+ account_filter=account_filter,
169
+ usage_type_filter=usage_type_filter
170
+ )
171
+
172
+
173
+ def execute_analyze(config, weeks, analysis_type, pattern=None, min_cost=None):
174
+ """
175
+ Execute pandas-based analysis via API.
176
+ Note: This only works via API (requires pandas layer).
177
+
178
+ Returns:
179
+ dict: analysis results
180
+ """
181
+ accounts = config['accounts']
182
+
183
+ if not is_api_configured():
184
+ raise click.ClickException(
185
+ "Analyze command requires API configuration.\n"
186
+ "Set COST_API_SECRET environment variable."
187
+ )
188
+
189
+ credentials = get_credentials_dict(config)
190
+ kwargs = {'weeks': weeks, 'type': analysis_type}
191
+
192
+ if pattern:
193
+ kwargs['pattern'] = pattern
194
+ if min_cost:
195
+ kwargs['min_cost'] = min_cost
196
+
197
+ return call_lambda_api('analyze', credentials, accounts, **kwargs)
198
+
199
+
200
+ def execute_profile_operation(operation, profile_name=None, accounts=None, description=None):
201
+ """
202
+ Execute profile CRUD operations via API.
203
+
204
+ Returns:
205
+ dict: operation result
206
+ """
207
+ if not is_api_configured():
208
+ raise click.ClickException(
209
+ "Profile commands require API configuration.\n"
210
+ "Set COST_API_SECRET environment variable."
211
+ )
212
+
213
+ # Profile operations don't need AWS credentials, just API secret
214
+ import os
215
+ import requests
216
+ import json
217
+
218
+ api_secret = os.environ.get('COST_API_SECRET', '')
219
+
220
+ # Use profiles endpoint (hardcoded URL)
221
+ url = 'https://64g7jq7sjygec2zmll5lsghrpi0txrzo.lambda-url.us-east-1.on.aws/'
222
+
223
+ payload = {'operation': operation}
224
+ if profile_name:
225
+ payload['profile_name'] = profile_name
226
+ if accounts:
227
+ payload['accounts'] = accounts
228
+ if description:
229
+ payload['description'] = description
230
+
231
+ headers = {
232
+ 'X-API-Secret': api_secret,
233
+ 'Content-Type': 'application/json'
234
+ }
235
+
236
+ response = requests.post(url, headers=headers, json=payload, timeout=60)
237
+
238
+ if response.status_code != 200:
239
+ raise Exception(f"API call failed: {response.status_code} - {response.text}")
240
+
241
+ return response.json()