awslabs.cost-explorer-mcp-server 0.0.4__py3-none-any.whl → 0.0.6__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.
@@ -15,60 +15,91 @@
15
15
  """Helper functions for the Cost Explorer MCP server."""
16
16
 
17
17
  import boto3
18
- import logging
18
+ import os
19
19
  import re
20
- from datetime import datetime
20
+ import sys
21
+ from awslabs.cost_explorer_mcp_server.constants import (
22
+ VALID_DIMENSIONS,
23
+ VALID_GROUP_BY_DIMENSIONS,
24
+ VALID_GROUP_BY_TYPES,
25
+ VALID_MATCH_OPTIONS,
26
+ )
27
+ from datetime import datetime, timezone
28
+ from loguru import logger
21
29
  from typing import Any, Dict, Optional, Tuple
22
30
 
23
31
 
24
- # Set up logging
25
- logger = logging.getLogger(__name__)
32
+ # Configure Loguru logging
33
+ logger.remove()
34
+ logger.add(sys.stderr, level=os.getenv('FASTMCP_LOG_LEVEL', 'WARNING'))
26
35
 
27
- # Initialize AWS Cost Explorer client
28
- ce = boto3.client('ce')
36
+ # Global client cache
37
+ _cost_explorer_client = None
29
38
 
30
39
 
31
- def validate_date_format(date_str: str) -> Tuple[bool, str]:
32
- """Validate that a date string is in YYYY-MM-DD format and is a valid date.
40
+ def get_cost_explorer_client():
41
+ """Get Cost Explorer client with proper session management and caching.
42
+
43
+ Returns:
44
+ boto3.client: Configured Cost Explorer client (cached after first call)
45
+ """
46
+ global _cost_explorer_client
47
+
48
+ if _cost_explorer_client is None:
49
+ try:
50
+ # Read environment variables dynamically
51
+ aws_region = os.environ.get('AWS_REGION', 'us-east-1')
52
+ aws_profile = os.environ.get('AWS_PROFILE')
53
+
54
+ if aws_profile:
55
+ _cost_explorer_client = boto3.Session(
56
+ profile_name=aws_profile, region_name=aws_region
57
+ ).client('ce')
58
+ else:
59
+ _cost_explorer_client = boto3.Session(region_name=aws_region).client('ce')
60
+ except Exception as e:
61
+ logger.error(f'Error creating Cost Explorer client: {str(e)}')
62
+ raise
63
+
64
+ return _cost_explorer_client
65
+
66
+
67
+ def validate_dimension_key(dimension_key: str) -> Dict[str, Any]:
68
+ """Validate that the dimension key is supported by AWS Cost Explorer.
33
69
 
34
70
  Args:
35
- date_str: The date string to validate
71
+ dimension_key: The dimension key to validate
36
72
 
37
73
  Returns:
