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.
- {aws_cost_calculator_cli-1.0.3.dist-info → aws_cost_calculator_cli-1.5.2.dist-info}/METADATA +129 -2
- aws_cost_calculator_cli-1.5.2.dist-info/RECORD +13 -0
- cost_calculator/api_client.py +84 -0
- cost_calculator/cli.py +332 -0
- cost_calculator/drill.py +323 -0
- cost_calculator/executor.py +241 -0
- cost_calculator/monthly.py +242 -0
- cost_calculator/trends.py +353 -0
- aws_cost_calculator_cli-1.0.3.dist-info/RECORD +0 -8
- {aws_cost_calculator_cli-1.0.3.dist-info → aws_cost_calculator_cli-1.5.2.dist-info}/WHEEL +0 -0
- {aws_cost_calculator_cli-1.0.3.dist-info → aws_cost_calculator_cli-1.5.2.dist-info}/entry_points.txt +0 -0
- {aws_cost_calculator_cli-1.0.3.dist-info → aws_cost_calculator_cli-1.5.2.dist-info}/licenses/LICENSE +0 -0
- {aws_cost_calculator_cli-1.0.3.dist-info → aws_cost_calculator_cli-1.5.2.dist-info}/top_level.txt +0 -0
{aws_cost_calculator_cli-1.0.3.dist-info → aws_cost_calculator_cli-1.5.2.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aws-cost-calculator-cli
|
|
3
|
-
Version: 1.
|
|
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
|
|
@@ -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
|
|
@@ -80,7 +81,23 @@ cc calculate --profile myprofile --offset 2 --window 30
|
|
|
80
81
|
cc calculate --profile myprofile --json-output
|
|
81
82
|
```
|
|
82
83
|
|
|
83
|
-
### 4.
|
|
84
|
+
### 4. Analyze cost trends
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
# Analyze last 3 weeks (default)
|
|
88
|
+
cc trends --profile myprofile
|
|
89
|
+
|
|
90
|
+
# Analyze more weeks
|
|
91
|
+
cc trends --profile myprofile --weeks 5
|
|
92
|
+
|
|
93
|
+
# Custom output file
|
|
94
|
+
cc trends --profile myprofile --output weekly_trends.md
|
|
95
|
+
|
|
96
|
+
# JSON output
|
|
97
|
+
cc trends --profile myprofile --json-output
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### 5. List profiles
|
|
84
101
|
|
|
85
102
|
```bash
|
|
86
103
|
cc list-profiles
|
|
@@ -138,8 +155,118 @@ cc calculate --profile myprofile --json-output > costs.json
|
|
|
138
155
|
|
|
139
156
|
# Different window size
|
|
140
157
|
cc calculate --profile myprofile --window 60
|
|
158
|
+
|
|
159
|
+
# Weekly cost trends analysis
|
|
160
|
+
cc trends --profile myprofile
|
|
161
|
+
|
|
162
|
+
# Analyze last 8 weeks
|
|
163
|
+
cc trends --profile myprofile --weeks 8
|
|
164
|
+
|
|
165
|
+
# Monthly cost trends analysis
|
|
166
|
+
cc monthly --profile myprofile
|
|
167
|
+
|
|
168
|
+
# Analyze last 12 months
|
|
169
|
+
cc monthly --profile myprofile --months 12
|
|
170
|
+
|
|
171
|
+
# Drill down into specific service
|
|
172
|
+
cc drill --profile myprofile --service "EC2 - Other"
|
|
173
|
+
|
|
174
|
+
# Drill down into specific account
|
|
175
|
+
cc drill --profile myprofile --account 123456789012
|
|
176
|
+
|
|
177
|
+
# Drill down into service within account
|
|
178
|
+
cc drill --profile myprofile --service "EC2 - Other" --account 123456789012
|
|
141
179
|
```
|
|
142
180
|
|
|
181
|
+
## Trends Report
|
|
182
|
+
|
|
183
|
+
The `trends` command generates a markdown report with **two types of analysis**:
|
|
184
|
+
|
|
185
|
+
### 1. Week-over-Week (WoW)
|
|
186
|
+
Compares each week to the previous week - good for catching immediate spikes and changes.
|
|
187
|
+
|
|
188
|
+
### 2. Trailing 30-Day (T-30)
|
|
189
|
+
Compares each week to the same week 4 weeks ago - filters out noise and shows sustained trends.
|
|
190
|
+
|
|
191
|
+
**Features:**
|
|
192
|
+
- **Service-level aggregation**: Shows total cost per service (not individual usage types)
|
|
193
|
+
- **Top 10 Increases/Decreases**: For each comparison period
|
|
194
|
+
- **Total rows**: Sum of top 10 changes for quick assessment
|
|
195
|
+
- **Filters**: Only shows changes >$10 and >5%
|
|
196
|
+
|
|
197
|
+
Example output:
|
|
198
|
+
```
|
|
199
|
+
Week of Oct 19 → Week of Oct 26 (WoW)
|
|
200
|
+
Increases: 4, Decreases: 10
|
|
201
|
+
Top: EC2 - Other (+$949.12)
|
|
202
|
+
|
|
203
|
+
Week of Oct 26 vs Week of Sep 28 (T-30)
|
|
204
|
+
Increases: 10, Decreases: 10
|
|
205
|
+
Top: EC2 - Other (+$886.39)
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
The report is saved to `cost_trends.md` by default and includes:
|
|
209
|
+
- Service name
|
|
210
|
+
- Previous/baseline cost
|
|
211
|
+
- Current cost
|
|
212
|
+
- Change amount and percentage
|
|
213
|
+
- Total of top 10 changes
|
|
214
|
+
|
|
215
|
+
## Monthly Report
|
|
216
|
+
|
|
217
|
+
The `monthly` command generates month-over-month cost comparisons:
|
|
218
|
+
|
|
219
|
+
**Features:**
|
|
220
|
+
- **Service-level aggregation**: Shows total cost per service
|
|
221
|
+
- **Calendar month comparisons**: October vs September, September vs August, etc.
|
|
222
|
+
- **Top 10 Increases/Decreases**: For each month comparison
|
|
223
|
+
- **Total rows**: Sum of top 10 changes
|
|
224
|
+
- **Filters**: Only shows changes >$50 and >5%
|
|
225
|
+
|
|
226
|
+
Example output:
|
|
227
|
+
```
|
|
228
|
+
October 2025 → November 2025
|
|
229
|
+
Increases: 1, Decreases: 10
|
|
230
|
+
Top: Savings Plans for AWS Compute usage (+$231,161.46)
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
The report is saved to `monthly_trends.md` by default.
|
|
234
|
+
|
|
235
|
+
## Drill-Down Analysis
|
|
236
|
+
|
|
237
|
+
The `drill` command allows you to investigate cost changes at different levels of detail:
|
|
238
|
+
|
|
239
|
+
**Funnel Approach:**
|
|
240
|
+
1. **Start broad:** `cc trends` → See EC2 costs up $1000
|
|
241
|
+
2. **Drill by service:** `cc drill --service "EC2 - Other"` → See which accounts
|
|
242
|
+
3. **Drill deeper:** `cc drill --service "EC2 - Other" --account 123` → See usage types
|
|
243
|
+
|
|
244
|
+
**Features:**
|
|
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
|
|
256
|
+
|
|
257
|
+
Example output:
|
|
258
|
+
```
|
|
259
|
+
# Drill by service
|
|
260
|
+
cc drill --profile myprofile --service "EC2 - Other"
|
|
261
|
+
|
|
262
|
+
Showing top accounts:
|
|
263
|
+
Week of Oct 19 → Week of Oct 26
|
|
264
|
+
Increases: 3, Decreases: 2
|
|
265
|
+
Top: 887649220066 (+$450.23)
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
The report is saved to `drill_down.md` by default.
|
|
269
|
+
|
|
143
270
|
## Output
|
|
144
271
|
|
|
145
272
|
```
|
|
@@ -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,,
|
|
@@ -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,6 +13,10 @@ 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 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
|
|
16
20
|
|
|
17
21
|
|
|
18
22
|
def load_profile(profile_name):
|
|
@@ -539,5 +543,333 @@ def configure(profile, access_key_id, secret_access_key, session_token, region):
|
|
|
539
543
|
click.echo(" you'll need to reconfigure when they expire.")
|
|
540
544
|
|
|
541
545
|
|
|
546
|
+
@cli.command()
|
|
547
|
+
@click.option('--profile', required=True, help='Profile name')
|
|
548
|
+
@click.option('--weeks', default=3, help='Number of weeks to analyze (default: 3)')
|
|
549
|
+
@click.option('--output', default='cost_trends.md', help='Output markdown file (default: cost_trends.md)')
|
|
550
|
+
@click.option('--json-output', is_flag=True, help='Output as JSON instead of markdown')
|
|
551
|
+
def trends(profile, weeks, output, json_output):
|
|
552
|
+
"""Analyze cost trends with Week-over-Week and Trailing 30-Day comparisons"""
|
|
553
|
+
|
|
554
|
+
# Load profile configuration
|
|
555
|
+
config = load_profile(profile)
|
|
556
|
+
|
|
557
|
+
click.echo(f"Analyzing last {weeks} weeks...")
|
|
558
|
+
click.echo("")
|
|
559
|
+
|
|
560
|
+
# Execute via API or locally
|
|
561
|
+
trends_data = execute_trends(config, weeks)
|
|
562
|
+
|
|
563
|
+
if json_output:
|
|
564
|
+
# Output as JSON
|
|
565
|
+
import json
|
|
566
|
+
click.echo(json.dumps(trends_data, indent=2, default=str))
|
|
567
|
+
else:
|
|
568
|
+
# Generate markdown report
|
|
569
|
+
markdown = format_trends_markdown(trends_data)
|
|
570
|
+
|
|
571
|
+
# Save to file
|
|
572
|
+
with open(output, 'w') as f:
|
|
573
|
+
f.write(markdown)
|
|
574
|
+
|
|
575
|
+
click.echo(f"✓ Trends report saved to {output}")
|
|
576
|
+
click.echo("")
|
|
577
|
+
|
|
578
|
+
# Show summary
|
|
579
|
+
click.echo("WEEK-OVER-WEEK:")
|
|
580
|
+
for comparison in trends_data['wow_comparisons']:
|
|
581
|
+
prev_week = comparison['prev_week']['label']
|
|
582
|
+
curr_week = comparison['curr_week']['label']
|
|
583
|
+
num_increases = len(comparison['increases'])
|
|
584
|
+
num_decreases = len(comparison['decreases'])
|
|
585
|
+
|
|
586
|
+
click.echo(f" {prev_week} → {curr_week}")
|
|
587
|
+
click.echo(f" Increases: {num_increases}, Decreases: {num_decreases}")
|
|
588
|
+
|
|
589
|
+
if comparison['increases']:
|
|
590
|
+
top = comparison['increases'][0]
|
|
591
|
+
click.echo(f" Top: {top['service']} (+${top['change']:,.2f})")
|
|
592
|
+
|
|
593
|
+
click.echo("")
|
|
594
|
+
|
|
595
|
+
click.echo("TRAILING 30-DAY (T-30):")
|
|
596
|
+
for comparison in trends_data['t30_comparisons']:
|
|
597
|
+
baseline_week = comparison['baseline_week']['label']
|
|
598
|
+
curr_week = comparison['curr_week']['label']
|
|
599
|
+
num_increases = len(comparison['increases'])
|
|
600
|
+
num_decreases = len(comparison['decreases'])
|
|
601
|
+
|
|
602
|
+
click.echo(f" {curr_week} vs {baseline_week}")
|
|
603
|
+
click.echo(f" Increases: {num_increases}, Decreases: {num_decreases}")
|
|
604
|
+
|
|
605
|
+
if comparison['increases']:
|
|
606
|
+
top = comparison['increases'][0]
|
|
607
|
+
click.echo(f" Top: {top['service']} (+${top['change']:,.2f})")
|
|
608
|
+
|
|
609
|
+
click.echo("")
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
@cli.command()
|
|
613
|
+
@click.option('--profile', required=True, help='Profile name')
|
|
614
|
+
@click.option('--months', default=6, help='Number of months to analyze (default: 6)')
|
|
615
|
+
@click.option('--output', default='monthly_trends.md', help='Output markdown file (default: monthly_trends.md)')
|
|
616
|
+
@click.option('--json-output', is_flag=True, help='Output as JSON instead of markdown')
|
|
617
|
+
def monthly(profile, months, output, json_output):
|
|
618
|
+
"""Analyze month-over-month cost trends at service level"""
|
|
619
|
+
|
|
620
|
+
# Load profile configuration
|
|
621
|
+
config = load_profile(profile)
|
|
622
|
+
|
|
623
|
+
click.echo(f"Analyzing last {months} months...")
|
|
624
|
+
click.echo("")
|
|
625
|
+
|
|
626
|
+
# Execute via API or locally
|
|
627
|
+
monthly_data = execute_monthly(config, months)
|
|
628
|
+
|
|
629
|
+
if json_output:
|
|
630
|
+
# Output as JSON
|
|
631
|
+
output_data = {
|
|
632
|
+
'generated': datetime.now().isoformat(),
|
|
633
|
+
'months': months,
|
|
634
|
+
'comparisons': []
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
for comparison in monthly_data['comparisons']:
|
|
638
|
+
output_data['comparisons'].append({
|
|
639
|
+
'prev_month': comparison['prev_month']['label'],
|
|
640
|
+
'curr_month': comparison['curr_month']['label'],
|
|
641
|
+
'increases': comparison['increases'],
|
|
642
|
+
'decreases': comparison['decreases'],
|
|
643
|
+
'total_increase': comparison['total_increase'],
|
|
644
|
+
'total_decrease': comparison['total_decrease']
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
click.echo(json.dumps(output_data, indent=2))
|
|
648
|
+
else:
|
|
649
|
+
# Generate markdown report
|
|
650
|
+
markdown = format_monthly_markdown(monthly_data)
|
|
651
|
+
|
|
652
|
+
# Save to file
|
|
653
|
+
with open(output, 'w') as f:
|
|
654
|
+
f.write(markdown)
|
|
655
|
+
|
|
656
|
+
click.echo(f"✓ Monthly trends report saved to {output}")
|
|
657
|
+
click.echo("")
|
|
658
|
+
|
|
659
|
+
# Show summary
|
|
660
|
+
for comparison in monthly_data['comparisons']:
|
|
661
|
+
prev_month = comparison['prev_month']['label']
|
|
662
|
+
curr_month = comparison['curr_month']['label']
|
|
663
|
+
num_increases = len(comparison['increases'])
|
|
664
|
+
num_decreases = len(comparison['decreases'])
|
|
665
|
+
|
|
666
|
+
click.echo(f"{prev_month} → {curr_month}")
|
|
667
|
+
click.echo(f" Increases: {num_increases}, Decreases: {num_decreases}")
|
|
668
|
+
|
|
669
|
+
if comparison['increases']:
|
|
670
|
+
top = comparison['increases'][0]
|
|
671
|
+
click.echo(f" Top: {top['service']} (+${top['change']:,.2f})")
|
|
672
|
+
|
|
673
|
+
click.echo("")
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
@cli.command()
|
|
677
|
+
@click.option('--profile', required=True, help='Profile name')
|
|
678
|
+
@click.option('--weeks', default=4, help='Number of weeks to analyze (default: 4)')
|
|
679
|
+
@click.option('--service', help='Filter by service name (e.g., "EC2 - Other")')
|
|
680
|
+
@click.option('--account', help='Filter by account ID')
|
|
681
|
+
@click.option('--usage-type', help='Filter by usage type')
|
|
682
|
+
@click.option('--output', default='drill_down.md', help='Output markdown file (default: drill_down.md)')
|
|
683
|
+
@click.option('--json-output', is_flag=True, help='Output as JSON instead of markdown')
|
|
684
|
+
def drill(profile, weeks, service, account, usage_type, output, json_output):
|
|
685
|
+
"""Drill down into cost changes by service, account, or usage type"""
|
|
686
|
+
|
|
687
|
+
# Load profile configuration
|
|
688
|
+
config = load_profile(profile)
|
|
689
|
+
|
|
690
|
+
# Show filters
|
|
691
|
+
click.echo(f"Analyzing last {weeks} weeks...")
|
|
692
|
+
if service:
|
|
693
|
+
click.echo(f" Service filter: {service}")
|
|
694
|
+
if account:
|
|
695
|
+
click.echo(f" Account filter: {account}")
|
|
696
|
+
if usage_type:
|
|
697
|
+
click.echo(f" Usage type filter: {usage_type}")
|
|
698
|
+
click.echo("")
|
|
699
|
+
|
|
700
|
+
# Execute via API or locally
|
|
701
|
+
drill_data = execute_drill(config, weeks, service, account, usage_type)
|
|
702
|
+
|
|
703
|
+
if json_output:
|
|
704
|
+
# Output as JSON
|
|
705
|
+
output_data = {
|
|
706
|
+
'generated': datetime.now().isoformat(),
|
|
707
|
+
'weeks': weeks,
|
|
708
|
+
'filters': drill_data['filters'],
|
|
709
|
+
'group_by': drill_data['group_by'],
|
|
710
|
+
'comparisons': []
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
for comparison in drill_data['comparisons']:
|
|
714
|
+
output_data['comparisons'].append({
|
|
715
|
+
'prev_week': comparison['prev_week']['label'],
|
|
716
|
+
'curr_week': comparison['curr_week']['label'],
|
|
717
|
+
'increases': comparison['increases'],
|
|
718
|
+
'decreases': comparison['decreases'],
|
|
719
|
+
'total_increase': comparison['total_increase'],
|
|
720
|
+
'total_decrease': comparison['total_decrease']
|
|
721
|
+
})
|
|
722
|
+
|
|
723
|
+
click.echo(json.dumps(output_data, indent=2))
|
|
724
|
+
else:
|
|
725
|
+
# Generate markdown report
|
|
726
|
+
markdown = format_drill_down_markdown(drill_data)
|
|
727
|
+
|
|
728
|
+
# Save to file
|
|
729
|
+
with open(output, 'w') as f:
|
|
730
|
+
f.write(markdown)
|
|
731
|
+
|
|
732
|
+
click.echo(f"✓ Drill-down report saved to {output}")
|
|
733
|
+
click.echo("")
|
|
734
|
+
|
|
735
|
+
# Show summary
|
|
736
|
+
group_by_label = {
|
|
737
|
+
'SERVICE': 'services',
|
|
738
|
+
'LINKED_ACCOUNT': 'accounts',
|
|
739
|
+
'USAGE_TYPE': 'usage types',
|
|
740
|
+
'REGION': 'regions'
|
|
741
|
+
}.get(drill_data['group_by'], 'items')
|
|
742
|
+
|
|
743
|
+
click.echo(f"Showing top {group_by_label}:")
|
|
744
|
+
for comparison in drill_data['comparisons']:
|
|
745
|
+
prev_week = comparison['prev_week']['label']
|
|
746
|
+
curr_week = comparison['curr_week']['label']
|
|
747
|
+
num_increases = len(comparison['increases'])
|
|
748
|
+
num_decreases = len(comparison['decreases'])
|
|
749
|
+
|
|
750
|
+
click.echo(f"{prev_week} → {curr_week}")
|
|
751
|
+
click.echo(f" Increases: {num_increases}, Decreases: {num_decreases}")
|
|
752
|
+
|
|
753
|
+
if comparison['increases']:
|
|
754
|
+
top = comparison['increases'][0]
|
|
755
|
+
click.echo(f" Top: {top['dimension'][:50]} (+${top['change']:,.2f})")
|
|
756
|
+
|
|
757
|
+
click.echo("")
|
|
758
|
+
|
|
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
|
+
|
|
542
874
|
if __name__ == '__main__':
|
|
543
875
|
cli()
|