aws-cost-calculator-cli 1.3.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-cost-calculator-cli might be problematic. Click here for more details.

cost_calculator/cli.py ADDED
@@ -0,0 +1,848 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ AWS Cost Calculator CLI
4
+
5
+ Usage:
6
+ cc --profile myprofile
7
+ cc --profile myprofile --start-date 2025-11-04
8
+ cc --profile myprofile --offset 2 --window 30
9
+ """
10
+
11
+ import click
12
+ import boto3
13
+ import json
14
+ from datetime import datetime, timedelta
15
+ from pathlib import Path
16
+ from cost_calculator.trends import analyze_trends, format_trends_markdown
17
+ from cost_calculator.monthly import analyze_monthly_trends, format_monthly_markdown
18
+ from cost_calculator.drill import analyze_drill_down, format_drill_down_markdown
19
+
20
+
21
+ def load_profile(profile_name):
22
+ """Load profile configuration from ~/.config/cost-calculator/profiles.json"""
23
+ config_dir = Path.home() / '.config' / 'cost-calculator'
24
+ config_file = config_dir / 'profiles.json'
25
+ creds_file = config_dir / 'credentials.json'
26
+
27
+ if not config_file.exists():
28
+ raise click.ClickException(
29
+ f"Profile configuration not found at {config_file}\n"
30
+ f"Run: cc init --profile {profile_name}"
31
+ )
32
+
33
+ with open(config_file) as f:
34
+ profiles = json.load(f)
35
+
36
+ if profile_name not in profiles:
37
+ raise click.ClickException(
38
+ f"Profile '{profile_name}' not found in {config_file}\n"
39
+ f"Available profiles: {', '.join(profiles.keys())}"
40
+ )
41
+
42
+ profile = profiles[profile_name]
43
+
44
+ # Load credentials if using static credentials (not SSO)
45
+ if 'aws_profile' not in profile:
46
+ if not creds_file.exists():
47
+ raise click.ClickException(
48
+ f"No credentials found for profile '{profile_name}'.\n"
49
+ f"Run: cc configure --profile {profile_name}"
50
+ )
51
+
52
+ with open(creds_file) as f:
53
+ creds = json.load(f)
54
+
55
+ if profile_name not in creds:
56
+ raise click.ClickException(
57
+ f"No credentials found for profile '{profile_name}'.\n"
58
+ f"Run: cc configure --profile {profile_name}"
59
+ )
60
+
61
+ profile['credentials'] = creds[profile_name]
62
+
63
+ return profile
64
+
65
+
66
+ def calculate_costs(profile_config, accounts, start_date, offset, window):
67
+ """
68
+ Calculate AWS costs for the specified period.
69
+
70
+ Args:
71
+ profile_config: Profile configuration (with aws_profile or credentials)
72
+ accounts: List of AWS account IDs
73
+ start_date: Start date (defaults to today)
74
+ offset: Days to go back from start_date (default: 2)
75
+ window: Number of days to analyze (default: 30)
76
+
77
+ Returns:
78
+ dict with cost breakdown
79
+ """
80
+ # Calculate date range
81
+ if start_date:
82
+ end_date = datetime.strptime(start_date, '%Y-%m-%d')
83
+ else:
84
+ end_date = datetime.now()
85
+
86
+ # Go back by offset days
87
+ end_date = end_date - timedelta(days=offset)
88
+
89
+ # Start date is window days before end_date
90
+ start_date_calc = end_date - timedelta(days=window)
91
+
92
+ # Format for API (end date is exclusive, so add 1 day)
93
+ api_start = start_date_calc.strftime('%Y-%m-%d')
94
+ api_end = (end_date + timedelta(days=1)).strftime('%Y-%m-%d')
95
+
96
+ click.echo(f"Analyzing: {api_start} to {end_date.strftime('%Y-%m-%d')} ({window} days)")
97
+
98
+ # Initialize boto3 client
99
+ try:
100
+ if 'aws_profile' in profile_config:
101
+ # SSO-based authentication
102
+ aws_profile = profile_config['aws_profile']
103
+ click.echo(f"AWS Profile: {aws_profile} (SSO)")
104
+ click.echo(f"Accounts: {len(accounts)}")
105
+ click.echo("")
106
+ session = boto3.Session(profile_name=aws_profile)
107
+ ce_client = session.client('ce', region_name='us-east-1')
108
+ else:
109
+ # Static credentials
110
+ creds = profile_config['credentials']
111
+ click.echo(f"AWS Credentials: Static")
112
+ click.echo(f"Accounts: {len(accounts)}")
113
+ click.echo("")
114
+
115
+ session_kwargs = {
116
+ 'aws_access_key_id': creds['aws_access_key_id'],
117
+ 'aws_secret_access_key': creds['aws_secret_access_key'],
118
+ 'region_name': creds.get('region', 'us-east-1')
119
+ }
120
+
121
+ if 'aws_session_token' in creds:
122
+ session_kwargs['aws_session_token'] = creds['aws_session_token']
123
+
124
+ session = boto3.Session(**session_kwargs)
125
+ ce_client = session.client('ce')
126
+
127
+ except Exception as e:
128
+ if 'Token has expired' in str(e) or 'sso' in str(e).lower():
129
+ if 'aws_profile' in profile_config:
130
+ raise click.ClickException(
131
+ f"AWS SSO session expired or not initialized.\n"
132
+ f"Run: aws sso login --profile {profile_config['aws_profile']}"
133
+ )
134
+ else:
135
+ raise click.ClickException(
136
+ f"AWS credentials expired.\n"
137
+ f"Run: cc configure --profile <profile_name>"
138
+ )
139
+ raise
140
+
141
+ # Build filter
142
+ cost_filter = {
143
+ "And": [
144
+ {
145
+ "Dimensions": {
146
+ "Key": "LINKED_ACCOUNT",
147
+ "Values": accounts
148
+ }
149
+ },
150
+ {
151
+ "Dimensions": {
152
+ "Key": "BILLING_ENTITY",
153
+ "Values": ["AWS"]
154
+ }
155
+ },
156
+ {
157
+ "Not": {
158
+ "Dimensions": {
159
+ "Key": "RECORD_TYPE",
160
+ "Values": ["Tax", "Support"]
161
+ }
162
+ }
163
+ }
164
+ ]
165
+ }
166
+
167
+ # Get daily costs
168
+ click.echo("Fetching cost data...")
169
+ try:
170
+ response = ce_client.get_cost_and_usage(
171
+ TimePeriod={
172
+ 'Start': api_start,
173
+ 'End': api_end
174
+ },
175
+ Granularity='DAILY',
176
+ Metrics=['NetAmortizedCost'],
177
+ Filter=cost_filter
178
+ )
179
+ except Exception as e:
180
+ if 'Token has expired' in str(e) or 'expired' in str(e).lower():
181
+ raise click.ClickException(
182
+ f"AWS SSO session expired.\n"
183
+ f"Run: aws sso login --profile {aws_profile}"
184
+ )
185
+ raise
186
+
187
+ # Calculate total
188
+ total_cost = sum(
189
+ float(day['Total']['NetAmortizedCost']['Amount'])
190
+ for day in response['ResultsByTime']
191
+ )
192
+
193
+ # Get support cost from the 1st of the month containing the end date
194
+ # Support is charged on the 1st of each month for the previous month's usage
195
+ # For Oct 3-Nov 2 analysis, we get support from Nov 1 (which is October's support)
196
+ support_month_date = end_date.replace(day=1)
197
+ support_date_str = support_month_date.strftime('%Y-%m-%d')
198
+ support_date_end = (support_month_date + timedelta(days=1)).strftime('%Y-%m-%d')
199
+
200
+ click.echo("Fetching support costs...")
201
+ support_response = ce_client.get_cost_and_usage(
202
+ TimePeriod={
203
+ 'Start': support_date_str,
204
+ 'End': support_date_end
205
+ },
206
+ Granularity='DAILY',
207
+ Metrics=['NetAmortizedCost'],
208
+ Filter={
209
+ "And": [
210
+ {
211
+ "Dimensions": {
212
+ "Key": "LINKED_ACCOUNT",
213
+ "Values": accounts
214
+ }
215
+ },
216
+ {
217
+ "Dimensions": {
218
+ "Key": "RECORD_TYPE",
219
+ "Values": ["Support"]
220
+ }
221
+ }
222
+ ]
223
+ }
224
+ )
225
+
226
+ support_cost = float(support_response['ResultsByTime'][0]['Total']['NetAmortizedCost']['Amount'])
227
+
228
+ # Calculate days in the month that the support covers
229
+ # Support on Nov 1 covers October (31 days)
230
+ support_month = support_month_date - timedelta(days=1) # Go back to previous month
231
+ days_in_support_month = support_month.day # This gives us the last day of the month
232
+
233
+ # Support allocation: divide by 2 (half to Khoros), then by days in month
234
+ support_per_day = (support_cost / 2) / days_in_support_month
235
+
236
+ # Calculate daily rate
237
+ # NOTE: We divide operational by window, but support by days_in_support_month
238
+ # This matches the console's calculation method
239
+ daily_operational = total_cost / days_in_support_month # Use 31 for October, not 30
240
+ daily_total = daily_operational + support_per_day
241
+
242
+ # Annual projection
243
+ annual = daily_total * 365
244
+
245
+ return {
246
+ 'period': {
247
+ 'start': api_start,
248
+ 'end': end_date.strftime('%Y-%m-%d'),
249
+ 'days': window
250
+ },
251
+ 'costs': {
252
+ 'total_operational': total_cost,
253
+ 'daily_operational': daily_operational,
254
+ 'support_month': support_cost,
255
+ 'support_per_day': support_per_day,
256
+ 'daily_total': daily_total,
257
+ 'annual_projection': annual
258
+ }
259
+ }
260
+
261
+
262
+ @click.group()
263
+ def cli():
264
+ """
265
+ AWS Cost Calculator - Calculate daily and annual AWS costs
266
+
267
+ \b
268
+ Two authentication methods:
269
+ 1. AWS SSO (recommended for interactive use)
270
+ 2. Static credentials (for automation/CI)
271
+
272
+ \b
273
+ Quick Start:
274
+ # SSO Method
275
+ aws sso login --profile my_aws_profile
276
+ cc init --profile myprofile --aws-profile my_aws_profile --accounts "123,456,789"
277
+ cc calculate --profile myprofile
278
+
279
+ # Static Credentials Method
280
+ cc init --profile myprofile --aws-profile dummy --accounts "123,456,789"
281
+ cc configure --profile myprofile
282
+ cc calculate --profile myprofile
283
+
284
+ \b
285
+ For detailed documentation, see:
286
+ - COST_CALCULATION_METHODOLOGY.md
287
+ - README.md
288
+ """
289
+ pass
290
+
291
+
292
+ @cli.command()
293
+ @click.option('--profile', required=True, help='Profile name (e.g., myprofile)')
294
+ @click.option('--start-date', help='Start date (YYYY-MM-DD, default: today)')
295
+ @click.option('--offset', default=2, help='Days to go back from start date (default: 2)')
296
+ @click.option('--window', default=30, help='Number of days to analyze (default: 30)')
297
+ @click.option('--json-output', is_flag=True, help='Output as JSON')
298
+ def calculate(profile, start_date, offset, window, json_output):
299
+ """Calculate AWS costs for the specified period"""
300
+
301
+ # Load profile configuration
302
+ config = load_profile(profile)
303
+
304
+ # Calculate costs
305
+ result = calculate_costs(
306
+ profile_config=config,
307
+ accounts=config['accounts'],
308
+ start_date=start_date,
309
+ offset=offset,
310
+ window=window
311
+ )
312
+
313
+ if json_output:
314
+ click.echo(json.dumps(result, indent=2))
315
+ else:
316
+ # Pretty print results
317
+ click.echo("=" * 60)
318
+ click.echo(f"Period: {result['period']['start']} to {result['period']['end']}")
319
+ click.echo(f"Days analyzed: {result['period']['days']}")
320
+ click.echo("=" * 60)
321
+ click.echo(f"Total operational cost: ${result['costs']['total_operational']:,.2f}")
322
+ click.echo(f"Daily operational: ${result['costs']['daily_operational']:,.2f}")
323
+ click.echo(f"Support (month): ${result['costs']['support_month']:,.2f}")
324
+ click.echo(f"Support per day (÷2÷days): ${result['costs']['support_per_day']:,.2f}")
325
+ click.echo("=" * 60)
326
+ click.echo(f"DAILY RATE: ${result['costs']['daily_total']:,.2f}")
327
+ click.echo(f"ANNUAL PROJECTION: ${result['costs']['annual_projection']:,.0f}")
328
+ click.echo("=" * 60)
329
+
330
+
331
+ @cli.command()
332
+ @click.option('--profile', required=True, help='Profile name to create')
333
+ @click.option('--aws-profile', required=True, help='AWS CLI profile name')
334
+ @click.option('--accounts', required=True, help='Comma-separated list of account IDs')
335
+ def init(profile, aws_profile, accounts):
336
+ """Initialize a new profile configuration"""
337
+
338
+ config_dir = Path.home() / '.config' / 'cost-calculator'
339
+ config_file = config_dir / 'profiles.json'
340
+
341
+ # Create config directory if it doesn't exist
342
+ config_dir.mkdir(parents=True, exist_ok=True)
343
+
344
+ # Load existing profiles or create new
345
+ if config_file.exists() and config_file.stat().st_size > 0:
346
+ try:
347
+ with open(config_file) as f:
348
+ profiles = json.load(f)
349
+ except json.JSONDecodeError:
350
+ profiles = {}
351
+ else:
352
+ profiles = {}
353
+
354
+ # Parse accounts
355
+ account_list = [acc.strip() for acc in accounts.split(',')]
356
+
357
+ # Add new profile
358
+ profiles[profile] = {
359
+ 'aws_profile': aws_profile,
360
+ 'accounts': account_list
361
+ }
362
+
363
+ # Save
364
+ with open(config_file, 'w') as f:
365
+ json.dump(profiles, f, indent=2)
366
+
367
+ click.echo(f"✓ Profile '{profile}' created with {len(account_list)} accounts")
368
+ click.echo(f"✓ Configuration saved to {config_file}")
369
+ click.echo(f"\nUsage: cc calculate --profile {profile}")
370
+
371
+
372
+ @cli.command()
373
+ def list_profiles():
374
+ """List all configured profiles"""
375
+
376
+ config_file = Path.home() / '.config' / 'cost-calculator' / 'profiles.json'
377
+
378
+ if not config_file.exists():
379
+ click.echo("No profiles configured. Run: cc init --profile <name>")
380
+ return
381
+
382
+ with open(config_file) as f:
383
+ profiles = json.load(f)
384
+
385
+ if not profiles:
386
+ click.echo("No profiles configured.")
387
+ return
388
+
389
+ click.echo("Configured profiles:")
390
+ click.echo("")
391
+ for name, config in profiles.items():
392
+ click.echo(f" {name}")
393
+ if 'aws_profile' in config:
394
+ click.echo(f" AWS Profile: {config['aws_profile']} (SSO)")
395
+ else:
396
+ click.echo(f" AWS Credentials: Configured (Static)")
397
+ click.echo(f" Accounts: {len(config['accounts'])}")
398
+ click.echo("")
399
+
400
+
401
+ @cli.command()
402
+ def setup():
403
+ """Show setup instructions for manual profile configuration"""
404
+ import platform
405
+
406
+ system = platform.system()
407
+
408
+ if system == "Windows":
409
+ config_path = "%USERPROFILE%\\.config\\cost-calculator\\profiles.json"
410
+ config_path_example = "C:\\Users\\YourName\\.config\\cost-calculator\\profiles.json"
411
+ mkdir_cmd = "mkdir %USERPROFILE%\\.config\\cost-calculator"
412
+ edit_cmd = "notepad %USERPROFILE%\\.config\\cost-calculator\\profiles.json"
413
+ else: # macOS/Linux
414
+ config_path = "~/.config/cost-calculator/profiles.json"
415
+ config_path_example = "/Users/yourname/.config/cost-calculator/profiles.json"
416
+ mkdir_cmd = "mkdir -p ~/.config/cost-calculator"
417
+ edit_cmd = "nano ~/.config/cost-calculator/profiles.json"
418
+
419
+ click.echo("=" * 70)
420
+ click.echo("AWS Cost Calculator - Manual Profile Setup")
421
+ click.echo("=" * 70)
422
+ click.echo("")
423
+ click.echo(f"Platform: {system}")
424
+ click.echo(f"Config location: {config_path}")
425
+ click.echo("")
426
+ click.echo("Step 1: Create the config directory")
427
+ click.echo(f" {mkdir_cmd}")
428
+ click.echo("")
429
+ click.echo("Step 2: Create the profiles.json file")
430
+ click.echo(f" {edit_cmd}")
431
+ click.echo("")
432
+ click.echo("Step 3: Add your profile configuration (JSON format):")
433
+ click.echo("")
434
+ click.echo(' {')
435
+ click.echo(' "myprofile": {')
436
+ click.echo(' "aws_profile": "my_aws_profile",')
437
+ click.echo(' "accounts": [')
438
+ click.echo(' "123456789012",')
439
+ click.echo(' "234567890123",')
440
+ click.echo(' "345678901234"')
441
+ click.echo(' ]')
442
+ click.echo(' }')
443
+ click.echo(' }')
444
+ click.echo("")
445
+ click.echo("Step 4: Save the file")
446
+ click.echo("")
447
+ click.echo("Step 5: Verify it works")
448
+ click.echo(" cc list-profiles")
449
+ click.echo("")
450
+ click.echo("Step 6: Configure AWS credentials")
451
+ click.echo(" Option A (SSO):")
452
+ click.echo(" aws sso login --profile my_aws_profile")
453
+ click.echo(" cc calculate --profile myprofile")
454
+ click.echo("")
455
+ click.echo(" Option B (Static credentials):")
456
+ click.echo(" cc configure --profile myprofile")
457
+ click.echo(" cc calculate --profile myprofile")
458
+ click.echo("")
459
+ click.echo("=" * 70)
460
+ click.echo("")
461
+ click.echo("For multiple profiles, add more entries to the JSON:")
462
+ click.echo("")
463
+ click.echo(' {')
464
+ click.echo(' "profile1": { ... },')
465
+ click.echo(' "profile2": { ... }')
466
+ click.echo(' }')
467
+ click.echo("")
468
+ click.echo(f"Full path example: {config_path_example}")
469
+ click.echo("=" * 70)
470
+
471
+
472
+ @cli.command()
473
+ @click.option('--profile', required=True, help='Profile name to configure')
474
+ @click.option('--access-key-id', prompt=True, hide_input=False, help='AWS Access Key ID')
475
+ @click.option('--secret-access-key', prompt=True, hide_input=True, help='AWS Secret Access Key')
476
+ @click.option('--session-token', default='', help='AWS Session Token (optional, for temporary credentials)')
477
+ @click.option('--region', default='us-east-1', help='AWS Region (default: us-east-1)')
478
+ def configure(profile, access_key_id, secret_access_key, session_token, region):
479
+ """Configure AWS credentials for a profile (alternative to SSO)"""
480
+
481
+ config_dir = Path.home() / '.config' / 'cost-calculator'
482
+ config_file = config_dir / 'profiles.json'
483
+ creds_file = config_dir / 'credentials.json'
484
+
485
+ # Create config directory if it doesn't exist
486
+ config_dir.mkdir(parents=True, exist_ok=True)
487
+
488
+ # Load existing profiles
489
+ if config_file.exists() and config_file.stat().st_size > 0:
490
+ try:
491
+ with open(config_file) as f:
492
+ profiles = json.load(f)
493
+ except json.JSONDecodeError:
494
+ profiles = {}
495
+ else:
496
+ profiles = {}
497
+
498
+ # Check if profile exists
499
+ if profile not in profiles:
500
+ click.echo(f"Error: Profile '{profile}' not found. Create it first with: cc init --profile {profile}")
501
+ return
502
+
503
+ # Remove aws_profile if it exists (switching from SSO to static creds)
504
+ if 'aws_profile' in profiles[profile]:
505
+ del profiles[profile]['aws_profile']
506
+
507
+ # Save updated profile
508
+ with open(config_file, 'w') as f:
509
+ json.dump(profiles, f, indent=2)
510
+
511
+ # Load or create credentials file
512
+ if creds_file.exists() and creds_file.stat().st_size > 0:
513
+ try:
514
+ with open(creds_file) as f:
515
+ creds = json.load(f)
516
+ except json.JSONDecodeError:
517
+ creds = {}
518
+ else:
519
+ creds = {}
520
+
521
+ # Store credentials (encrypted would be better, but for now just file permissions)
522
+ creds[profile] = {
523
+ 'aws_access_key_id': access_key_id,
524
+ 'aws_secret_access_key': secret_access_key,
525
+ 'region': region
526
+ }
527
+
528
+ if session_token:
529
+ creds[profile]['aws_session_token'] = session_token
530
+
531
+ # Save credentials with restricted permissions
532
+ with open(creds_file, 'w') as f:
533
+ json.dump(creds, f, indent=2)
534
+
535
+ # Set file permissions to 600 (owner read/write only)
536
+ creds_file.chmod(0o600)
537
+
538
+ click.echo(f"✓ AWS credentials configured for profile '{profile}'")
539
+ click.echo(f"✓ Credentials saved to {creds_file} (permissions: 600)")
540
+ click.echo(f"\nUsage: cc calculate --profile {profile}")
541
+ click.echo("\nNote: Credentials are stored locally. For temporary credentials,")
542
+ click.echo(" you'll need to reconfigure when they expire.")
543
+
544
+
545
+ @cli.command()
546
+ @click.option('--profile', required=True, help='Profile name')
547
+ @click.option('--weeks', default=3, help='Number of weeks to analyze (default: 3)')
548
+ @click.option('--output', default='cost_trends.md', help='Output markdown file (default: cost_trends.md)')
549
+ @click.option('--json-output', is_flag=True, help='Output as JSON instead of markdown')
550
+ def trends(profile, weeks, output, json_output):
551
+ """Analyze cost trends with Week-over-Week and Trailing 30-Day comparisons"""
552
+
553
+ # Load profile configuration
554
+ config = load_profile(profile)
555
+
556
+ # Initialize boto3 client
557
+ try:
558
+ if 'aws_profile' in config:
559
+ aws_profile = config['aws_profile']
560
+ click.echo(f"AWS Profile: {aws_profile} (SSO)")
561
+ session = boto3.Session(profile_name=aws_profile)
562
+ ce_client = session.client('ce', region_name='us-east-1')
563
+ else:
564
+ creds = config['credentials']
565
+ click.echo(f"AWS Credentials: Static")
566
+
567
+ session_kwargs = {
568
+ 'aws_access_key_id': creds['aws_access_key_id'],
569
+ 'aws_secret_access_key': creds['aws_secret_access_key'],
570
+ 'region_name': creds.get('region', 'us-east-1')
571
+ }
572
+
573
+ if 'aws_session_token' in creds:
574
+ session_kwargs['aws_session_token'] = creds['aws_session_token']
575
+
576
+ session = boto3.Session(**session_kwargs)
577
+ ce_client = session.client('ce')
578
+
579
+ except Exception as e:
580
+ if 'Token has expired' in str(e) or 'sso' in str(e).lower():
581
+ if 'aws_profile' in config:
582
+ raise click.ClickException(
583
+ f"AWS SSO session expired or not initialized.\n"
584
+ f"Run: aws sso login --profile {config['aws_profile']}"
585
+ )
586
+ else:
587
+ raise click.ClickException(
588
+ f"AWS credentials expired.\n"
589
+ f"Run: cc configure --profile {profile}"
590
+ )
591
+ raise
592
+
593
+ click.echo(f"Analyzing last {weeks} weeks...")
594
+ click.echo("")
595
+
596
+ # Analyze trends
597
+ trends_data = analyze_trends(ce_client, config['accounts'], num_weeks=weeks)
598
+
599
+ if json_output:
600
+ # Output as JSON
601
+ import json
602
+ click.echo(json.dumps(trends_data, indent=2, default=str))
603
+ else:
604
+ # Generate markdown report
605
+ markdown = format_trends_markdown(trends_data)
606
+
607
+ # Save to file
608
+ with open(output, 'w') as f:
609
+ f.write(markdown)
610
+
611
+ click.echo(f"✓ Trends report saved to {output}")
612
+ click.echo("")
613
+
614
+ # Show summary
615
+ click.echo("WEEK-OVER-WEEK:")
616
+ for comparison in trends_data['wow_comparisons']:
617
+ prev_week = comparison['prev_week']['label']
618
+ curr_week = comparison['curr_week']['label']
619
+ num_increases = len(comparison['increases'])
620
+ num_decreases = len(comparison['decreases'])
621
+
622
+ click.echo(f" {prev_week} → {curr_week}")
623
+ click.echo(f" Increases: {num_increases}, Decreases: {num_decreases}")
624
+
625
+ if comparison['increases']:
626
+ top = comparison['increases'][0]
627
+ click.echo(f" Top: {top['service']} (+${top['change']:,.2f})")
628
+
629
+ click.echo("")
630
+
631
+ click.echo("TRAILING 30-DAY (T-30):")
632
+ for comparison in trends_data['t30_comparisons']:
633
+ baseline_week = comparison['baseline_week']['label']
634
+ curr_week = comparison['curr_week']['label']
635
+ num_increases = len(comparison['increases'])
636
+ num_decreases = len(comparison['decreases'])
637
+
638
+ click.echo(f" {curr_week} vs {baseline_week}")
639
+ click.echo(f" Increases: {num_increases}, Decreases: {num_decreases}")
640
+
641
+ if comparison['increases']:
642
+ top = comparison['increases'][0]
643
+ click.echo(f" Top: {top['service']} (+${top['change']:,.2f})")
644
+
645
+ click.echo("")
646
+
647
+
648
+ @cli.command()
649
+ @click.option('--profile', required=True, help='Profile name')
650
+ @click.option('--months', default=6, help='Number of months to analyze (default: 6)')
651
+ @click.option('--output', default='monthly_trends.md', help='Output markdown file (default: monthly_trends.md)')
652
+ @click.option('--json-output', is_flag=True, help='Output as JSON instead of markdown')
653
+ def monthly(profile, months, output, json_output):
654
+ """Analyze month-over-month cost trends at service level"""
655
+
656
+ # Load profile configuration
657
+ config = load_profile(profile)
658
+
659
+ # Initialize boto3 client
660
+ try:
661
+ if 'aws_profile' in config:
662
+ aws_profile = config['aws_profile']
663
+ click.echo(f"AWS Profile: {aws_profile} (SSO)")
664
+ session = boto3.Session(profile_name=aws_profile)
665
+ else:
666
+ # Use static credentials
667
+ creds = config['credentials']
668
+ click.echo("AWS Credentials: Static")
669
+ session = boto3.Session(
670
+ aws_access_key_id=creds['aws_access_key_id'],
671
+ aws_secret_access_key=creds['aws_secret_access_key'],
672
+ aws_session_token=creds.get('aws_session_token')
673
+ )
674
+
675
+ ce_client = session.client('ce', region_name='us-east-1')
676
+ except Exception as e:
677
+ raise click.ClickException(f"Failed to initialize AWS session: {str(e)}")
678
+
679
+ # Get account list
680
+ accounts = config['accounts']
681
+
682
+ click.echo(f"Analyzing last {months} months...")
683
+ click.echo("")
684
+
685
+ # Analyze monthly trends
686
+ monthly_data = analyze_monthly_trends(ce_client, accounts, months)
687
+
688
+ if json_output:
689
+ # Output as JSON
690
+ output_data = {
691
+ 'generated': datetime.now().isoformat(),
692
+ 'months': months,
693
+ 'comparisons': []
694
+ }
695
+
696
+ for comparison in monthly_data['comparisons']:
697
+ output_data['comparisons'].append({
698
+ 'prev_month': comparison['prev_month']['label'],
699
+ 'curr_month': comparison['curr_month']['label'],
700
+ 'increases': comparison['increases'],
701
+ 'decreases': comparison['decreases'],
702
+ 'total_increase': comparison['total_increase'],
703
+ 'total_decrease': comparison['total_decrease']
704
+ })
705
+
706
+ click.echo(json.dumps(output_data, indent=2))
707
+ else:
708
+ # Generate markdown report
709
+ markdown = format_monthly_markdown(monthly_data)
710
+
711
+ # Save to file
712
+ with open(output, 'w') as f:
713
+ f.write(markdown)
714
+
715
+ click.echo(f"✓ Monthly trends report saved to {output}")
716
+ click.echo("")
717
+
718
+ # Show summary
719
+ for comparison in monthly_data['comparisons']:
720
+ prev_month = comparison['prev_month']['label']
721
+ curr_month = comparison['curr_month']['label']
722
+ num_increases = len(comparison['increases'])
723
+ num_decreases = len(comparison['decreases'])
724
+
725
+ click.echo(f"{prev_month} → {curr_month}")
726
+ click.echo(f" Increases: {num_increases}, Decreases: {num_decreases}")
727
+
728
+ if comparison['increases']:
729
+ top = comparison['increases'][0]
730
+ click.echo(f" Top: {top['service']} (+${top['change']:,.2f})")
731
+
732
+ click.echo("")
733
+
734
+
735
+ @cli.command()
736
+ @click.option('--profile', required=True, help='Profile name')
737
+ @click.option('--weeks', default=4, help='Number of weeks to analyze (default: 4)')
738
+ @click.option('--service', help='Filter by service name (e.g., "EC2 - Other")')
739
+ @click.option('--account', help='Filter by account ID')
740
+ @click.option('--usage-type', help='Filter by usage type')
741
+ @click.option('--output', default='drill_down.md', help='Output markdown file (default: drill_down.md)')
742
+ @click.option('--json-output', is_flag=True, help='Output as JSON instead of markdown')
743
+ def drill(profile, weeks, service, account, usage_type, output, json_output):
744
+ """Drill down into cost changes by service, account, or usage type"""
745
+
746
+ # Load profile configuration
747
+ config = load_profile(profile)
748
+
749
+ # Initialize boto3 client
750
+ try:
751
+ if 'aws_profile' in config:
752
+ aws_profile = config['aws_profile']
753
+ click.echo(f"AWS Profile: {aws_profile} (SSO)")
754
+ session = boto3.Session(profile_name=aws_profile)
755
+ else:
756
+ # Use static credentials
757
+ creds = config['credentials']
758
+ click.echo("AWS Credentials: Static")
759
+ session = boto3.Session(
760
+ aws_access_key_id=creds['aws_access_key_id'],
761
+ aws_secret_access_key=creds['aws_secret_access_key'],
762
+ aws_session_token=creds.get('aws_session_token')
763
+ )
764
+
765
+ ce_client = session.client('ce', region_name='us-east-1')
766
+ except Exception as e:
767
+ raise click.ClickException(f"Failed to initialize AWS session: {str(e)}")
768
+
769
+ # Get account list
770
+ accounts = config['accounts']
771
+
772
+ # Show filters
773
+ click.echo(f"Analyzing last {weeks} weeks...")
774
+ if service:
775
+ click.echo(f" Service filter: {service}")
776
+ if account:
777
+ click.echo(f" Account filter: {account}")
778
+ if usage_type:
779
+ click.echo(f" Usage type filter: {usage_type}")
780
+ click.echo("")
781
+
782
+ # Analyze with drill-down
783
+ drill_data = analyze_drill_down(
784
+ ce_client, accounts, weeks,
785
+ service_filter=service,
786
+ account_filter=account,
787
+ usage_type_filter=usage_type
788
+ )
789
+
790
+ if json_output:
791
+ # Output as JSON
792
+ output_data = {
793
+ 'generated': datetime.now().isoformat(),
794
+ 'weeks': weeks,
795
+ 'filters': drill_data['filters'],
796
+ 'group_by': drill_data['group_by'],
797
+ 'comparisons': []
798
+ }
799
+
800
+ for comparison in drill_data['comparisons']:
801
+ output_data['comparisons'].append({
802
+ 'prev_week': comparison['prev_week']['label'],
803
+ 'curr_week': comparison['curr_week']['label'],
804
+ 'increases': comparison['increases'],
805
+ 'decreases': comparison['decreases'],
806
+ 'total_increase': comparison['total_increase'],
807
+ 'total_decrease': comparison['total_decrease']
808
+ })
809
+
810
+ click.echo(json.dumps(output_data, indent=2))
811
+ else:
812
+ # Generate markdown report
813
+ markdown = format_drill_down_markdown(drill_data)
814
+
815
+ # Save to file
816
+ with open(output, 'w') as f:
817
+ f.write(markdown)
818
+
819
+ click.echo(f"✓ Drill-down report saved to {output}")
820
+ click.echo("")
821
+
822
+ # Show summary
823
+ group_by_label = {
824
+ 'SERVICE': 'services',
825
+ 'LINKED_ACCOUNT': 'accounts',
826
+ 'USAGE_TYPE': 'usage types',
827
+ 'REGION': 'regions'
828
+ }.get(drill_data['group_by'], 'items')
829
+
830
+ click.echo(f"Showing top {group_by_label}:")
831
+ for comparison in drill_data['comparisons']:
832
+ prev_week = comparison['prev_week']['label']
833
+ curr_week = comparison['curr_week']['label']
834
+ num_increases = len(comparison['increases'])
835
+ num_decreases = len(comparison['decreases'])
836
+
837
+ click.echo(f"{prev_week} → {curr_week}")
838
+ click.echo(f" Increases: {num_increases}, Decreases: {num_decreases}")
839
+
840
+ if comparison['increases']:
841
+ top = comparison['increases'][0]
842
+ click.echo(f" Top: {top['dimension'][:50]} (+${top['change']:,.2f})")
843
+
844
+ click.echo("")
845
+
846
+
847
+ if __name__ == '__main__':
848
+ cli()