38
- Tuple of (is_valid, error_message)
74
+ Empty dictionary if valid, or an error dictionary
39
75
  """
40
- # Check format with regex
41
- if not re.match(r'^\d{4}-\d{2}-\d{2}$', date_str):
42
- return False, f"Date '{date_str}' is not in YYYY-MM-DD format"
43
-
44
- # Check if it's a valid date
45
76
  try:
46
- datetime.strptime(date_str, '%Y-%m-%d')
47
- return True, ''
48
- except ValueError as e:
49
- return False, f"Invalid date '{date_str}': {str(e)}"
77
+ dimension_upper = dimension_key.upper()
78
+ if dimension_upper not in VALID_DIMENSIONS:
79
+ return {
80
+ 'error': f"Invalid dimension key '{dimension_key}'. Valid dimensions are: {', '.join(VALID_DIMENSIONS)}"
81
+ }
82
+ return {}
83
+ except Exception as e:
84
+ return {'error': f'Error validating dimension key: {str(e)}'}
50
85
 
51
86
 
52
- def get_dimension_values(
87
+ def get_available_dimension_values(
53
88
  key: str, billing_period_start: str, billing_period_end: str
54
89
  ) -> Dict[str, Any]:
55
90
  """Get available values for a specific dimension."""
56
- # Validate date formats
57
- is_valid_start, error_start = validate_date_format(billing_period_start)
58
- if not is_valid_start:
59
- return {'error': error_start}
91
+ # Validate dimension key first
92
+ dimension_validation = validate_dimension_key(key)
93
+ if 'error' in dimension_validation:
94
+ return dimension_validation
60
95
 
61
- is_valid_end, error_end = validate_date_format(billing_period_end)
62
- if not is_valid_end:
63
- return {'error': error_end}
64
-
65
- # Validate date range
66
- if billing_period_start > billing_period_end:
67
- return {
68
- 'error': f"Start date '{billing_period_start}' cannot be after end date '{billing_period_end}'"
69
- }
96
+ # Validate date range (no granularity constraint for dimension values)
97
+ is_valid, error_message = validate_date_range(billing_period_start, billing_period_end)
98
+ if not is_valid:
99
+ return {'error': error_message}
70
100
 
71
101
  try:
102
+ ce = get_cost_explorer_client()
72
103
  response = ce.get_dimension_values(
73
104
  TimePeriod={'Start': billing_period_start, 'End': billing_period_end},
74
105
  Dimension=key.upper(),
@@ -77,30 +108,23 @@ def get_dimension_values(
77
108
  values = [value['Value'] for value in dimension_values]
78
109
  return {'dimension': key.upper(), 'values': values}
79
110
  except Exception as e:
80
- logger.error(f'Error getting dimension values: {e}')
111
+ logger.error(
112
+ f'Error getting dimension values for {key.upper()} ({billing_period_start} to {billing_period_end}): {e}'
113
+ )
81
114
  return {'error': str(e)}
82
115
 
83
116
 
84
- def get_tag_values(
117
+ def get_available_tag_values(
85
118
  tag_key: str, billing_period_start: str, billing_period_end: str
86
119
  ) -> Dict[str, Any]:
87
120
  """Get available values for a specific tag key."""
88
- # Validate date formats
89
- is_valid_start, error_start = validate_date_format(billing_period_start)
90
- if not is_valid_start:
91
- return {'error': error_start}
92
-
93
- is_valid_end, error_end = validate_date_format(billing_period_end)
94
- if not is_valid_end:
95
- return {'error': error_end}
96
-
97
- # Validate date range
98
- if billing_period_start > billing_period_end:
99
- return {
100
- 'error': f"Start date '{billing_period_start}' cannot be after end date '{billing_period_end}'"
101
- }
121
+ # Validate date range (no granularity constraint for tag values)
122
+ is_valid, error_message = validate_date_range(billing_period_start, billing_period_end)
123
+ if not is_valid:
124
+ return {'error': error_message}
102
125
 
103
126
  try:
127
+ ce = get_cost_explorer_client()
104
128
  response = ce.get_tags(
105
129
  TimePeriod={'Start': billing_period_start, 'End': billing_period_end},
106
130
  TagKey=tag_key,
@@ -108,10 +132,119 @@ def get_tag_values(
108
132
  tag_values = response['Tags']
109
133
  return {'tag_key': tag_key, 'values': tag_values}
110
134
  except Exception as e:
111
- logger.error(f'Error getting tag values: {e}')
135
+ logger.error(
136
+ f'Error getting tag values for {tag_key} ({billing_period_start} to {billing_period_end}): {e}'
137
+ )
112
138
  return {'error': str(e)}
113
139
 
114
140
 
141
+ def validate_date_format(date_str: str) -> Tuple[bool, str]:
142
+ """Validate that a date string is in YYYY-MM-DD format and is a valid date.
143
+
144
+ Args:
145
+ date_str: The date string to validate
146
+
147
+ Returns:
148
+ Tuple of (is_valid, error_message)
149
+ """
150
+ # Check format with regex
151
+ if not re.match(r'^\d{4}-\d{2}-\d{2}$', date_str):
152
+ return False, f"Date '{date_str}' is not in YYYY-MM-DD format"
153
+
154
+ # Check if it's a valid date
155
+ try:
156
+ datetime.strptime(date_str, '%Y-%m-%d')
157
+ return True, ''
158
+ except ValueError as e:
159
+ return False, f"Invalid date '{date_str}': {str(e)}"
160
+
161
+
162
+ def format_date_for_api(date_str: str, granularity: str) -> str:
163
+ """Format date string appropriately for AWS Cost Explorer API based on granularity.
164
+
165
+ Args:
166
+ date_str: Date string in YYYY-MM-DD format
167
+ granularity: The granularity (DAILY, MONTHLY, HOURLY)
168
+
169
+ Returns:
170
+ Formatted date string appropriate for the API call
171
+ """
172
+ if granularity.upper() == 'HOURLY':
173
+ # For hourly granularity, AWS expects datetime format
174
+ # Convert YYYY-MM-DD to YYYY-MM-DDTHH:MM:SSZ
175
+ dt = datetime.strptime(date_str, '%Y-%m-%d')
176
+ return dt.strftime('%Y-%m-%dT00:00:00Z')
177
+ else:
178
+ # For DAILY and MONTHLY, use the original date format
179
+ return date_str
180
+
181
+
182
+ def validate_date_range(
183
+ start_date: str, end_date: str, granularity: Optional[str] = None
184
+ ) -> Tuple[bool, str]:
185
+ """Validate date range with format and logical checks.
186
+
187
+ Args:
188
+ start_date: The start date string in YYYY-MM-DD format
189
+ end_date: The end date string in YYYY-MM-DD format
190
+ granularity: Optional granularity to check specific constraints
191
+
192
+ Returns:
193
+ Tuple of (is_valid, error_message)
194
+ """
195
+ # Validate start date format
196
+ is_valid_start, error_start = validate_date_format(start_date)
197
+ if not is_valid_start:
198
+ return False, error_start
199
+
200
+ # Validate end date format
201
+ is_valid_end, error_end = validate_date_format(end_date)
202
+ if not is_valid_end:
203
+ return False, error_end
204
+
205
+ # Validate date range logic
206
+ start_dt = datetime.strptime(start_date, '%Y-%m-%d')
207
+ end_dt = datetime.strptime(end_date, '%Y-%m-%d')
208
+ if start_dt > end_dt:
209
+ return False, f"Start date '{start_date}' cannot be after end date '{end_date}'"
210
+
211
+ # Validate granularity-specific constraints
212
+ if granularity and granularity.upper() == 'HOURLY':
213
+ # HOURLY granularity supports maximum 14 days
214
+ date_diff = (end_dt - start_dt).days
215
+ if date_diff > 14:
216
+ return (
217
+ False,
218
+ f'HOURLY granularity supports a maximum of 14 days. Current range is {date_diff} days ({start_date} to {end_date}). Please use a shorter date range.',
219
+ )
220
+
221
+ return True, ''
222
+
223
+
224
+ def validate_match_options(match_options: list, filter_type: str) -> Dict[str, Any]:
225
+ """Validate MatchOptions based on filter type.
226
+
227
+ Args:
228
+ match_options: List of match options to validate
229
+ filter_type: Type of filter ('Dimensions', 'Tags', 'CostCategories')
230
+
231
+ Returns:
232
+ Empty dictionary if valid, or an error dictionary
233
+ """
234
+ if filter_type not in VALID_MATCH_OPTIONS:
235
+ return {'error': f'Unknown filter type: {filter_type}'}
236
+
237
+ valid_options = VALID_MATCH_OPTIONS[filter_type]
238
+
239
+ for option in match_options:
240
+ if option not in valid_options:
241
+ return {
242
+ 'error': f"Invalid MatchOption '{option}' for {filter_type}. Valid values are: {valid_options}"
243
+ }
244
+
245
+ return {}
246
+
247
+
115
248
  def validate_expression(
116
249
  expression: Dict[str, Any], billing_period_start: str, billing_period_end: str
117
250
  ) -> Dict[str, Any]:
@@ -125,20 +258,10 @@ def validate_expression(
125
258
  Returns:
126
259
  Empty dictionary if valid, or an error dictionary
127
260
  """
