aws-cost-calculator-cli 1.3.0__py3-none-any.whl → 1.6.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.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aws-cost-calculator-cli
3
- Version: 1.3.0
3
+ Version: 1.6.0
4
4
  Summary: AWS Cost Calculator CLI - Calculate daily and annual AWS costs across multiple accounts
5
5
  Home-page: https://github.com/yourusername/cost-calculator
6
6
  Author: Cost Optimization Team
@@ -23,6 +23,7 @@ Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
24
  Requires-Dist: click>=8.0.0
25
25
  Requires-Dist: boto3>=1.26.0
26
+ Requires-Dist: requests>=2.28.0
26
27
  Dynamic: author
27
28
  Dynamic: classifier
28
29
  Dynamic: description
@@ -48,36 +49,51 @@ pip install -e .
48
49
 
49
50
  ## Quick Start
50
51
 
51
- ### 1. Login to AWS SSO
52
+ ### Authentication Methods
52
53
 
54
+ The CLI supports three authentication methods:
55
+
56
+ #### 1. SSO (Recommended)
53
57
  ```bash
54
- aws sso login --profile my_aws_profile
58
+ # The CLI will automatically trigger SSO login if needed
59
+ cc calculate --profile khoros --sso khoros_umbrella
55
60
  ```
56
61
 
57
- **Note:** You need to do this before running cost calculations. The SSO session typically lasts 8-12 hours.
58
-
59
- ### 2. Initialize a profile
62
+ #### 2. Static Credentials
63
+ ```bash
64
+ cc calculate --profile khoros \
65
+ --access-key-id ASIA3D3QOXPO6EBAPXVI \
66
+ --secret-access-key /9ijZEUoszN/S2A8IlCrHpW+1fMZ7aUb7fPvU0dL \
67
+ --session-token IQoJb3JpZ2luX2VjENr...
68
+ ```
60
69
 
70
+ #### 3. Environment Variables
61
71
  ```bash
62
- cc init --profile myprofile \
63
- --aws-profile my_aws_profile \
64
- --accounts "123456789012,234567890123,345678901234"
72
+ # For SSO
73
+ export AWS_PROFILE=khoros_umbrella
74
+ cc calculate --profile khoros
75
+
76
+ # For static credentials
77
+ export AWS_ACCESS_KEY_ID=ASIA...
78
+ export AWS_SECRET_ACCESS_KEY=...
79
+ export AWS_SESSION_TOKEN=...
80
+ cc calculate --profile khoros
65
81
  ```
66
82
 
67
- ### 3. Calculate costs
83
+ ### Basic Usage
68
84
 
69
85
  ```bash
70
86
  # Default: Today minus 2 days, going back 30 days
71
- cc calculate --profile myprofile
87
+ cc calculate --profile khoros --sso khoros_umbrella
72
88
 
73
89
  # Specific start date
74
- cc calculate --profile myprofile --start-date 2025-11-04
90
+ cc calculate --profile khoros --sso khoros_umbrella --start-date 2025-11-04
75
91
 
76
92
  # Custom offset and window
77
- cc calculate --profile myprofile --offset 2 --window 30
93
+ cc calculate --profile khoros --sso khoros_umbrella --offset 2 --window 30
78
94
 
79
95
  # JSON output
80
- cc calculate --profile myprofile --json-output
96
+ cc calculate --profile khoros --sso khoros_umbrella --json-output
81
97
  ```
82
98
 
83
99
  ### 4. Analyze cost trends
@@ -241,14 +257,17 @@ The `drill` command allows you to investigate cost changes at different levels o
241
257
  3. **Drill deeper:** `cc drill --service "EC2 - Other" --account 123` → See usage types
242
258
 
243
259
  **Features:**
244
- - **Automatic grouping**: Shows the next level of detail based on your filters
245
- - No filters Shows services
246
- - Service only Shows accounts using that service
247
- - Account only Shows services in that account
248
- - Service + Account Shows usage types
249
- - **Top 10 Increases/Decreases**: For each week comparison
250
- - **Total rows**: Sum of top 10 changes
251
- - **Filters**: Only shows changes >$10 and >5%
260
+ - **Week-over-week cost analysis**: Compare costs between consecutive weeks
261
+ - **Month-over-month cost analysis**: Compare costs between consecutive months
262
+ - **Drill-down analysis**: Analyze costs by service, account, or usage type
263
+ - **Pandas aggregations**: Time series analysis with sum, avg, std across all weeks
264
+ - **Volatility detection**: Identify services with high cost variability and outliers
265
+ - **Trend detection**: Auto-detect increasing/decreasing cost patterns
266
+ - **Search & filter**: Find services by pattern or cost threshold
267
+ - **Profile management**: CRUD operations for account profiles in DynamoDB
268
+ - **Markdown reports**: Generate formatted reports
269
+ - **JSON output**: Machine-readable output for automation
270
+ - **Lambda API backend**: Serverless backend with pandas/numpy support
252
271
 
