aws-cost-calculator-cli 1.11.1__tar.gz → 2.1.0__tar.gz

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.
Files changed (22) hide show
  1. {aws_cost_calculator_cli-1.11.1/aws_cost_calculator_cli.egg-info → aws_cost_calculator_cli-2.1.0}/PKG-INFO +1 -1
  2. {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0/aws_cost_calculator_cli.egg-info}/PKG-INFO +1 -1
  3. {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/cost_calculator/cli.py +488 -177
  4. {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/cost_calculator/executor.py +5 -2
  5. {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/setup.py +1 -1
  6. {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/CHANGES.md +0 -0
  7. {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/LICENSE +0 -0
  8. {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/MANIFEST.in +0 -0
  9. {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/README.md +0 -0
  10. {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/aws_cost_calculator_cli.egg-info/SOURCES.txt +0 -0
  11. {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/aws_cost_calculator_cli.egg-info/dependency_links.txt +0 -0
  12. {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/aws_cost_calculator_cli.egg-info/entry_points.txt +0 -0
  13. {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/aws_cost_calculator_cli.egg-info/requires.txt +0 -0
  14. {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/aws_cost_calculator_cli.egg-info/top_level.txt +0 -0
  15. {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/cost_calculator/__init__.py +0 -0
  16. {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/cost_calculator/api_client.py +0 -0
  17. {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/cost_calculator/cur.py +0 -0
  18. {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/cost_calculator/drill.py +0 -0
  19. {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/cost_calculator/forensics.py +0 -0
  20. {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/cost_calculator/monthly.py +0 -0
  21. {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/cost_calculator/trends.py +0 -0
  22. {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aws-cost-calculator-cli
3
- Version: 1.11.1
3
+ Version: 2.1.0
4
4
  Summary: AWS Cost Calculator CLI - Calculate daily and annual AWS costs across multiple accounts
5
5
  Home-page: https://github.com/trilogy-group/aws-cost-calculator
6
6
  Author: Cost Optimization Team
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aws-cost-calculator-cli
3
- Version: 1.11.1
3
+ Version: 2.1.0
4
4
  Summary: AWS Cost Calculator CLI - Calculate daily and annual AWS costs across multiple accounts
5
5
  Home-page: https://github.com/trilogy-group/aws-cost-calculator
6
6
  Author: Cost Optimization Team
@@ -18,6 +18,12 @@ from pathlib import Path
18
18
  from cost_calculator.trends import format_trends_markdown
19
19
  from cost_calculator.monthly import format_monthly_markdown
20
20
  from cost_calculator.drill import format_drill_down_markdown
21
+
22
+ # API Configuration - can be overridden via environment variable
23
+ PROFILES_API_URL = os.environ.get(
24
+ 'COST_CALCULATOR_PROFILES_URL',
25
+ 'https://64g7jq7sjygec2zmll5lsghrpi0txrzo.lambda-url.us-east-1.on.aws/'
26
+ )
21
27
  from cost_calculator.executor import execute_trends, execute_monthly, execute_drill
22
28
 
23
29
 
@@ -67,127 +73,197 @@ def apply_auth_options(config, sso=None, access_key_id=None, secret_access_key=N
67
73
  return config
68
74
 
69
75
 
70
- def load_profile(profile_name):
71
- """Load profile configuration from DynamoDB API or local file as fallback"""
76
+ def get_api_secret():
77
+ """Get API secret from config file or environment variable"""
72
78
  import os
73
- import requests
74
79
 
75
- config_dir = Path.home() / '.config' / 'cost-calculator'
76
- config_file = config_dir / 'profiles.json'
77
- creds_file = config_dir / 'credentials.json'
78
-
79
- # Try DynamoDB API first if COST_API_SECRET is set
80
+ # Check environment variable first
80
81
  api_secret = os.environ.get('COST_API_SECRET')
81
82
  if api_secret:
82
- try:
83
- response = requests.post(
84
- 'https://64g7jq7sjygec2zmll5lsghrpi0txrzo.lambda-url.us-east-1.on.aws/',
85
- headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
86
- json={'operation': 'get', 'profile_name': profile_name},
87
- timeout=10
88
- )
89
-
90
- if response.status_code == 200:
91
- response_data = response.json()
92
- # API returns {"profile": {...}} wrapper
93
- profile_data = response_data.get('profile', response_data)
94
- profile = {'accounts': profile_data['accounts']}
95
-
96
- # If profile has aws_profile field, use it
97
- if 'aws_profile' in profile_data:
98
- profile['aws_profile'] = profile_data['aws_profile']
99
- # Check for AWS_PROFILE environment variable (SSO support)
100
- elif os.environ.get('AWS_PROFILE'):
101
- profile['aws_profile'] = os.environ['AWS_PROFILE']
102
- # Use environment credentials
103
- elif os.environ.get('AWS_ACCESS_KEY_ID'):
104
- profile['credentials'] = {
105
- 'aws_access_key_id': os.environ['AWS_ACCESS_KEY_ID'],
106
- 'aws_secret_access_key': os.environ['AWS_SECRET_ACCESS_KEY'],
107
- 'aws_session_token': os.environ.get('AWS_SESSION_TOKEN')
108
- }
109
- else:
110
- # Try to find a matching AWS profile by name
111
- # This allows "khoros" profile to work with "khoros_umbrella" AWS profile
112
- import subprocess
113
- try:
114
- result = subprocess.run(
115
- ['aws', 'configure', 'list-profiles'],
116
- capture_output=True,
117
- text=True,
118
- timeout=5
119
- )
120
- if result.returncode == 0:
121
- available_profiles = result.stdout.strip().split('\n')
122
- # Try exact match first
123
- if profile_name in available_profiles:
124
- profile['aws_profile'] = profile_name
125
- # Try with common suffixes
126
- elif f"{profile_name}_umbrella" in available_profiles:
127
- profile['aws_profile'] = f"{profile_name}_umbrella"
128
- elif f"{profile_name}-umbrella" in available_profiles:
129
- profile['aws_profile'] = f"{profile_name}-umbrella"
130
- elif f"{profile_name}_prod" in available_profiles:
131
- profile['aws_profile'] = f"{profile_name}_prod"
132
- # If no match found, leave it unset - user must provide --sso
133
- except:
134
- # If we can't list profiles, leave it unset - user must provide --sso
135
- pass
136
-
137
- return profile
138
- else:
139
- raise click.ClickException(
140
- f"Profile '{profile_name}' not found in DynamoDB.\n"
141
- f"Run: cc profile create --name {profile_name} --accounts \"...\""
142
- )
143
- except requests.exceptions.RequestException as e:
144
- raise click.ClickException(
145
- f"Failed to fetch profile from API: {e}\n"
146
- )
83
+ return api_secret
84
+
85
+ # Check config file
86
+ config_dir = Path.home() / '.config' / 'cost-calculator'
87
+ config_file = config_dir / 'config.json'
147
88
 
148
- # Fallback to local file if no API secret
149
89
  if config_file.exists():
150
90
  with open(config_file) as f:
151
- profiles = json.load(f)
91
+ config = json.load(f)
92
+ return config.get('api_secret')
93
+
94
+ return None
95
+
96
+
97
+ def get_exclusions(profile_name=None):
98
+ """Get exclusions configuration from DynamoDB API"""
99
+ import requests
100
+
101
+ api_secret = get_api_secret()
102
+ if not api_secret:
103
+ raise click.ClickException(
104
+ "No API secret configured.\n"
105
+ "Run: cc configure --api-secret YOUR_SECRET"
106
+ )
107
+
108
+ try:
109
+ response = requests.post(
110
+ PROFILES_API_URL,
111
+ headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
112
+ json={'operation': 'get_exclusions', 'profile_name': profile_name},
113
+ timeout=10
114
+ )
115
+
116
+ if response.status_code == 200:
117
+ return response.json().get('exclusions', {})
118
+ else:
119
+ # Return defaults if API fails
120
+ return {
121
+ 'record_types': ['Tax', 'Support'],
122
+ 'services': [],
123
+ 'usage_types': [],
124
+ 'line_item_types': []
125
+ }
126
+ except:
127
+ # Return defaults if request fails
128
+ return {
129
+ 'record_types': ['Tax', 'Support'],
130
+ 'services': [],
131
+ 'usage_types': [],
132
+ 'line_item_types': []
133
+ }
134
+
135
+
136
+ def build_exclusion_filter(exclusions):
137
+ """Build AWS Cost Explorer filter from exclusions config"""
138
+ filter_parts = []
139
+
140
+ # Exclude record types (Tax, Support, etc.)
141
+ if exclusions.get('record_types'):
142
+ filter_parts.append({
143
+ "Not": {
144
+ "Dimensions": {
145
+ "Key": "RECORD_TYPE",
146
+ "Values": exclusions['record_types']
147
+ }
148
+ }
149
+ })
150
+
151
+ # Exclude specific services (OCBLateFee, etc.)
152
+ if exclusions.get('services'):
153
+ filter_parts.append({
154
+ "Not": {
155
+ "Dimensions": {
156
+ "Key": "SERVICE",
157
+ "Values": exclusions['services']
158
+ }
159
+ }
160
+ })
161
+
162
+ # Exclude usage types
163
+ if exclusions.get('usage_types'):
164
+ filter_parts.append({
165
+ "Not": {
166
+ "Dimensions": {
167
+ "Key": "USAGE_TYPE",
168
+ "Values": exclusions['usage_types']
169
+ }
170
+ }
171
+ })
172
+
173
+ # Exclude line item types (Refund, Credit, etc.)
174
+ if exclusions.get('line_item_types'):
175
+ filter_parts.append({
176
+ "Not": {
177
+ "Dimensions": {
178
+ "Key": "LINE_ITEM_TYPE",
179
+ "Values": exclusions['line_item_types']
180
+ }
181
+ }
182
+ })
183
+
184
+ return filter_parts
185
+
186
+
187
+ def load_profile(profile_name):
188
+ """Load profile configuration from DynamoDB API (API-only, no local files)"""
189
+ import requests
190
+
191
+ # Get API secret
192
+ api_secret = get_api_secret()
193
+
194
+ if not api_secret:
195
+ raise click.ClickException(
196
+ "No API secret configured.\n"
197
+ "Run: cc configure --api-secret YOUR_SECRET\n"
198
+ "Or set environment variable: export COST_API_SECRET=YOUR_SECRET"
199
+ )
200
+
201
+ try:
202
+ response = requests.post(
203
+ PROFILES_API_URL,
204
+ headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
205
+ json={'operation': 'get', 'profile_name': profile_name},
206
+ timeout=10
207
+ )
152
208
 
153
- if profile_name in profiles:
154
- profile = profiles[profile_name]
209
+ if response.status_code == 200:
210
+ response_data = response.json()
211
+ # API returns {"profile": {...}} wrapper
212
+ profile_data = response_data.get('profile', response_data)
213
+ profile = {'accounts': profile_data['accounts']}
155
214
 
156
- # Load credentials if using static credentials (not SSO)
157
- if 'aws_profile' not in profile:
158
- if not creds_file.exists():
159
- # Try environment variables
160
- if os.environ.get('AWS_ACCESS_KEY_ID'):
161
- profile['credentials'] = {
162
- 'aws_access_key_id': os.environ['AWS_ACCESS_KEY_ID'],
163
- 'aws_secret_access_key': os.environ['AWS_SECRET_ACCESS_KEY'],
164
- 'aws_session_token': os.environ.get('AWS_SESSION_TOKEN')
165
- }
166
- return profile
167
-
168
- raise click.ClickException(
169
- f"No credentials found for profile '{profile_name}'.\n"
170
- f"Run: cc configure --profile {profile_name}"
171
- )
172
-
173
- with open(creds_file) as f:
174
- creds = json.load(f)
175
-
176
- if profile_name not in creds:
177
- raise click.ClickException(
178
- f"No credentials found for profile '{profile_name}'.\n"
179
- f"Run: cc configure --profile {profile_name}"
215
+ # If profile has aws_profile field, use it
216
+ if 'aws_profile' in profile_data:
217
+ profile['aws_profile'] = profile_data['aws_profile']
218
+ # Check for AWS_PROFILE environment variable (SSO support)
219
+ elif os.environ.get('AWS_PROFILE'):
220
+ profile['aws_profile'] = os.environ['AWS_PROFILE']
221
+ # Use environment credentials
222
+ elif os.environ.get('AWS_ACCESS_KEY_ID'):
223
+ profile['credentials'] = {
224
+ 'aws_access_key_id': os.environ['AWS_ACCESS_KEY_ID'],
225
+ 'aws_secret_access_key': os.environ['AWS_SECRET_ACCESS_KEY'],
226
+ 'aws_session_token': os.environ.get('AWS_SESSION_TOKEN')
227
+ }
228
+ else:
229
+ # Try to find a matching AWS profile by name
230
+ # This allows "khoros" profile to work with "khoros_umbrella" AWS profile
231
+ import subprocess
232
+ try:
233
+ result = subprocess.run(
234
+ ['aws', 'configure', 'list-profiles'],
235
+ capture_output=True,
236
+ text=True,
237
+ timeout=5
180
238
  )
181
-
182
- profile['credentials'] = creds[profile_name]
239
+ if result.returncode == 0:
240
+ available_profiles = result.stdout.strip().split('\n')
241
+ # Try exact match first
242
+ if profile_name in available_profiles:
243
+ profile['aws_profile'] = profile_name
244
+ # Try with common suffixes
245
+ elif f"{profile_name}_umbrella" in available_profiles:
246
+ profile['aws_profile'] = f"{profile_name}_umbrella"
247
+ elif f"{profile_name}-umbrella" in available_profiles:
248
+ profile['aws_profile'] = f"{profile_name}-umbrella"
249
+ elif f"{profile_name}_prod" in available_profiles:
250
+ profile['aws_profile'] = f"{profile_name}_prod"
251
+ # If no match found, leave it unset - user must provide --sso
252
+ except:
253
+ # If we can't list profiles, leave it unset - user must provide --sso
254
+ pass
183
255
 
184
256
  return profile
185
-
186
- # Profile not found anywhere
187
- raise click.ClickException(
188
- f"Profile '{profile_name}' not found.\n"
189
- f"Run: cc profile create --name {profile_name} --accounts \"...\""
190
- )
257
+ else:
258
+ raise click.ClickException(
259
+ f"Profile '{profile_name}' not found in DynamoDB.\n"
260
+ f"Run: cc profile create --name {profile_name} --accounts \"...\""
261
+ )
262
+ except requests.exceptions.RequestException as e:
263
+ raise click.ClickException(
264
+ f"Failed to fetch profile from API: {e}\n"
265
+ "Check your API secret and network connection."
266
+ )
191
267
 
192
268
 
193
269
  def calculate_costs(profile_config, accounts, start_date, offset, window):
@@ -265,7 +341,10 @@ def calculate_costs(profile_config, accounts, start_date, offset, window):
265
341
  )
266
342
  raise
267
343
 
268
- # Build filter
344
+ # Build filter with dynamic exclusions
345
+ exclusions = get_exclusions() # Get from DynamoDB
346
+ exclusion_filters = build_exclusion_filter(exclusions)
347
+
269
348
  cost_filter = {
270
349
  "And": [
271
350
  {
@@ -279,18 +358,13 @@ def calculate_costs(profile_config, accounts, start_date, offset, window):
279
358
  "Key": "BILLING_ENTITY",
280
359
  "Values": ["AWS"]
281
360
  }
282
- },
283
- {
284
- "Not": {
285
- "Dimensions": {
286
- "Key": "RECORD_TYPE",
287
- "Values": ["Tax", "Support"]
288
- }
289
- }
290
361
  }
291
362
  ]
292
363
  }
293
364
 
365
+ # Add dynamic exclusion filters
366
+ cost_filter["And"].extend(exclusion_filters)
367
+
294
368
  # Get daily costs
295
369
  click.echo("Fetching cost data...")
296
370
  try:
@@ -737,77 +811,81 @@ def setup():
737
811
 
738
812
 
739
813
  @cli.command()
740
- @click.option('--profile', required=True, help='Profile name to configure')
741
- @click.option('--access-key-id', prompt=True, hide_input=False, help='AWS Access Key ID')
742
- @click.option('--secret-access-key', prompt=True, hide_input=True, help='AWS Secret Access Key')
743
- @click.option('--session-token', default='', help='AWS Session Token (optional, for temporary credentials)')
744
- @click.option('--region', default='us-east-1', help='AWS Region (default: us-east-1)')
745
- def configure(profile, access_key_id, secret_access_key, session_token, region):
746
- """Configure AWS credentials for a profile (alternative to SSO)"""
814
+ @click.option('--api-secret', help='API secret for DynamoDB profile access')
815
+ @click.option('--show', is_flag=True, help='Show current configuration')
816
+ def configure(api_secret, show):
817
+ """
818
+ Configure Cost Calculator CLI settings.
747
819
 
748
- config_dir = Path.home() / '.config' / 'cost-calculator'
749
- config_file = config_dir / 'profiles.json'
750
- creds_file = config_dir / 'credentials.json'
820
+ This tool requires an API secret to access profiles stored in DynamoDB.
821
+ The secret can be configured here or set via COST_API_SECRET environment variable.
751
822
 
752
- # Create config directory if it doesn't exist
823
+ Examples:
824
+ # Configure API secret
825
+ cc configure --api-secret YOUR_SECRET_KEY
826
+
827
+ # Show current configuration
828
+ cc configure --show
829
+
830
+ # Use environment variable instead (no configuration needed)
831
+ export COST_API_SECRET=YOUR_SECRET_KEY
832
+ """
833
+ import os
834
+
835
+ config_dir = Path.home() / '.config' / 'cost-calculator'
753
836
  config_dir.mkdir(parents=True, exist_ok=True)
837
+ config_file = config_dir / 'config.json'
754
838
 
755
- # Load existing profiles
756
- if config_file.exists() and config_file.stat().st_size > 0:
757
- try:
839
+ if show:
840
+ # Show current configuration
841
+ if config_file.exists():
758
842
  with open(config_file) as f:
759
- profiles = json.load(f)
760
- except json.JSONDecodeError:
761
- profiles = {}
762
- else:
763
- profiles = {}
764
-
765
- # Check if profile exists
766
- if profile not in profiles:
767
- click.echo(f"Error: Profile '{profile}' not found. Create it first with: cc init --profile {profile}")
843
+ config = json.load(f)
844
+ if 'api_secret' in config:
845
+ masked_secret = config['api_secret'][:8] + '...' + config['api_secret'][-4:]
846
+ click.echo(f"API Secret: {masked_secret} (configured)")
847
+ else:
848
+ click.echo("API Secret: Not configured")
849
+ else:
850
+ click.echo("No configuration file found")
851
+
852
+ # Check environment variable
853
+ import os
854
+ if os.environ.get('COST_API_SECRET'):
855
+ click.echo("Environment: COST_API_SECRET is set")
856
+ else:
857
+ click.echo("Environment: COST_API_SECRET is not set")
858
+
768
859
  return
769
860
 
770
- # Remove aws_profile if it exists (switching from SSO to static creds)
771
- if 'aws_profile' in profiles[profile]:
772
- del profiles[profile]['aws_profile']
773
-
774
- # Save updated profile
775
- with open(config_file, 'w') as f:
776
- json.dump(profiles, f, indent=2)
777
-
778
- # Load or create credentials file
779
- if creds_file.exists() and creds_file.stat().st_size > 0:
780
- try:
781
- with open(creds_file) as f:
782
- creds = json.load(f)
783
- except json.JSONDecodeError:
784
- creds = {}
785
- else:
786
- creds = {}
861
+ if not api_secret:
862
+ raise click.ClickException(
863
+ "Please provide --api-secret or use --show to view current configuration\n"
864
+ "Example: cc configure --api-secret YOUR_SECRET_KEY"
865
+ )
787
866
 
788
- # Store credentials (encrypted would be better, but for now just file permissions)
789
- creds[profile] = {
790
- 'aws_access_key_id': access_key_id,
791
- 'aws_secret_access_key': secret_access_key,
792
- 'region': region
793
- }
867
+ # Load existing config
868
+ config = {}
869
+ if config_file.exists():
870
+ with open(config_file) as f:
871
+ config = json.load(f)
794
872
 
795
- if session_token:
796
- creds[profile]['aws_session_token'] = session_token
873
+ # Update API secret
874
+ config['api_secret'] = api_secret
797
875
 
798
- # Save credentials with restricted permissions
799
- with open(creds_file, 'w') as f:
800
- json.dump(creds, f, indent=2)
876
+ # Save config
877
+ with open(config_file, 'w') as f:
878
+ json.dump(config, f, indent=2)
801
879
 
802
- # Set file permissions to 600 (owner read/write only)
803
- creds_file.chmod(0o600)
880
+ # Set restrictive permissions (Unix/Mac only - Windows uses different permission model)
881
+ import platform
882
+ if platform.system() != 'Windows':
883
+ os.chmod(config_file, 0o600)
804
884
 
805
- click.echo(f"✓ AWS credentials configured for profile '{profile}'")
806
- click.echo(f"✓ Credentials saved to {creds_file} (permissions: 600)")
807
- click.echo(f"\nUsage: cc calculate --profile {profile}")
808
- click.echo("\nNote: Credentials are stored locally. For temporary credentials,")
809
- click.echo(" you'll need to reconfigure when they expire.")
810
-
885
+ masked_secret = api_secret[:8] + '...' + api_secret[-4:]
886
+ click.echo(f"✓ API secret configured: {masked_secret}")
887
+ click.echo(f"\nYou can now run: cc calculate --profile PROFILE_NAME")
888
+ click.echo(f"\nNote: Profiles are stored in DynamoDB and accessed via the API.")
811
889
 
812
890
  @cli.command()
813
891
  @click.option('--profile', required=True, help='Profile name')
@@ -2013,5 +2091,238 @@ def query(profile, query, database, output_bucket, sso):
2013
2091
  raise click.ClickException(f"Query failed: {e}")
2014
2092
 
2015
2093
 
2094
+ @cli.group()
2095
+ def exclusions():
2096
+ """
2097
+ Manage cost exclusions (services/types to exclude from calculations).
2098
+
2099
+ Exclusions are stored in DynamoDB and apply globally or per-profile.
2100
+ Common exclusions: Tax, Support, OCBLateFee, Refunds, Credits
2101
+ """
2102
+ pass
2103
+
2104
+
2105
+ @exclusions.command('show')
2106
+ @click.option('--profile', help='Show profile-specific exclusions (default: global)')
2107
+ @click.option('--json', 'output_json', is_flag=True, help='Output as JSON')
2108
+ def show_exclusions(profile, output_json):
2109
+ """Show current exclusions configuration"""
2110
+ import requests
2111
+
2112
+ api_secret = get_api_secret()
2113
+ if not api_secret:
2114
+ raise click.ClickException(
2115
+ "No API secret configured.\n"
2116
+ "Run: cc configure --api-secret YOUR_SECRET"
2117
+ )
2118
+
2119
+ try:
2120
+ response = requests.post(
2121
+ PROFILES_API_URL,
2122
+ headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
2123
+ json={'operation': 'get_exclusions', 'profile_name': profile},
2124
+ timeout=10
2125
+ )
2126
+
2127
+ if response.status_code == 200:
2128
+ exclusions_data = response.json().get('exclusions', {})
2129
+
2130
+ if output_json:
2131
+ click.echo(json.dumps(exclusions_data, indent=2))
2132
+ else:
2133
+ scope = profile if profile else "global"
2134
+ click.echo(f"Exclusions ({scope}):")
2135
+ click.echo("")
2136
+
2137
+ if exclusions_data.get('record_types'):
2138
+ click.echo("Record Types:")
2139
+ for rt in exclusions_data['record_types']:
2140
+ click.echo(f" - {rt}")
2141
+ click.echo("")
2142
+
2143
+ if exclusions_data.get('services'):
2144
+ click.echo("Services:")
2145
+ for svc in exclusions_data['services']:
2146
+ click.echo(f" - {svc}")
2147
+ click.echo("")
2148
+
2149
+ if exclusions_data.get('line_item_types'):
2150
+ click.echo("Line Item Types:")
2151
+ for lit in exclusions_data['line_item_types']:
2152
+ click.echo(f" - {lit}")
2153
+ click.echo("")
2154
+
2155
+ if exclusions_data.get('usage_types'):
2156
+ click.echo("Usage Types:")
2157
+ for ut in exclusions_data['usage_types']:
2158
+ click.echo(f" - {ut}")
2159
+ else:
2160
+ raise click.ClickException(f"Failed to fetch exclusions: {response.status_code}")
2161
+
2162
+ except requests.exceptions.RequestException as e:
2163
+ raise click.ClickException(f"API request failed: {e}")
2164
+
2165
+
2166
+ @exclusions.command('add')
2167
+ @click.option('--record-type', help='Add record type exclusion (e.g., Tax, Support)')
2168
+ @click.option('--service', help='Add service exclusion (e.g., OCBLateFee)')
2169
+ @click.option('--line-item-type', help='Add line item type exclusion (e.g., Refund, Credit)')
2170
+ @click.option('--usage-type', help='Add usage type exclusion')
2171
+ @click.option('--profile', help='Add to profile-specific exclusions (default: global)')
2172
+ def add_exclusion(record_type, service, line_item_type, usage_type, profile):
2173
+ """Add an exclusion"""
2174
+ import requests
2175
+
2176
+ if not any([record_type, service, line_item_type, usage_type]):
2177
+ raise click.ClickException(
2178
+ "Must specify at least one of: --record-type, --service, --line-item-type, --usage-type"
2179
+ )
2180
+
2181
+ api_secret = get_api_secret()
2182
+ if not api_secret:
2183
+ raise click.ClickException(
2184
+ "No API secret configured.\n"
2185
+ "Run: cc configure --api-secret YOUR_SECRET"
2186
+ )
2187
+
2188
+ # Get current exclusions
2189
+ try:
2190
+ response = requests.post(
2191
+ PROFILES_API_URL,
2192
+ headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
2193
+ json={'operation': 'get_exclusions', 'profile_name': profile},
2194
+ timeout=10
2195
+ )
2196
+
2197
+ if response.status_code == 200:
2198
+ exclusions_data = response.json().get('exclusions', {})
2199
+ else:
2200
+ exclusions_data = {
2201
+ 'record_types': [],
2202
+ 'services': [],
2203
+ 'line_item_types': [],
2204
+ 'usage_types': []
2205
+ }
2206
+
2207
+ # Add new exclusions
2208
+ if record_type and record_type not in exclusions_data.get('record_types', []):
2209
+ exclusions_data.setdefault('record_types', []).append(record_type)
2210
+
2211
+ if service and service not in exclusions_data.get('services', []):
2212
+ exclusions_data.setdefault('services', []).append(service)
2213
+
2214
+ if line_item_type and line_item_type not in exclusions_data.get('line_item_types', []):
2215
+ exclusions_data.setdefault('line_item_types', []).append(line_item_type)
2216
+
2217
+ if usage_type and usage_type not in exclusions_data.get('usage_types', []):
2218
+ exclusions_data.setdefault('usage_types', []).append(usage_type)
2219
+
2220
+ # Update exclusions
2221
+ update_response = requests.post(
2222
+ PROFILES_API_URL,
2223
+ headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
2224
+ json={
2225
+ 'operation': 'update_exclusions',
2226
+ 'profile_name': profile,
2227
+ 'record_types': exclusions_data.get('record_types', []),
2228
+ 'services': exclusions_data.get('services', []),
2229
+ 'line_item_types': exclusions_data.get('line_item_types', []),
2230
+ 'usage_types': exclusions_data.get('usage_types', [])
2231
+ },
2232
+ timeout=10
2233
+ )
2234
+
2235
+ if update_response.status_code == 200:
2236
+ scope = profile if profile else "global"
2237
+ click.echo(f"✓ Exclusion added to {scope} config")
2238
+ if record_type:
2239
+ click.echo(f" Record Type: {record_type}")
2240
+ if service:
2241
+ click.echo(f" Service: {service}")
2242
+ if line_item_type:
2243
+ click.echo(f" Line Item Type: {line_item_type}")
2244
+ if usage_type:
2245
+ click.echo(f" Usage Type: {usage_type}")
2246
+ else:
2247
+ raise click.ClickException(f"Failed to update exclusions: {update_response.status_code}")
2248
+
2249
+ except requests.exceptions.RequestException as e:
2250
+ raise click.ClickException(f"API request failed: {e}")
2251
+
2252
+
2253
+ @exclusions.command('remove')
2254
+ @click.option('--record-type', help='Remove record type exclusion')
2255
+ @click.option('--service', help='Remove service exclusion')
2256
+ @click.option('--line-item-type', help='Remove line item type exclusion')
2257
+ @click.option('--usage-type', help='Remove usage type exclusion')
2258
+ @click.option('--profile', help='Remove from profile-specific exclusions (default: global)')
2259
+ def remove_exclusion(record_type, service, line_item_type, usage_type, profile):
2260
+ """Remove an exclusion"""
2261
+ import requests
2262
+
2263
+ if not any([record_type, service, line_item_type, usage_type]):
2264
+ raise click.ClickException(
2265
+ "Must specify at least one of: --record-type, --service, --line-item-type, --usage-type"
2266
+ )
2267
+
2268
+ api_secret = get_api_secret()
2269
+ if not api_secret:
2270
+ raise click.ClickException(
2271
+ "No API secret configured.\n"
2272
+ "Run: cc configure --api-secret YOUR_SECRET"
2273
+ )
2274
+
2275
+ # Get current exclusions
2276
+ try:
2277
+ response = requests.post(
2278
+ PROFILES_API_URL,
2279
+ headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
2280
+ json={'operation': 'get_exclusions', 'profile_name': profile},
2281
+ timeout=10
2282
+ )
2283
+
2284
+ if response.status_code == 200:
2285
+ exclusions_data = response.json().get('exclusions', {})
2286
+ else:
2287
+ raise click.ClickException("No exclusions found")
2288
+
2289
+ # Remove exclusions
2290
+ if record_type and record_type in exclusions_data.get('record_types', []):
2291
+ exclusions_data['record_types'].remove(record_type)
2292
+
2293
+ if service and service in exclusions_data.get('services', []):
2294
+ exclusions_data['services'].remove(service)
2295
+
2296
+ if line_item_type and line_item_type in exclusions_data.get('line_item_types', []):
2297
+ exclusions_data['line_item_types'].remove(line_item_type)
2298
+
2299
+ if usage_type and usage_type in exclusions_data.get('usage_types', []):
2300
+ exclusions_data['usage_types'].remove(usage_type)
2301
+
2302
+ # Update exclusions
2303
+ update_response = requests.post(
2304
+ PROFILES_API_URL,
2305
+ headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
2306
+ json={
2307
+ 'operation': 'update_exclusions',
2308
+ 'profile_name': profile,
2309
+ 'record_types': exclusions_data.get('record_types', []),
2310
+ 'services': exclusions_data.get('services', []),
2311
+ 'line_item_types': exclusions_data.get('line_item_types', []),
2312
+ 'usage_types': exclusions_data.get('usage_types', [])
2313
+ },
2314
+ timeout=10
2315
+ )
2316
+
2317
+ if update_response.status_code == 200:
2318
+ scope = profile if profile else "global"
2319
+ click.echo(f"✓ Exclusion removed from {scope} config")
2320
+ else:
2321
+ raise click.ClickException(f"Failed to update exclusions: {update_response.status_code}")
2322
+
2323
+ except requests.exceptions.RequestException as e:
2324
+ raise click.ClickException(f"API request failed: {e}")
2325
+
2326
+
2016
2327
  if __name__ == '__main__':
2017
2328
  cli()
@@ -227,8 +227,11 @@ def execute_profile_operation(operation, profile_name=None, accounts=None, descr
227
227
 
228
228
  api_secret = os.environ.get('COST_API_SECRET', '')
229
229
 
230
- # Use profiles endpoint (hardcoded URL)
231
- url = 'https://64g7jq7sjygec2zmll5lsghrpi0txrzo.lambda-url.us-east-1.on.aws/'
230
+ # Use profiles endpoint (can be overridden via environment variable)
231
+ url = os.environ.get(
232
+ 'COST_CALCULATOR_PROFILES_URL',
233
+ 'https://64g7jq7sjygec2zmll5lsghrpi0txrzo.lambda-url.us-east-1.on.aws/'
234
+ )
232
235
 
233
236
  payload = {'operation': operation}
234
237
  if profile_name:
@@ -5,7 +5,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
5
5
 
6
6
  setup(
7
7
  name='aws-cost-calculator-cli',
8
- version='1.11.1',
8
+ version='2.1.0',
9
9
  packages=['cost_calculator'],
10
10
  install_requires=[
11
11
  'click>=8.0.0',