128
- # Validate date formats
129
- is_valid_start, error_start = validate_date_format(billing_period_start)
130
- if not is_valid_start:
131
- return {'error': error_start}
132
-
133
- is_valid_end, error_end = validate_date_format(billing_period_end)
134
- if not is_valid_end:
135
- return {'error': error_end}
136
-
137
- # Validate date range
138
- if billing_period_start > billing_period_end:
139
- return {
140
- 'error': f"Start date '{billing_period_start}' cannot be after end date '{billing_period_end}'"
141
- }
261
+ # Validate date range (no granularity constraint for filter validation)
262
+ is_valid, error_message = validate_date_range(billing_period_start, billing_period_end)
263
+ if not is_valid:
264
+ return {'error': error_message}
142
265
 
143
266
  try:
144
267
  if 'Dimensions' in expression:
@@ -152,9 +275,14 @@ def validate_expression(
152
275
  'error': 'Dimensions filter must include "Key", "Values", and "MatchOptions".'
153
276
  }
154
277
 
278
+ # Validate MatchOptions for Dimensions
279
+ match_options_result = validate_match_options(dimension['MatchOptions'], 'Dimensions')
280
+ if 'error' in match_options_result:
281
+ return match_options_result
282
+
155
283
  dimension_key = dimension['Key']
