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.
- awslabs/cost_explorer_mcp_server/comparison_handler.py +719 -0
- awslabs/cost_explorer_mcp_server/constants.py +106 -0
- awslabs/cost_explorer_mcp_server/cost_usage_handler.py +385 -0
- awslabs/cost_explorer_mcp_server/forecasting_handler.py +234 -0
- awslabs/cost_explorer_mcp_server/helpers.py +501 -68
- awslabs/cost_explorer_mcp_server/metadata_handler.py +88 -0
- awslabs/cost_explorer_mcp_server/models.py +70 -0
- awslabs/cost_explorer_mcp_server/server.py +60 -463
- awslabs/cost_explorer_mcp_server/utility_handler.py +50 -0
- {awslabs_cost_explorer_mcp_server-0.0.4.dist-info → awslabs_cost_explorer_mcp_server-0.0.6.dist-info}/METADATA +44 -14
- awslabs_cost_explorer_mcp_server-0.0.6.dist-info/RECORD +17 -0
- awslabs_cost_explorer_mcp_server-0.0.4.dist-info/RECORD +0 -10
- {awslabs_cost_explorer_mcp_server-0.0.4.dist-info → awslabs_cost_explorer_mcp_server-0.0.6.dist-info}/WHEEL +0 -0
- {awslabs_cost_explorer_mcp_server-0.0.4.dist-info → awslabs_cost_explorer_mcp_server-0.0.6.dist-info}/entry_points.txt +0 -0
- {awslabs_cost_explorer_mcp_server-0.0.4.dist-info → awslabs_cost_explorer_mcp_server-0.0.6.dist-info}/licenses/LICENSE +0 -0
- {awslabs_cost_explorer_mcp_server-0.0.4.dist-info → awslabs_cost_explorer_mcp_server-0.0.6.dist-info}/licenses/NOTICE +0 -0
|
@@ -15,60 +15,91 @@
|
|
|
15
15
|
"""Helper functions for the Cost Explorer MCP server."""
|
|
16
16
|
|
|
17
17
|
import boto3
|
|
18
|
-
import
|
|
18
|
+
import os
|
|
19
19
|
import re
|
|
20
|
-
|
|
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
|
-
#
|
|
25
|
-
logger
|
|
32
|
+
# Configure Loguru logging
|
|
33
|
+
logger.remove()
|
|
34
|
+
logger.add(sys.stderr, level=os.getenv('FASTMCP_LOG_LEVEL', 'WARNING'))
|
|
26
35
|
|
|
27
|
-
#
|
|
28
|
-
|
|
36
|
+
# Global client cache
|
|
37
|
+
_cost_explorer_client = None
|
|
29
38
|
|
|
30
39
|
|
|
31
|
-
def
|
|
32
|
-
"""
|
|
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
|
-
|
|
71
|
+
dimension_key: The dimension key to validate
|
|
36
72
|
|
|
37
73
|
Returns:
|
|
38
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
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
|
|
57
|
-
|
|
58
|
-
if
|
|
59
|
-
return
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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(
|
|
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
|
|
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
|
|
89
|
-
|
|
90
|
-
if not
|
|
91
|
-
return {'error':
|
|
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(
|
|
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
|
|
129
|
-
|
|
130
|
-
if not
|
|
131
|
-
return {'error':
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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)
|