253
272
  Example output:
254
273
  ```
@@ -0,0 +1,13 @@
1
+ aws_cost_calculator_cli-1.6.0.dist-info/licenses/LICENSE,sha256=cYtmQZHNGGTXOtg3T7LHDRneleaH0dHXHfxFV3WR50Y,1079
2
+ cost_calculator/__init__.py,sha256=PJeIqvWh5AYJVrJxPPkI4pJnAt37rIjasrNS0I87kaM,52
3
+ cost_calculator/api_client.py,sha256=LUzQmveDF0X9MqAyThp9mbSzJzkOO73Pk4F7IEJjASU,2353
4
+ cost_calculator/cli.py,sha256=sJYvzbdHCxOEcCgOjZs4o9MOogV1Yh8r7x0hJtd__K0,38639
5
+ cost_calculator/drill.py,sha256=hGi-prLgZDvNMMICQc4fl3LenM7YaZ3To_Ei4LKwrdc,10543
6
+ cost_calculator/executor.py,sha256=tVyyBtXIj9OPyG-xQj8CUmyFjDhb9IVK639360dUZDc,8076
7
+ cost_calculator/monthly.py,sha256=6k9F8S7djhX1wGV3-T1MZP7CvWbbfhSTEaddwCfVu5M,7932
8
+ cost_calculator/trends.py,sha256=k_s4ylBX50sqoiM_fwepi58HW01zz767FMJhQUPDznk,12246
9
+ aws_cost_calculator_cli-1.6.0.dist-info/METADATA,sha256=AaLbUH-anBc1lv2o2DpdJR3v0tSavhPtDo5Sjb7VsHA,8612
10
+ aws_cost_calculator_cli-1.6.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
11
+ aws_cost_calculator_cli-1.6.0.dist-info/entry_points.txt,sha256=_5Qy4EcHbYVYrdgOu1E48faMHb9fLUl5VJ3djDHuJBo,47
12
+ aws_cost_calculator_cli-1.6.0.dist-info/top_level.txt,sha256=PRwGPPlNqASfyhGHDjSfyl4SXeE7GF3OVTu1tY1Uqyc,16
13
+ aws_cost_calculator_cli-1.6.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.7.1)
2
+ Generator: setuptools (80.9.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -0,0 +1,84 @@
1
+ """
2
+ API client for calling Lambda backend.
3
+ Falls back to local execution if API is not configured.
4
+ """
5
+ import os
6
+ import json
7
+ import requests
8
+ from pathlib import Path
9
+
10
+
11
+ def get_api_config():
12
+ """
13
+ Get API configuration.
14
+
15
+ Returns:
16
+ dict: API configuration with api_secret, or None if not configured
17
+ """
18
+ api_secret = os.environ.get('COST_API_SECRET', '')
19
+
20
+ if api_secret:
21
+ return {'api_secret': api_secret}
22
+
23
+ return None
24
+
25
+
26
+ def call_lambda_api(endpoint, credentials, accounts, **kwargs):
27
+ """
28
+ Call Lambda API endpoint.
29
+
30
+ Args:
31
+ endpoint: API endpoint name ('trends', 'monthly', 'drill')
32
+ credentials: dict with AWS credentials
33
+ accounts: list of account IDs
34
+ **kwargs: additional parameters for the specific endpoint
35
+
36
+ Returns:
37
+ dict: API response data
38
+
39
+ Raises:
40
+ Exception: if API call fails
41
+ """
42
+ api_config = get_api_config()
43
+
44
+ if not api_config:
45
+ raise Exception("API not configured. Set COST_API_SECRET environment variable.")
46
+
47
+ # Map endpoint names to Lambda URLs
48
+ endpoint_urls = {
49
+ 'trends': 'https://pq3mqntc6vuwi4zw5flulsoleq0yiqtl.lambda-url.us-east-1.on.aws/',
50
+ 'monthly': 'https://6aueebodw6q4zdeu3aaexb6tle0fqhhr.lambda-url.us-east-1.on.aws/',
51
+ 'drill': 'https://3ncm2gzxrsyptrhud3ua3x5lju0akvsr.lambda-url.us-east-1.on.aws/',
52
+ 'analyze': 'https://y6npmidtxwzg62nrqzkbacfs5q0edwgs.lambda-url.us-east-1.on.aws/',
53
+ 'profiles': 'https://64g7jq7sjygec2zmll5lsghrpi0txrzo.lambda-url.us-east-1.on.aws/'
54
+ }
55
+
56
+ url = endpoint_urls.get(endpoint)
57
+
58
+ if not url:
59
+ raise Exception(f"Unknown endpoint: {endpoint}")
60
+
61
+ # Build request payload
62
+ payload = {
63
+ 'credentials': credentials,
64
+ 'accounts': accounts
65
+ }
66
+ payload.update(kwargs)
67
+
68
+ # Make API call
69
+ headers = {
70
+ 'X-API-Secret': api_config['api_secret'],
71
+ 'Content-Type': 'application/json'
72
+ }
73
+
74
+ response = requests.post(url, headers=headers, json=payload, timeout=300)
75
+
76
+ if response.status_code != 200:
77
+ raise Exception(f"API call failed: {response.status_code} - {response.text}")
78
+
79
+ return response.json()
80
+
81
+
82
+ def is_api_configured():
83
+ """Check if API is configured."""
84
+ return get_api_config() is not None
cost_calculator/cli.py CHANGED
@@ -13,54 +13,149 @@ import boto3
13
13
  import json
14
14
  from datetime import datetime, timedelta
15
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
16
+ from cost_calculator.trends import format_trends_markdown
17
+ from cost_calculator.monthly import format_monthly_markdown
18
+ from cost_calculator.drill import format_drill_down_markdown
19
+ from cost_calculator.executor import execute_trends, execute_monthly, execute_drill
20
+
21
+
22
+ def apply_auth_options(config, sso=None, access_key_id=None, secret_access_key=None, session_token=None):
23
+ """Apply authentication options to profile config
24
+
25
+ Args:
26
+ config: Profile configuration dict
27
+ sso: AWS SSO profile name
28
+ access_key_id: AWS Access Key ID
29
+ secret_access_key: AWS Secret Access Key
30
+ session_token: AWS Session Token
31
+
32
+ Returns:
33
+ Updated config dict
34
+ """
35
+ import subprocess
36
+
37
+ if sso:
38
+ # SSO authentication - trigger login if needed
39
+ try:
40
+ # Test if SSO session is valid
41
+ result = subprocess.run(
42
+ ['aws', 'sts', 'get-caller-identity', '--profile', sso],
43
+ capture_output=True,
44
+ text=True,
45
+ timeout=5
46
+ )
47
+ if result.returncode != 0:
48
+ if 'expired' in result.stderr.lower() or 'token' in result.stderr.lower():
49
+ click.echo(f"SSO session expired or not initialized. Logging in...")
50
+ subprocess.run(['aws', 'sso', 'login', '--profile', sso], check=True)
51
+ except Exception as e:
52
+ click.echo(f"Warning: Could not verify SSO session: {e}")
53
+
54
+ config['aws_profile'] = sso
55
+ elif access_key_id and secret_access_key:
56
+ # Static credentials provided via CLI
57
+ config['credentials'] = {
58
+ 'aws_access_key_id': access_key_id,
59
+ 'aws_secret_access_key': secret_access_key,
60
+ 'region': 'us-east-1'
61
+ }
62
+ if session_token:
63
+ config['credentials']['aws_session_token'] = session_token
64
+
65
+ return config
19
66
 
20
67
 
21
68
  def load_profile(profile_name):
22
- """Load profile configuration from ~/.config/cost-calculator/profiles.json"""
69
+ """Load profile configuration from local file or DynamoDB API"""
70
+ import os
71
+ import requests
72
+
23
73
  config_dir = Path.home() / '.config' / 'cost-calculator'
24
74
  config_file = config_dir / 'profiles.json'
25
75
  creds_file = config_dir / 'credentials.json'
26
76
 
27
- if not config_file.exists():
77
+ # Try local file first
78
+ if config_file.exists():
79
+ with open(config_file) as f:
80
+ profiles = json.load(f)
81
+
82
+ if profile_name in profiles:
83
+ profile = profiles[profile_name]
84
+
85
+ # Load credentials if using static credentials (not SSO)
86
+ if 'aws_profile' not in profile:
87
+ if not creds_file.exists():
88
+ # Try environment variables
89
+ if os.environ.get('AWS_ACCESS_KEY_ID'):
90
+ profile['credentials'] = {
91
+ 'aws_access_key_id': os.environ['AWS_ACCESS_KEY_ID'],
92
+ 'aws_secret_access_key': os.environ['AWS_SECRET_ACCESS_KEY'],
93
+ 'aws_session_token': os.environ.get('AWS_SESSION_TOKEN')
94
+ }
95
+ return profile
96
+
97
+ raise click.ClickException(
98
+ f"No credentials found for profile '{profile_name}'.\n"
99
+ f"Run: cc configure --profile {profile_name}"
100
+ )
101
+
102
+ with open(creds_file) as f:
103
+ creds = json.load(f)
104
+
105
+ if profile_name not in creds:
106
+ raise click.ClickException(
107
+ f"No credentials found for profile '{profile_name}'.\n"
108
+ f"Run: cc configure --profile {profile_name}"
109
+ )
110
+
111
+ profile['credentials'] = creds[profile_name]
112
+
113
+ return profile
114
+
115
+ # Profile not found locally - try DynamoDB API
116
+ api_secret = os.environ.get('COST_API_SECRET')
117
+ if not api_secret:
28
118
  raise click.ClickException(
29
- f"Profile configuration not found at {config_file}\n"
119
+ f"Profile '{profile_name}' not found locally and COST_API_SECRET not set.\n"
30
120
  f"Run: cc init --profile {profile_name}"
31
121
  )
32
122
 
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())}"
123
+ try:
124
+ response = requests.post(
125
+ 'https://64g7jq7sjygec2zmll5lsghrpi0txrzo.lambda-url.us-east-1.on.aws/',
126
+ headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
127
+ json={'operation': 'get', 'profile_name': profile_name},
128
+ timeout=10
40
129
  )
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
130
 
55
- if profile_name not in creds:
131
+ if response.status_code == 200:
132
+ response_data = response.json()
133
+ # API returns {"profile": {...}} wrapper
134
+ profile_data = response_data.get('profile', response_data)
135
+ profile = {'accounts': profile_data['accounts']}
136
+
137
+ # Check for AWS_PROFILE environment variable (SSO support)
138
+ if os.environ.get('AWS_PROFILE'):
139
+ profile['aws_profile'] = os.environ['AWS_PROFILE']
140
+ # Use environment credentials
141
+ elif os.environ.get('AWS_ACCESS_KEY_ID'):
142
+ profile['credentials'] = {
143
+ 'aws_access_key_id': os.environ['AWS_ACCESS_KEY_ID'],
144
+ 'aws_secret_access_key': os.environ['AWS_SECRET_ACCESS_KEY'],
145
+ 'aws_session_token': os.environ.get('AWS_SESSION_TOKEN')
146
+ }
147
+
148
+ return profile
149
+ else:
56
150
  raise click.ClickException(
57
- f"No credentials found for profile '{profile_name}'.\n"
58
- f"Run: cc configure --profile {profile_name}"
151
+ f"Profile '{profile_name}' not found in DynamoDB.\n"
152
+ f"Run: cc profile create --name {profile_name} --accounts \"...\""
59
153
  )
60
-
61
- profile['credentials'] = creds[profile_name]
62
-
63
- return profile
154
+ except requests.exceptions.RequestException as e:
155
+ raise click.ClickException(
156
+ f"Failed to fetch profile from API: {e}\n"
157
+ f"Run: cc init --profile {profile_name}"
158
+ )
64
159
 
65
160
 
66
161
  def calculate_costs(profile_config, accounts, start_date, offset, window):
@@ -295,12 +390,30 @@ def cli():
295
390
  @click.option('--offset', default=2, help='Days to go back from start date (default: 2)')
296
391
  @click.option('--window', default=30, help='Number of days to analyze (default: 30)')
297
392
  @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"""
393
+ @click.option('--sso', help='AWS SSO profile name (e.g., khoros_umbrella)')
394
+ @click.option('--access-key-id', help='AWS Access Key ID (for static credentials)')
395
+ @click.option('--secret-access-key', help='AWS Secret Access Key (for static credentials)')
396
+ @click.option('--session-token', help='AWS Session Token (for static credentials)')
397
+ def calculate(profile, start_date, offset, window, json_output, sso, access_key_id, secret_access_key, session_token):
398
+ """Calculate AWS costs for the specified period
399
+
400
+ \b
401
+ Authentication Options:
402
+ 1. SSO: --sso <profile_name>
403
+ Example: cc calculate --profile khoros --sso khoros_umbrella
404
+
405
+ 2. Static Credentials: --access-key-id, --secret-access-key, --session-token
406
+ Example: cc calculate --profile khoros --access-key-id ASIA... --secret-access-key ... --session-token ...
407
+
408
+ 3. Environment Variables: AWS_PROFILE or AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY
409
+ """
300
410
 
301
411
  # Load profile configuration
302
412
  config = load_profile(profile)
303
413
 
414
+ # Apply authentication options
415
+ config = apply_auth_options(config, sso, access_key_id, secret_access_key, session_token)
416
+
304
417
  # Calculate costs
305
418
  result = calculate_costs(
306
419
  profile_config=config,
@@ -546,55 +659,23 @@ def configure(profile, access_key_id, secret_access_key, session_token, region):
546
659
  @click.option('--profile', required=True, help='Profile name')
547
660
  @click.option('--weeks', default=3, help='Number of weeks to analyze (default: 3)')
548
661
  @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):
662
+ @click.option('--json-output', is_flag=True, help='Output as JSON')
663
+ @click.option('--sso', help='AWS SSO profile name')
664
+ @click.option('--access-key-id', help='AWS Access Key ID')
665
+ @click.option('--secret-access-key', help='AWS Secret Access Key')
666
+ @click.option('--session-token', help='AWS Session Token')
667
+ def trends(profile, weeks, output, json_output, sso, access_key_id, secret_access_key, session_token):
551
668
  """Analyze cost trends with Week-over-Week and Trailing 30-Day comparisons"""
552
669
 
553
670
  # Load profile configuration
554
671
  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
672
+ config = apply_auth_options(config, sso, access_key_id, secret_access_key, session_token)
592
673
 
593
674
  click.echo(f"Analyzing last {weeks} weeks...")
594
675
  click.echo("")
595
676
 
596
- # Analyze trends
597
- trends_data = analyze_trends(ce_client, config['accounts'], num_weeks=weeks)
677
+ # Execute via API or locally
678
+ trends_data = execute_trends(config, weeks)
598
679
 
599
680
  if json_output:
600
681
  # Output as JSON
@@ -649,41 +730,23 @@ def trends(profile, weeks, output, json_output):
649
730
  @click.option('--profile', required=True, help='Profile name')
650
731
  @click.option('--months', default=6, help='Number of months to analyze (default: 6)')
651
732
  @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):
733
+ @click.option('--json-output', is_flag=True, help='Output as JSON')
734
+ @click.option('--sso', help='AWS SSO profile name')
735
+ @click.option('--access-key-id', help='AWS Access Key ID')
736
+ @click.option('--secret-access-key', help='AWS Secret Access Key')
737
+ @click.option('--session-token', help='AWS Session Token')
738
+ def monthly(profile, months, output, json_output, sso, access_key_id, secret_access_key, session_token):
654
739
  """Analyze month-over-month cost trends at service level"""
655
740
 
656
- # Load profile configuration
741
+ # Load profile
657
742
  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']
743
+ config = apply_auth_options(config, sso, access_key_id, secret_access_key, session_token)
681
744
 
682
745
  click.echo(f"Analyzing last {months} months...")
683
746
  click.echo("")
684
747
 
685
- # Analyze monthly trends
686
- monthly_data = analyze_monthly_trends(ce_client, accounts, months)
748
+ # Execute via API or locally
749
+ monthly_data = execute_monthly(config, months)
687
750
 
688
751
  if json_output:
689
752
  # Output as JSON
@@ -739,35 +802,17 @@ def monthly(profile, months, output, json_output):
739
802
  @click.option('--account', help='Filter by account ID')
740
803
  @click.option('--usage-type', help='Filter by usage type')
741
804
  @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):
805
+ @click.option('--json-output', is_flag=True, help='Output as JSON')
806
+ @click.option('--sso', help='AWS SSO profile name')
807
+ @click.option('--access-key-id', help='AWS Access Key ID')
808
+ @click.option('--secret-access-key', help='AWS Secret Access Key')
809
+ @click.option('--session-token', help='AWS Session Token')
810
+ def drill(profile, weeks, service, account, usage_type, output, json_output, sso, access_key_id, secret_access_key, session_token):
744
811
  """Drill down into cost changes by service, account, or usage type"""
745
812
 
746
- # Load profile configuration
813
+ # Load profile
747
814
  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']
815
+ config = apply_auth_options(config, sso, access_key_id, secret_access_key, session_token)
771
816
 
772
817
  # Show filters
773
818
  click.echo(f"Analyzing last {weeks} weeks...")
@@ -779,13 +824,8 @@ def drill(profile, weeks, service, account, usage_type, output, json_output):
779
824
  click.echo(f" Usage type filter: {usage_type}")
780
825
  click.echo("")
781
826
 
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
- )
827
+ # Execute via API or locally
828
+ drill_data = execute_drill(config, weeks, service, account, usage_type)
789
829
 
790
830
  if json_output:
791
831
  # Output as JSON
@@ -844,5 +884,119 @@ def drill(profile, weeks, service, account, usage_type, output, json_output):
844
884
  click.echo("")
845
885
 
846
886
 
887
+ @cli.command()
888
+ @click.option('--profile', required=True, help='Profile name')
889
+ @click.option('--type', 'analysis_type', default='summary',
890
+ type=click.Choice(['summary', 'volatility', 'trends', 'search']),
891
+ help='Analysis type')
892
+ @click.option('--weeks', default=12, help='Number of weeks (default: 12)')
893
+ @click.option('--pattern', help='Service search pattern (for search type)')
894
+ @click.option('--min-cost', type=float, help='Minimum cost filter (for search type)')
895
+ @click.option('--json-output', is_flag=True, help='Output as JSON')
896
+ def analyze(profile, analysis_type, weeks, pattern, min_cost, json_output):
897
+ """Perform pandas-based analysis (aggregations, volatility, trends, search)"""
898
+
899
+ config = load_profile(profile)
900
+
901
+ if not json_output:
902
+ click.echo(f"Running {analysis_type} analysis for {weeks} weeks...")
903
+
904
+ from cost_calculator.executor import execute_analyze
905
+ result = execute_analyze(config, weeks, analysis_type, pattern, min_cost)
906
+
907
+ if json_output:
908
+ import json
909
+ click.echo(json.dumps(result, indent=2, default=str))
910
+ else:
911
+ # Format output based on type
912
+ if analysis_type == 'summary':
913
+ click.echo(f"\n📊 Summary ({result.get('total_services', 0)} services)")
914
+ click.echo(f"Weeks analyzed: {result.get('weeks_analyzed', 0)}")
915
+ click.echo(f"\nTop 10 Services (by total change):")
916
+ for svc in result.get('services', [])[:10]:
917
+ click.echo(f" {svc['service']}")
918
+ click.echo(f" Total: ${svc['change_sum']:,.2f}")
919
+ click.echo(f" Average: ${svc['change_mean']:,.2f}")
920
+ click.echo(f" Volatility: {svc['volatility']:.3f}")
921
+
922
+ elif analysis_type == 'volatility':
923
+ click.echo(f"\n📈 High Volatility Services:")
924
+ for svc in result.get('high_volatility_services', [])[:10]:
925
+ click.echo(f" {svc['service']}: CV={svc['coefficient_of_variation']:.3f}")
926
+
927
+ outliers = result.get('outliers', [])
928
+ if outliers:
929
+ click.echo(f"\n⚠️ Outliers ({len(outliers)}):")
930
+ for o in outliers[:5]:
931
+ click.echo(f" {o['service']} ({o['week']}): ${o['change']:,.2f} (z={o['z_score']:.2f})")
932
+
933
+ elif analysis_type == 'trends':
934
+ inc = result.get('increasing_trends', [])
935
+ dec = result.get('decreasing_trends', [])
936
+
937
+ click.echo(f"\n📈 Increasing Trends ({len(inc)}):")
938
+ for t in inc[:5]:
939
+ click.echo(f" {t['service']}: ${t['avg_change']:,.2f}/week")
940
+
941
+ click.echo(f"\n📉 Decreasing Trends ({len(dec)}):")
942
+ for t in dec[:5]:
943
+ click.echo(f" {t['service']}: ${t['avg_change']:,.2f}/week")
944
+
945
+ elif analysis_type == 'search':
946
+ matches = result.get('matches', [])
947
+ click.echo(f"\n🔍 Search Results ({len(matches)} matches)")
948
+ if pattern:
949
+ click.echo(f"Pattern: {pattern}")
950
+ if min_cost:
951
+ click.echo(f"Min cost: ${min_cost:,.2f}")
952
+
953
+ for m in matches[:20]:
954
+ click.echo(f" {m['service']}: ${m['curr_cost']:,.2f}")
955
+
956
+
957
+ @cli.command()
958
+ @click.argument('operation', type=click.Choice(['list', 'get', 'create', 'update', 'delete']))
959
+ @click.option('--name', help='Profile name')
960
+ @click.option('--accounts', help='Comma-separated account IDs')
961
+ @click.option('--description', help='Profile description')
962
+ def profile(operation, name, accounts, description):
963
+ """Manage profiles (CRUD operations)"""
964
+
965
+ from cost_calculator.executor import execute_profile_operation
966
+
967
+ # Parse accounts if provided
968
+ account_list = None
969
+ if accounts:
970
+ account_list = [a.strip() for a in accounts.split(',')]
971
+
972
+ result = execute_profile_operation(
973
+ operation=operation,
974
+ profile_name=name,
975
+ accounts=account_list,
976
+ description=description
977
+ )
978
+
979
+ if operation == 'list':
980
+ profiles = result.get('profiles', [])
981
+ click.echo(f"\n📋 Profiles ({len(profiles)}):")
982
+ for p in profiles:
983
+ click.echo(f" {p['profile_name']}: {len(p.get('accounts', []))} accounts")
984
+ if p.get('description'):
985
+ click.echo(f" {p['description']}")
986
+
987
+ elif operation == 'get':
988
+ profile_data = result.get('profile', {})
989
+ click.echo(f"\n📋 Profile: {profile_data.get('profile_name')}")
990
+ click.echo(f"Accounts: {len(profile_data.get('accounts', []))}")
991
+ if profile_data.get('description'):
992
+ click.echo(f"Description: {profile_data['description']}")
993
+ click.echo(f"\nAccounts:")
994
+ for acc in profile_data.get('accounts', []):
995
+ click.echo(f" {acc}")
996
+
997
+ else:
998
+ click.echo(result.get('message', 'Operation completed'))
999
+
1000
+
847
1001
  if __name__ == '__main__':
848
1002
  cli()
@@ -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()
@@ -1,11 +0,0 @@
1
- aws_cost_calculator_cli-1.3.0.dist-info/licenses/LICENSE,sha256=cYtmQZHNGGTXOtg3T7LHDRneleaH0dHXHfxFV3WR50Y,1079
2
- cost_calculator/__init__.py,sha256=PJeIqvWh5AYJVrJxPPkI4pJnAt37rIjasrNS0I87kaM,52
3
- cost_calculator/cli.py,sha256=YJOWA-uWSMEZc4QHof8DF_An6VpKmTsxh0X_G31y1SM,31018
4
- cost_calculator/drill.py,sha256=hGi-prLgZDvNMMICQc4fl3LenM7YaZ3To_Ei4LKwrdc,10543
5
- cost_calculator/monthly.py,sha256=6k9F8S7djhX1wGV3-T1MZP7CvWbbfhSTEaddwCfVu5M,7932
6
- cost_calculator/trends.py,sha256=k_s4ylBX50sqoiM_fwepi58HW01zz767FMJhQUPDznk,12246
7
- aws_cost_calculator_cli-1.3.0.dist-info/METADATA,sha256=jZYbRF46p0qvjkw4Dqwja-4cnOJv59i7zTzmFXxfCsw,7761
8
- aws_cost_calculator_cli-1.3.0.dist-info/WHEEL,sha256=Nw36Djuh_5VDukK0H78QzOX-_FQEo6V37m3nkm96gtU,91
9
- aws_cost_calculator_cli-1.3.0.dist-info/entry_points.txt,sha256=_5Qy4EcHbYVYrdgOu1E48faMHb9fLUl5VJ3djDHuJBo,47
10
- aws_cost_calculator_cli-1.3.0.dist-info/top_level.txt,sha256=PRwGPPlNqASfyhGHDjSfyl4SXeE7GF3OVTu1tY1Uqyc,16
11
- aws_cost_calculator_cli-1.3.0.dist-info/RECORD,,