156
284
  dimension_values = dimension['Values']
157
- valid_values_response = get_dimension_values(
285
+ valid_values_response = get_available_dimension_values(
158
286
  dimension_key, billing_period_start, billing_period_end
159
287
  )
160
288
  if 'error' in valid_values_response:
@@ -171,9 +299,14 @@ def validate_expression(
171
299
  if 'Key' not in tag or 'Values' not in tag or 'MatchOptions' not in tag:
172
300
  return {'error': 'Tags filter must include "Key", "Values", and "MatchOptions".'}
173
301
 
302
+ # Validate MatchOptions for Tags
303
+ match_options_result = validate_match_options(tag['MatchOptions'], 'Tags')
304
+ if 'error' in match_options_result:
305
+ return match_options_result
306
+
174
307
  tag_key = tag['Key']
175
308
  tag_values = tag['Values']
176
- valid_tag_values_response = get_tag_values(
309
+ valid_tag_values_response = get_available_tag_values(
177
310
  tag_key, billing_period_start, billing_period_end
178
311
  )
179
312
  if 'error' in valid_tag_values_response:
@@ -196,6 +329,13 @@ def validate_expression(
196
329
  'error': 'CostCategories filter must include "Key", "Values", and "MatchOptions".'
197
330
  }
198
331
 
332
+ # Validate MatchOptions for CostCategories
333
+ match_options_result = validate_match_options(
334
+ cost_category['MatchOptions'], 'CostCategories'
335
+ )
336
+ if 'error' in match_options_result:
337
+ return match_options_result
338
+
199
339
  logical_operators = ['And', 'Or', 'Not']
200
340
  logical_count = sum(1 for op in logical_operators if op in expression)
201
341
 
@@ -268,11 +408,304 @@ def validate_group_by(group_by: Optional[Dict[str, Any]]) -> Dict[str, Any]:
268
408
  ):
269
409
  return {'error': 'group_by must be a dictionary with "Type" and "Key" keys.'}
270
410
 
271
- if group_by['Type'].upper() not in ['DIMENSION', 'TAG', 'COST_CATEGORY']:
411
+ group_type = group_by['Type'].upper()
412
+ group_key = group_by['Key']
413
+
414
+ if group_type not in VALID_GROUP_BY_TYPES:
272
415
  return {
273
- 'error': 'Invalid group Type. Valid types are DIMENSION, TAG, and COST_CATEGORY.'
416
+ 'error': f'Invalid group Type: {group_type}. Valid types are {", ".join(VALID_GROUP_BY_TYPES)}.'
274
417
  }
275
418
 
419
+ # Validate dimension key if type is DIMENSION
420
+ if group_type == 'DIMENSION':
421
+ dimension_upper = group_key.upper()
422
+ if dimension_upper not in VALID_GROUP_BY_DIMENSIONS:
423
+ return {
424
+ 'error': f'Invalid dimension key for GROUP BY: {group_key}. Valid values for the DIMENSION type are {", ".join(VALID_GROUP_BY_DIMENSIONS)}.'
425
+ }
426
+
276
427
  return {}
277
428
  except Exception as e:
278
429
  return {'error': f'Error validating group_by: {str(e)}'}
430
+
431
+
432
+ def validate_forecast_date_range(
433
+ start_date: str, end_date: str, granularity: str = 'MONTHLY'
434
+ ) -> Tuple[bool, str]:
435
+ """Validate that forecast dates meet AWS Cost Explorer requirements.
436
+
437
+ Args:
438
+ start_date: The forecast start date string in YYYY-MM-DD format
439
+ end_date: The forecast end date string in YYYY-MM-DD format
440
+ granularity: The granularity for the forecast (DAILY or MONTHLY)
441
+
442
+ Returns:
443
+ Tuple of (is_valid, error_message)
444
+ """
445
+ # First validate basic date format and range
446
+ is_valid, error = validate_date_range(start_date, end_date)
447
+ if not is_valid:
448
+ return False, error
449
+
450
+ today = datetime.now(timezone.utc).date()
451
+ start_dt = datetime.strptime(start_date, '%Y-%m-%d').date()
452
+ end_dt = datetime.strptime(end_date, '%Y-%m-%d').date()
453
+
454
+ # AWS requires start date to be equal to or no later than current date
455
+ if start_dt > today:
456
+ return (
457
+ False,
458
+ f"Forecast start date '{start_date}' must be equal to or no later than the current date ({today})",
459
+ )
460
+
461
+ # End date must be in the future
462
+ if end_dt <= today:
463
+ return False, f"Forecast end date '{end_date}' must be in the future (after {today})"
464
+
465
+ # AWS Cost Explorer forecast granularity-specific limits
466
+ date_diff = (end_dt - start_dt).days
467
+
468
+ if granularity.upper() == 'DAILY':
469
+ # DAILY forecasts support maximum 3 months (approximately 93 days)
470
+ if date_diff > 93:
471
+ return (
472
+ False,
473
+ f'DAILY granularity supports a maximum of 3 months (93 days). Current range is {date_diff} days ({start_date} to {end_date}). Please use a shorter date range or MONTHLY granularity.',
474
+ )
475
+ elif granularity.upper() == 'MONTHLY':
476
+ # MONTHLY forecasts support maximum 12 months
477
+ max_forecast_date = datetime.now(timezone.utc).date().replace(year=today.year + 1)
478
+ if end_dt > max_forecast_date:
479
+ return (
480
+ False,
481
+ f"MONTHLY granularity supports a maximum of 12 months in the future. Forecast end date '{end_date}' exceeds the limit (max: {max_forecast_date}).",
482
+ )
483
+
484
+ return True, ''
485
+
486
+
487
+ def validate_comparison_date_range(start_date: str, end_date: str) -> Tuple[bool, str]:
488
+ """Validate that comparison dates meet AWS Cost Explorer comparison API requirements.
489
+
490
+ Args:
491
+ start_date: The start date string in YYYY-MM-DD format
492
+ end_date: The end date string in YYYY-MM-DD format
493
+
494
+ Returns:
495
+ Tuple of (is_valid, error_message)
496
+ """
497
+ # First validate basic date format and range
498
+ is_valid, error = validate_date_range(start_date, end_date)
499
+ if not is_valid:
500
+ return False, error
501
+
502
+ today = datetime.now(timezone.utc).date()
503
+ start_dt = datetime.strptime(start_date, '%Y-%m-%d').date()
504
+ end_dt = datetime.strptime(end_date, '%Y-%m-%d').date()
505
+
506
+ # AWS requires start date to be equal to or no later than current date
507
+ if start_dt > today:
508
+ return (
509
+ False,
510
+ f"Comparison start date '{start_date}' must be equal to or no later than the current date ({today})",
511
+ )
512
+
513
+ # Must start on the first day of a month
514
+ if start_dt.day != 1:
515
+ return (
516
+ False,
517
+ f"Comparison start date '{start_date}' must be the first day of a month (e.g., 2025-01-01)",
518
+ )
519
+
520
+ # Must end on the first day of a month (exclusive end date)
521
+ if end_dt.day != 1:
522
+ return (
523
+ False,
524
+ f"Comparison end date '{end_date}' must be the first day of a month (e.g., 2025-02-01)",
525
+ )
526
+
527
+ # Comparison periods can only go up to the last complete month
528
+ # Calculate the first day of current month (last complete month boundary)
529
+ current_month_start = today.replace(day=1)
530
+ # The comparison period (start_date) cannot be in the current month or future
531
+ if start_dt >= current_month_start:
532
+ # Calculate last complete month for user guidance
533
+ if current_month_start.month == 1:
534
+ last_complete_month = current_month_start.replace(
535
+ year=current_month_start.year - 1, month=12
536
+ )
537
+ else:
538
+ last_complete_month = current_month_start.replace(month=current_month_start.month - 1)
539
+ return (
540
+ False,
541
+ f'Comparison periods can only include complete months. Current month ({current_month_start.strftime("%Y-%m")}) is not complete yet. Latest allowed start date: {last_complete_month.strftime("%Y-%m-%d")}',
542
+ )
543
+
544
+ # Must be exactly one month duration
545
+ # Calculate expected end date (first day of next month)
546
+ if start_dt.month == 12:
547
+ expected_end = start_dt.replace(year=start_dt.year + 1, month=1)
548
+ else:
549
+ expected_end = start_dt.replace(month=start_dt.month + 1)
550
+
551
+ if end_dt != expected_end:
552
+ return (
553
+ False,
554
+ f"Comparison period must be exactly one month. For start date '{start_date}', end date should be '{expected_end.strftime('%Y-%m-%d')}'",
555
+ )
556
+
557
+ # Check 13-month lookback limit (38 months if multi-year enabled, but we'll use 13 as conservative)
558
+ thirteen_months_ago = today.replace(day=1)
559
+ for _ in range(13):
560
+ if thirteen_months_ago.month == 1:
561
+ thirteen_months_ago = thirteen_months_ago.replace(
562
+ year=thirteen_months_ago.year - 1, month=12
563
+ )
564
+ else:
565
+ thirteen_months_ago = thirteen_months_ago.replace(month=thirteen_months_ago.month - 1)
566
+
567
+ if start_dt < thirteen_months_ago:
568
+ return (
569
+ False,
570
+ f"Comparison start date '{start_date}' cannot be more than 13 months ago (earliest: {thirteen_months_ago.strftime('%Y-%m-%d')})",
571
+ )
572
+
573
+ return True, ''
574
+
575
+
576
+ def extract_group_key_from_complex_selector(
577
+ selector: Dict[str, Any], group_by: Dict[str, str]
578
+ ) -> str:
579
+ """Extract group key from complex CostSelector structures dynamically.
580
+
581
+ Args:
582
+ selector: The CostSelector dictionary from API response
583
+ group_by: The GroupBy dictionary with Type and Key
584
+
585
+ Returns:
586
+ String representing the group key
587
+ """
588
+ group_type = group_by.get('Type', '').upper()
589
+ group_key = group_by.get('Key', '')
590
+
591
+ def search_for_group_key(sel_part):
592
+ """Recursively search for the group key in any part of the selector."""
593
+ if isinstance(sel_part, dict):
594
+ # Check if this is the structure we're looking for
595
+ if group_type == 'DIMENSION' and 'Dimensions' in sel_part:
596
+ dim_info = sel_part['Dimensions']
597
+ if dim_info.get('Key') == group_key and 'Values' in dim_info:
598
+ values = dim_info['Values']
599
+ return values[0] if values and values[0] else f'No {group_key}'
600
+
601
+ elif group_type == 'TAG' and 'Tags' in sel_part:
602
+ tag_info = sel_part['Tags']
603
+ if tag_info.get('Key') == group_key and 'Values' in tag_info:
604
+ values = tag_info['Values']
605
+ return values[0] if values and values[0] else f'No {group_key}'
606
+
607
+ elif group_type == 'COST_CATEGORY' and 'CostCategories' in sel_part:
608
+ cc_info = sel_part['CostCategories']
609
+ if cc_info.get('Key') == group_key and 'Values' in cc_info:
610
+ values = cc_info['Values']
611
+ return values[0] if values and values[0] else f'No {group_key}'
612
+
613
+ # Recursively search in nested structures
614
+ for key, value in sel_part.items():
615
+ if key in ['And', 'Or'] and isinstance(value, list):
616
+ for item in value:
617
+ result = search_for_group_key(item)
618
+ if result:
619
+ return result
620
+ elif key == 'Not' and isinstance(value, dict):
621
+ result = search_for_group_key(value)
622
+ if result:
623
+ return result
624
+
625
+ return None
626
+
627
+ result = search_for_group_key(selector)
628
+ return result if result else 'Unknown'
629
+
630
+
631
+ def extract_usage_context_from_selector(selector: Dict[str, Any]) -> Dict[str, str]:
632
+ """Extract all available context from complex selectors dynamically.
633
+
634
+ Args:
635
+ selector: The CostSelector dictionary from API response
636
+
637
+ Returns:
638
+ Dictionary with all available context information
639
+ """
640
+ context = {}
641
+
642
+ def extract_from_structure(sel_part):
643
+ """Recursively extract context from any part of the selector."""
644
+ if isinstance(sel_part, dict):
645
+ # Extract from Dimensions
646
+ if 'Dimensions' in sel_part:
647
+ dim_info = sel_part['Dimensions']
648
+ key = dim_info.get('Key', '')
649
+ values = dim_info.get('Values', [])
650
+ if values and values[0]: # Skip empty values
651
+ context[key.lower()] = values[0]
652
+
653
+ # Extract from Tags
654
+ if 'Tags' in sel_part:
655
+ tag_info = sel_part['Tags']
656
+ tag_key = tag_info.get('Key', '')
657
+ values = tag_info.get('Values', [])
658
+ if values and values[0]:
659
+ context[f'tag_{tag_key.lower()}'] = values[0]
660
+
661
+ # Extract from CostCategories
662
+ if 'CostCategories' in sel_part:
663
+ cc_info = sel_part['CostCategories']
664
+ cc_key = cc_info.get('Key', '')
665
+ values = cc_info.get('Values', [])
666
+ if values and values[0]:
667
+ context[f'category_{cc_key.lower()}'] = values[0]
668
+
669
+ # Recursively process nested structures
670
+ for key, value in sel_part.items():
671
+ if key in ['And', 'Or'] and isinstance(value, list):
672
+ for item in value:
673
+ extract_from_structure(item)
674
+ elif key == 'Not' and isinstance(value, dict):
675
+ extract_from_structure(value)
676
+
677
+ extract_from_structure(selector)
678
+ return context
679
+
680
+
681
+ def create_detailed_group_key(
682
+ group_key: str, context: Dict[str, str], group_by: Dict[str, str]
683
+ ) -> str:
684
+ """Create a detailed group key that includes relevant context.
685
+
686
+ Since AWS always includes SERVICE and USAGE_TYPE, we can use them for context.
687
+
688
+ Args:
689
+ group_key: The primary group key extracted from the selector
690
+ context: Additional context from the selector
691
+ group_by: The GroupBy dictionary with Type and Key
692
+
693
+ Returns:
694
+ Enhanced group key with context
695
+ """
696
+ # Get the always-present context
697
+ service = context.get('service', '')
698
+ usage_type = context.get('usage_type', '')
699
+
700
+ # Create a meaningful key based on what's available
701
+ parts = [group_key]
702
+
703
+ # Add service context if it's not the group key itself
704
+ if service and group_by.get('Key') != 'SERVICE':
705
+ parts.append(service)
706
+
707
+ # Add usage type in parentheses for specificity
708
+ if usage_type:
709
+ return f'{" - ".join(parts)} ({usage_type})'
710
+
711
+ return ' - '.join(parts)