aws-cost-calculator-cli 1.4.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aws-cost-calculator-cli
3
- Version: 1.4.0
3
+ Version: 1.5.2
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
@@ -242,14 +242,17 @@ The `drill` command allows you to investigate cost changes at different levels o
242
242
  3. **Drill deeper:** `cc drill --service "EC2 - Other" --account 123` → See usage types
243
243
 
244
244
  **Features:**
245
- - **Automatic grouping**: Shows the next level of detail based on your filters
246
- - No filters Shows services
247
- - Service only Shows accounts using that service
248
- - Account only Shows services in that account
249
- - Service + Account Shows usage types
250
- - **Top 10 Increases/Decreases**: For each week comparison
251
- - **Total rows**: Sum of top 10 changes
252
- - **Filters**: Only shows changes >$10 and >5%
245
+ - **Week-over-week cost analysis**: Compare costs between consecutive weeks
246
+ - **Month-over-month cost analysis**: Compare costs between consecutive months
247
+ - **Drill-down analysis**: Analyze costs by service, account, or usage type
248
+ - **Pandas aggregations**: Time series analysis with sum, avg, std across all weeks
249
+ - **Volatility detection**: Identify services with high cost variability and outliers
250
+ - **Trend detection**: Auto-detect increasing/decreasing cost patterns
251
+ - **Search & filter**: Find services by pattern or cost threshold
252
+ - **Profile management**: CRUD operations for account profiles in DynamoDB
253
+ - **Markdown reports**: Generate formatted reports
254
+ - **JSON output**: Machine-readable output for automation
255
+ - **Lambda API backend**: Serverless backend with pandas/numpy support
253
256
 
254
257
  Example output:
255
258
  ```
@@ -0,0 +1,13 @@
1
+ aws_cost_calculator_cli-1.5.2.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=ufK28divdvrceEryWd8cCWjvG5pT2owaqprskX2epeQ,32589
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.5.2.dist-info/METADATA,sha256=0wxy-jgVC-paubGHN87mDObETGr_u9qU4ZIP3xV49hM,8176
10
+ aws_cost_calculator_cli-1.5.2.dist-info/WHEEL,sha256=Nw36Djuh_5VDukK0H78QzOX-_FQEo6V37m3nkm96gtU,91
11
+ aws_cost_calculator_cli-1.5.2.dist-info/entry_points.txt,sha256=_5Qy4EcHbYVYrdgOu1E48faMHb9fLUl5VJ3djDHuJBo,47
12
+ aws_cost_calculator_cli-1.5.2.dist-info/top_level.txt,sha256=PRwGPPlNqASfyhGHDjSfyl4SXeE7GF3OVTu1tY1Uqyc,16
13
+ aws_cost_calculator_cli-1.5.2.dist-info/RECORD,,
@@ -10,33 +10,15 @@ from pathlib import Path
10
10
 
11
11
  def get_api_config():
12
12
  """
13
- Get API configuration from environment or config file.
13
+ Get API configuration.
14
14
 
15
15
  Returns:
16
- dict with 'base_url' and 'api_secret', or None if not configured
16
+ dict: API configuration with api_secret, or None if not configured
17
17
  """
18
- # Try environment variables first
19
- base_url = os.environ.get('COST_API_URL')
20
- api_secret = os.environ.get('COST_API_SECRET')
18
+ api_secret = os.environ.get('COST_API_SECRET', '')
21
19
 
22
- if base_url and api_secret:
23
- return {
24
- 'base_url': base_url.rstrip('/'),
25
- 'api_secret': api_secret
26
- }
27
-
28
- # Try config file
29
- config_dir = Path.home() / '.config' / 'cost-calculator'
30
- api_config_file = config_dir / 'api_config.json'
31
-
32
- if api_config_file.exists():
33
- with open(api_config_file, 'r') as f:
34
- config = json.load(f)
35
- if 'base_url' in config and 'api_secret' in config:
36
- return {
37
- 'base_url': config['base_url'].rstrip('/'),
38
- 'api_secret': config['api_secret']
39
- }
20
+ if api_secret:
21
+ return {'api_secret': api_secret}
40
22
 
41
23
  return None
42
24
 
@@ -60,21 +42,18 @@ def call_lambda_api(endpoint, credentials, accounts, **kwargs):
60
42
  api_config = get_api_config()
61
43
 
62
44
  if not api_config:
63
- raise Exception("API not configured. Set COST_API_URL and COST_API_SECRET environment variables.")
45
+ raise Exception("API not configured. Set COST_API_SECRET environment variable.")
64
46
 
65
- # Map endpoint names to URLs
47
+ # Map endpoint names to Lambda URLs
66
48
  endpoint_urls = {
67
- 'trends': f"{api_config['base_url']}/trends",
68
- 'monthly': f"{api_config['base_url']}/monthly",
69
- 'drill': f"{api_config['base_url']}/drill"
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/'
70
54
  }
71
55
 
72
- # For the actual Lambda URLs (no path)
73
- if '/trends' not in api_config['base_url']:
74
- # Base URL is the function URL itself
75
- url = api_config['base_url']
76
- else:
77
- url = endpoint_urls.get(endpoint)
56
+ url = endpoint_urls.get(endpoint)
78
57
 
79
58
  if not url:
80
59
  raise Exception(f"Unknown endpoint: {endpoint}")
cost_calculator/cli.py CHANGED
@@ -757,5 +757,119 @@ def drill(profile, weeks, service, account, usage_type, output, json_output):
757
757
  click.echo("")
758
758
 
759
759
 
760
+ @cli.command()
761
+ @click.option('--profile', required=True, help='Profile name')
762
+ @click.option('--type', 'analysis_type', default='summary',
763
+ type=click.Choice(['summary', 'volatility', 'trends', 'search']),
764
+ help='Analysis type')
765
+ @click.option('--weeks', default=12, help='Number of weeks (default: 12)')
766
+ @click.option('--pattern', help='Service search pattern (for search type)')
767
+ @click.option('--min-cost', type=float, help='Minimum cost filter (for search type)')
768
+ @click.option('--json-output', is_flag=True, help='Output as JSON')
769
+ def analyze(profile, analysis_type, weeks, pattern, min_cost, json_output):
770
+ """Perform pandas-based analysis (aggregations, volatility, trends, search)"""
771
+
772
+ config = load_profile(profile)
773
+
774
+ if not json_output:
775
+ click.echo(f"Running {analysis_type} analysis for {weeks} weeks...")
776
+
777
+ from cost_calculator.executor import execute_analyze
778
+ result = execute_analyze(config, weeks, analysis_type, pattern, min_cost)
779
+
780
+ if json_output:
781
+ import json
782
+ click.echo(json.dumps(result, indent=2, default=str))
783
+ else:
784
+ # Format output based on type
785
+ if analysis_type == 'summary':
786
+ click.echo(f"\n📊 Summary ({result.get('total_services', 0)} services)")
787
+ click.echo(f"Weeks analyzed: {result.get('weeks_analyzed', 0)}")
788
+ click.echo(f"\nTop 10 Services (by total change):")
789
+ for svc in result.get('services', [])[:10]:
790
+ click.echo(f" {svc['service']}")
791
+ click.echo(f" Total: ${svc['change_sum']:,.2f}")
792
+ click.echo(f" Average: ${svc['change_mean']:,.2f}")
793
+ click.echo(f" Volatility: {svc['volatility']:.3f}")
794
+
795
+ elif analysis_type == 'volatility':
796
+ click.echo(f"\n📈 High Volatility Services:")
797
+ for svc in result.get('high_volatility_services', [])[:10]:
798
+ click.echo(f" {svc['service']}: CV={svc['coefficient_of_variation']:.3f}")
799
+
800
+ outliers = result.get('outliers', [])
801
+ if outliers:
802
+ click.echo(f"\n⚠️ Outliers ({len(outliers)}):")
803
+ for o in outliers[:5]:
804
+ click.echo(f" {o['service']} ({o['week']}): ${o['change']:,.2f} (z={o['z_score']:.2f})")
805
+
806
+ elif analysis_type == 'trends':
807
+ inc = result.get('increasing_trends', [])
808
+ dec = result.get('decreasing_trends', [])
809
+
810
+ click.echo(f"\n📈 Increasing Trends ({len(inc)}):")
811
+ for t in inc[:5]:
812
+ click.echo(f" {t['service']}: ${t['avg_change']:,.2f}/week")
813
+
814
+ click.echo(f"\n📉 Decreasing Trends ({len(dec)}):")
815
+ for t in dec[:5]:
816
+ click.echo(f" {t['service']}: ${t['avg_change']:,.2f}/week")
817
+
818
+ elif analysis_type == 'search':
819
+ matches = result.get('matches', [])
820
+ click.echo(f"\n🔍 Search Results ({len(matches)} matches)")
821
+ if pattern:
822
+ click.echo(f"Pattern: {pattern}")
823
+ if min_cost:
824
+ click.echo(f"Min cost: ${min_cost:,.2f}")
825
+
826
+ for m in matches[:20]:
827
+ click.echo(f" {m['service']}: ${m['curr_cost']:,.2f}")
828
+
829
+
830
+ @cli.command()
831
+ @click.argument('operation', type=click.Choice(['list', 'get', 'create', 'update', 'delete']))
832
+ @click.option('--name', help='Profile name')
833
+ @click.option('--accounts', help='Comma-separated account IDs')
834
+ @click.option('--description', help='Profile description')
835
+ def profile(operation, name, accounts, description):
836
+ """Manage profiles (CRUD operations)"""
837
+
838
+ from cost_calculator.executor import execute_profile_operation
839
+
840
+ # Parse accounts if provided
841
+ account_list = None
842
+ if accounts:
843
+ account_list = [a.strip() for a in accounts.split(',')]
844
+
845
+ result = execute_profile_operation(
846
+ operation=operation,
847
+ profile_name=name,
848
+ accounts=account_list,
849
+ description=description
850
+ )
851
+
852
+ if operation == 'list':
853
+ profiles = result.get('profiles', [])
854
+ click.echo(f"\n📋 Profiles ({len(profiles)}):")
855
+ for p in profiles:
856
+ click.echo(f" {p['profile_name']}: {len(p.get('accounts', []))} accounts")
857
+ if p.get('description'):
858
+ click.echo(f" {p['description']}")
859
+
860
+ elif operation == 'get':
861
+ profile_data = result.get('profile', {})
862
+ click.echo(f"\n📋 Profile: {profile_data.get('profile_name')}")
863
+ click.echo(f"Accounts: {len(profile_data.get('accounts', []))}")
864
+ if profile_data.get('description'):
865
+ click.echo(f"Description: {profile_data['description']}")
866
+ click.echo(f"\nAccounts:")
867
+ for acc in profile_data.get('accounts', []):
868
+ click.echo(f" {acc}")
869
+
870
+ else:
871
+ click.echo(result.get('message', 'Operation completed'))
872
+
873
+
760
874
  if __name__ == '__main__':
761
875
  cli()
@@ -11,22 +11,33 @@ def get_credentials_dict(config):
11
11
  Extract credentials from config in format needed for API.
12
12
 
13
13
  Returns:
14
- dict with access_key, secret_key, session_token
14
+ dict with access_key, secret_key, session_token, or None if profile is 'dummy'
15
15
  """
16
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()
17
+ # Skip credential loading for dummy profile (API-only mode)
18
+ if config['aws_profile'] == 'dummy':
19
+ return None
21
20
 
22
- return {
23
- 'access_key': frozen_creds.access_key,
24
- 'secret_key': frozen_creds.secret_key,
25
- 'session_token': frozen_creds.token
26
- }
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
27
35
  else:
28
36
  # Use static credentials
29
- creds = config['credentials']
37
+ creds = config.get('credentials', {})
38
+ if not creds:
39
+ return None
40
+
30
41
  result = {
31
42
  'access_key': creds['aws_access_key_id'],
32
43
  'secret_key': creds['aws_secret_access_key']
@@ -157,3 +168,74 @@ def execute_drill(config, weeks, service_filter=None, account_filter=None, usage
157
168
  account_filter=account_filter,
158
169
  usage_type_filter=usage_type_filter
159
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,13 +0,0 @@
1
- aws_cost_calculator_cli-1.4.0.dist-info/licenses/LICENSE,sha256=cYtmQZHNGGTXOtg3T7LHDRneleaH0dHXHfxFV3WR50Y,1079
2
- cost_calculator/__init__.py,sha256=PJeIqvWh5AYJVrJxPPkI4pJnAt37rIjasrNS0I87kaM,52
3
- cost_calculator/api_client.py,sha256=pSH2U0tOghDd3fisPcKqEJG3TghQYZi18HZmianHd6Y,2932
4
- cost_calculator/cli.py,sha256=qK6WQcAM5W15NciGOUtPpqcEvgyua5n5GRRysB4NPWw,27631
5
- cost_calculator/drill.py,sha256=hGi-prLgZDvNMMICQc4fl3LenM7YaZ3To_Ei4LKwrdc,10543
6
- cost_calculator/executor.py,sha256=RZ45GuA8tzKqj_pJaZ-BVSc8xbxxWTi4yCGcKqvNsUg,5601
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.4.0.dist-info/METADATA,sha256=LJkphxt8op0vOE_UJv-rGYRI4obstONupdOS8tYHGKg,7793
10
- aws_cost_calculator_cli-1.4.0.dist-info/WHEEL,sha256=Nw36Djuh_5VDukK0H78QzOX-_FQEo6V37m3nkm96gtU,91
11
- aws_cost_calculator_cli-1.4.0.dist-info/entry_points.txt,sha256=_5Qy4EcHbYVYrdgOu1E48faMHb9fLUl5VJ3djDHuJBo,47
12
- aws_cost_calculator_cli-1.4.0.dist-info/top_level.txt,sha256=PRwGPPlNqASfyhGHDjSfyl4SXeE7GF3OVTu1tY1Uqyc,16
13
- aws_cost_calculator_cli-1.4.0.dist-info/RECORD,,