awslabs.cost-explorer-mcp-server 0.0.2__tar.gz → 0.0.5__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 (30) hide show
  1. awslabs_cost_explorer_mcp_server-0.0.5/CHANGELOG.md +27 -0
  2. {awslabs_cost_explorer_mcp_server-0.0.2 → awslabs_cost_explorer_mcp_server-0.0.5}/Dockerfile +11 -8
  3. {awslabs_cost_explorer_mcp_server-0.0.2 → awslabs_cost_explorer_mcp_server-0.0.5}/PKG-INFO +6 -2
  4. {awslabs_cost_explorer_mcp_server-0.0.2 → awslabs_cost_explorer_mcp_server-0.0.5}/README.md +3 -0
  5. awslabs_cost_explorer_mcp_server-0.0.5/awslabs/__init__.py +17 -0
  6. awslabs_cost_explorer_mcp_server-0.0.5/awslabs/cost_explorer_mcp_server/__init__.py +20 -0
  7. awslabs_cost_explorer_mcp_server-0.0.5/awslabs/cost_explorer_mcp_server/helpers.py +389 -0
  8. awslabs_cost_explorer_mcp_server-0.0.5/awslabs/cost_explorer_mcp_server/server.py +517 -0
  9. awslabs_cost_explorer_mcp_server-0.0.5/docker-healthcheck.sh +26 -0
  10. {awslabs_cost_explorer_mcp_server-0.0.2 → awslabs_cost_explorer_mcp_server-0.0.5}/pyproject.toml +25 -40
  11. awslabs_cost_explorer_mcp_server-0.0.5/tests/__init__.py +15 -0
  12. awslabs_cost_explorer_mcp_server-0.0.5/tests/conftest.py +150 -0
  13. awslabs_cost_explorer_mcp_server-0.0.5/tests/test_helpers.py +919 -0
  14. awslabs_cost_explorer_mcp_server-0.0.5/tests/test_server.py +1378 -0
  15. {awslabs_cost_explorer_mcp_server-0.0.2 → awslabs_cost_explorer_mcp_server-0.0.5}/uv.lock +392 -184
  16. awslabs_cost_explorer_mcp_server-0.0.2/.pre-commit-config.yaml +0 -19
  17. awslabs_cost_explorer_mcp_server-0.0.2/CHANGELOG.md +0 -11
  18. awslabs_cost_explorer_mcp_server-0.0.2/awslabs/__init__.py +0 -3
  19. awslabs_cost_explorer_mcp_server-0.0.2/awslabs/cost_explorer_mcp_server/__init__.py +0 -6
  20. awslabs_cost_explorer_mcp_server-0.0.2/awslabs/cost_explorer_mcp_server/helpers.py +0 -259
  21. awslabs_cost_explorer_mcp_server-0.0.2/awslabs/cost_explorer_mcp_server/server.py +0 -476
  22. awslabs_cost_explorer_mcp_server-0.0.2/docker-healthcheck.sh +0 -12
  23. awslabs_cost_explorer_mcp_server-0.0.2/tests/__init__.py +0 -1
  24. awslabs_cost_explorer_mcp_server-0.0.2/tests/conftest.py +0 -93
  25. awslabs_cost_explorer_mcp_server-0.0.2/tests/test_helpers.py +0 -282
  26. awslabs_cost_explorer_mcp_server-0.0.2/tests/test_server.py +0 -427
  27. {awslabs_cost_explorer_mcp_server-0.0.2 → awslabs_cost_explorer_mcp_server-0.0.5}/.gitignore +0 -0
  28. {awslabs_cost_explorer_mcp_server-0.0.2 → awslabs_cost_explorer_mcp_server-0.0.5}/.python-version +0 -0
  29. {awslabs_cost_explorer_mcp_server-0.0.2 → awslabs_cost_explorer_mcp_server-0.0.5}/LICENSE +0 -0
  30. {awslabs_cost_explorer_mcp_server-0.0.2 → awslabs_cost_explorer_mcp_server-0.0.5}/NOTICE +0 -0
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.5.0] - 2025-06-16
9
+
10
+ ### Added
11
+ - Enhanced documentation for UsageQuantity metric with specific filtering requirements
12
+ - Dynamic labeling for cost metrics based on grouping dimension (e.g., "Region Total" vs "Service Total")
13
+ - Metadata support for usage metrics including grouped_by, metric type, and period information
14
+ - Optimized JSON serialization that only runs stringify_keys when needed
15
+ - Added Match_Option Validation
16
+
17
+ ### Changed
18
+ - Usage metrics now return clean nested structure instead of complex pandas tuples
19
+ - Old: `{("2025-01-01", "Amount"): {"EC2": 100}, ("2025-01-01", "Unit"): {"EC2": "Hours"}}`
20
+ - New: `{"GroupedUsage": {"2025-01-01": {"EC2": {"amount": 100, "unit": "Hours"}}}}`
21
+ - Improved UsageQuantity documentation to emphasize need for SERVICE + USAGE_TYPE filtering
22
+ - Enhanced error handling for metric data processing
23
+
24
+ ## [0.1.0] - 2025-06-01
25
+
26
+ ### Added
27
+ - Initial release of the Cost Explorer MCP Server
@@ -1,15 +1,18 @@
1
1
  # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
2
  #
3
- # Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance
4
- # with the License. A copy of the License is located at
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
5
6
  #
6
- # http://www.apache.org/licenses/LICENSE-2.0
7
+ # http://www.apache.org/licenses/LICENSE-2.0
7
8
  #
8
- # or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES
9
- # OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions
10
- # and limitations under the License.
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
11
14
 
12
- FROM public.ecr.aws/sam/build-python3.10@sha256:e78695db10ca8cb129e59e30f7dc9789b0dbd0181dba195d68419c72bac51ac1 AS uv
15
+ FROM public.ecr.aws/sam/build-python3.10@sha256:d821662474d65f3cf2fc97dba2fa807a3adb580d02895fc4545527812550ea65 AS uv
13
16
 
14
17
  # Install the project into `/app`
15
18
  WORKDIR /app
@@ -43,7 +46,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
43
46
  # Make the directory just in case it doesn't exist
44
47
  RUN mkdir -p /root/.local
45
48
 
46
- FROM public.ecr.aws/sam/build-python3.10@sha256:e78695db10ca8cb129e59e30f7dc9789b0dbd0181dba195d68419c72bac51ac1
49
+ FROM public.ecr.aws/sam/build-python3.10@sha256:d821662474d65f3cf2fc97dba2fa807a3adb580d02895fc4545527812550ea65
47
50
 
48
51
  # Place executables in the environment at the front of the path and include other binaries
49
52
  ENV PATH="/app/.venv/bin:$PATH:/usr/sbin"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: awslabs.cost-explorer-mcp-server
3
- Version: 0.0.2
3
+ Version: 0.0.5
4
4
  Summary: MCP server for analyzing AWS costs and usage data through the AWS Cost Explorer API
5
5
  Project-URL: Homepage, https://awslabs.github.io/mcp/
6
6
  Project-URL: Documentation, https://awslabs.github.io/mcp/servers/cost-explorer-mcp-server/
@@ -22,7 +22,8 @@ Classifier: Programming Language :: Python :: 3.12
22
22
  Classifier: Programming Language :: Python :: 3.13
23
23
  Requires-Python: >=3.10
24
24
  Requires-Dist: boto3>=1.36.20
25
- Requires-Dist: fastmcp>=0.1.0
25
+ Requires-Dist: loguru>=0.7.0
26
+ Requires-Dist: mcp[cli]>=1.6.0
26
27
  Requires-Dist: pandas>=2.2.3
27
28
  Requires-Dist: pydantic>=2.10.6
28
29
  Description-Content-Type: text/markdown
@@ -63,6 +64,8 @@ MCP server for analyzing AWS costs and usage data through the AWS Cost Explorer
63
64
 
64
65
  ## Installation
65
66
 
67
+ [![Install MCP Server](https://cursor.com/deeplink/mcp-install-light.svg)](https://cursor.com/install-mcp?name=awslabs.cost-explorer-mcp-server&config=eyJjb21tYW5kIjoidXZ4IGF3c2xhYnMuY29zdC1leHBsb3Jlci1tY3Atc2VydmVyQGxhdGVzdCIsImVudiI6eyJGQVNUTUNQX0xPR19MRVZFTCI6IkVSUk9SIiwiQVdTX1BST0ZJTEUiOiJ5b3VyLWF3cy1wcm9maWxlIn0sImRpc2FibGVkIjpmYWxzZSwiYXV0b0FwcHJvdmUiOltdfQ%3D%3D)
68
+
66
69
  Here are some ways you can work with MCP across AWS, and we'll be adding support to more products including Amazon Q Developer CLI soon: (e.g. for Amazon Q Developer CLI MCP, `~/.aws/amazonq/mcp.json`):
67
70
 
68
71
  ```json
@@ -127,6 +130,7 @@ The MCP server uses the AWS profile specified in the `AWS_PROFILE` environment v
127
130
  ```
128
131
 
129
132
  Make sure the AWS profile has permissions to access the AWS Cost Explorer API. The MCP server creates a boto3 session using the specified profile to authenticate with AWS services. Your AWS IAM credentials remain on your local machine and are strictly used for accessing AWS services.
133
+
130
134
  ## Cost Considerations
131
135
 
132
136
  **Important:** AWS Cost Explorer API incurs charges on a per-request basis. Each API call made by this MCP server will result in charges to your AWS account.
@@ -34,6 +34,8 @@ MCP server for analyzing AWS costs and usage data through the AWS Cost Explorer
34
34
 
35
35
  ## Installation
36
36
 
37
+ [![Install MCP Server](https://cursor.com/deeplink/mcp-install-light.svg)](https://cursor.com/install-mcp?name=awslabs.cost-explorer-mcp-server&config=eyJjb21tYW5kIjoidXZ4IGF3c2xhYnMuY29zdC1leHBsb3Jlci1tY3Atc2VydmVyQGxhdGVzdCIsImVudiI6eyJGQVNUTUNQX0xPR19MRVZFTCI6IkVSUk9SIiwiQVdTX1BST0ZJTEUiOiJ5b3VyLWF3cy1wcm9maWxlIn0sImRpc2FibGVkIjpmYWxzZSwiYXV0b0FwcHJvdmUiOltdfQ%3D%3D)
38
+
37
39
  Here are some ways you can work with MCP across AWS, and we'll be adding support to more products including Amazon Q Developer CLI soon: (e.g. for Amazon Q Developer CLI MCP, `~/.aws/amazonq/mcp.json`):
38
40
 
39
41
  ```json
@@ -98,6 +100,7 @@ The MCP server uses the AWS profile specified in the `AWS_PROFILE` environment v
98
100
  ```
99
101
 
100
102
  Make sure the AWS profile has permissions to access the AWS Cost Explorer API. The MCP server creates a boto3 session using the specified profile to authenticate with AWS services. Your AWS IAM credentials remain on your local machine and are strictly used for accessing AWS services.
103
+
101
104
  ## Cost Considerations
102
105
 
103
106
  **Important:** AWS Cost Explorer API incurs charges on a per-request basis. Each API call made by this MCP server will result in charges to your AWS account.
@@ -0,0 +1,17 @@
1
+ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """
16
+ AWS Labs Cost Explorer MCP Server package.
17
+ """
@@ -0,0 +1,20 @@
1
+ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Cost Explorer MCP Server module.
16
+
17
+ This module provides MCP tools for analyzing AWS costs and usage data through the AWS Cost Explorer API.
18
+ """
19
+
20
+ __version__ = '0.0.0'
@@ -0,0 +1,389 @@
1
+ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Helper functions for the Cost Explorer MCP server."""
16
+
17
+ import boto3
18
+ import os
19
+ import re
20
+ import sys
21
+ from datetime import datetime
22
+ from loguru import logger
23
+ from typing import Any, Dict, Optional, Tuple
24
+
25
+
26
+ # Configure Loguru logging
27
+ logger.remove()
28
+ logger.add(sys.stderr, level=os.getenv('FASTMCP_LOG_LEVEL', 'WARNING'))
29
+
30
+ # Global client cache
31
+ _cost_explorer_client = None
32
+
33
+
34
+ def get_cost_explorer_client():
35
+ """Get Cost Explorer client with proper session management and caching.
36
+
37
+ Returns:
38
+ boto3.client: Configured Cost Explorer client (cached after first call)
39
+ """
40
+ global _cost_explorer_client
41
+
42
+ if _cost_explorer_client is None:
43
+ try:
44
+ # Read environment variables dynamically
45
+ aws_region = os.environ.get('AWS_REGION', 'us-east-1')
46
+ aws_profile = os.environ.get('AWS_PROFILE')
47
+
48
+ if aws_profile:
49
+ _cost_explorer_client = boto3.Session(
50
+ profile_name=aws_profile, region_name=aws_region
51
+ ).client('ce')
52
+ else:
53
+ _cost_explorer_client = boto3.Session(region_name=aws_region).client('ce')
54
+ except Exception as e:
55
+ logger.error(f'Error creating Cost Explorer client: {str(e)}')
56
+ raise
57
+
58
+ return _cost_explorer_client
59
+
60
+
61
+ def validate_date_format(date_str: str) -> Tuple[bool, str]:
62
+ """Validate that a date string is in YYYY-MM-DD format and is a valid date.
63
+
64
+ Args:
65
+ date_str: The date string to validate
66
+
67
+ Returns:
68
+ Tuple of (is_valid, error_message)
69
+ """
70
+ # Check format with regex
71
+ if not re.match(r'^\d{4}-\d{2}-\d{2}$', date_str):
72
+ return False, f"Date '{date_str}' is not in YYYY-MM-DD format"
73
+
74
+ # Check if it's a valid date
75
+ try:
76
+ datetime.strptime(date_str, '%Y-%m-%d')
77
+ return True, ''
78
+ except ValueError as e:
79
+ return False, f"Invalid date '{date_str}': {str(e)}"
80
+
81
+
82
+ def format_date_for_api(date_str: str, granularity: str) -> str:
83
+ """Format date string appropriately for AWS Cost Explorer API based on granularity.
84
+
85
+ Args:
86
+ date_str: Date string in YYYY-MM-DD format
87
+ granularity: The granularity (DAILY, MONTHLY, HOURLY)
88
+
89
+ Returns:
90
+ Formatted date string appropriate for the API call
91
+ """
92
+ if granularity.upper() == 'HOURLY':
93
+ # For hourly granularity, AWS expects datetime format
94
+ # Convert YYYY-MM-DD to YYYY-MM-DDTHH:MM:SSZ
95
+ dt = datetime.strptime(date_str, '%Y-%m-%d')
96
+ return dt.strftime('%Y-%m-%dT00:00:00Z')
97
+ else:
98
+ # For DAILY and MONTHLY, use the original date format
99
+ return date_str
100
+
101
+
102
+ def validate_date_range(
103
+ start_date: str, end_date: str, granularity: Optional[str] = None
104
+ ) -> Tuple[bool, str]:
105
+ """Validate date range with format and logical checks.
106
+
107
+ Args:
108
+ start_date: The start date string in YYYY-MM-DD format
109
+ end_date: The end date string in YYYY-MM-DD format
110
+ granularity: Optional granularity to check specific constraints
111
+
112
+ Returns:
113
+ Tuple of (is_valid, error_message)
114
+ """
115
+ # Validate start date format
116
+ is_valid_start, error_start = validate_date_format(start_date)
117
+ if not is_valid_start:
118
+ return False, error_start
119
+
120
+ # Validate end date format
121
+ is_valid_end, error_end = validate_date_format(end_date)
122
+ if not is_valid_end:
123
+ return False, error_end
124
+
125
+ # Validate date range logic
126
+ start_dt = datetime.strptime(start_date, '%Y-%m-%d')
127
+ end_dt = datetime.strptime(end_date, '%Y-%m-%d')
128
+ if start_dt > end_dt:
129
+ return False, f"Start date '{start_date}' cannot be after end date '{end_date}'"
130
+
131
+ # Validate granularity-specific constraints
132
+ if granularity and granularity.upper() == 'HOURLY':
133
+ # HOURLY granularity supports maximum 14 days
134
+ date_diff = (end_dt - start_dt).days
135
+ if date_diff > 14:
136
+ return (
137
+ False,
138
+ 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.',
139
+ )
140
+
141
+ return True, ''
142
+
143
+
144
+ def get_dimension_values(
145
+ key: str, billing_period_start: str, billing_period_end: str
146
+ ) -> Dict[str, Any]:
147
+ """Get available values for a specific dimension."""
148
+ # Validate date range (no granularity constraint for dimension values)
149
+ is_valid, error_message = validate_date_range(billing_period_start, billing_period_end)
150
+ if not is_valid:
151
+ return {'error': error_message}
152
+
153
+ try:
154
+ ce = get_cost_explorer_client()
155
+ response = ce.get_dimension_values(
156
+ TimePeriod={'Start': billing_period_start, 'End': billing_period_end},
157
+ Dimension=key.upper(),
158
+ )
159
+ dimension_values = response['DimensionValues']
160
+ values = [value['Value'] for value in dimension_values]
161
+ return {'dimension': key.upper(), 'values': values}
162
+ except Exception as e:
163
+ logger.error(
164
+ f'Error getting dimension values for {key.upper()} ({billing_period_start} to {billing_period_end}): {e}'
165
+ )
166
+ return {'error': str(e)}
167
+
168
+
169
+ def get_tag_values(
170
+ tag_key: str, billing_period_start: str, billing_period_end: str
171
+ ) -> Dict[str, Any]:
172
+ """Get available values for a specific tag key."""
173
+ # Validate date range (no granularity constraint for tag values)
174
+ is_valid, error_message = validate_date_range(billing_period_start, billing_period_end)
175
+ if not is_valid:
176
+ return {'error': error_message}
177
+
178
+ try:
179
+ ce = get_cost_explorer_client()
180
+ response = ce.get_tags(
181
+ TimePeriod={'Start': billing_period_start, 'End': billing_period_end},
182
+ TagKey=tag_key,
183
+ )
184
+ tag_values = response['Tags']
185
+ return {'tag_key': tag_key, 'values': tag_values}
186
+ except Exception as e:
187
+ logger.error(
188
+ f'Error getting tag values for {tag_key} ({billing_period_start} to {billing_period_end}): {e}'
189
+ )
190
+ return {'error': str(e)}
191
+
192
+
193
+ def validate_match_options(match_options: list, filter_type: str) -> Dict[str, Any]:
194
+ """Validate MatchOptions based on filter type.
195
+
196
+ Args:
197
+ match_options: List of match options to validate
198
+ filter_type: Type of filter ('Dimensions', 'Tags', 'CostCategories')
199
+
200
+ Returns:
201
+ Empty dictionary if valid, or an error dictionary
202
+ """
203
+ if filter_type == 'Dimensions':
204
+ valid_options = ['EQUALS', 'CASE_SENSITIVE']
205
+ elif filter_type in ['Tags', 'CostCategories']:
206
+ valid_options = ['EQUALS', 'ABSENT', 'CASE_SENSITIVE']
207
+ else:
208
+ return {'error': f'Unknown filter type: {filter_type}'}
209
+
210
+ for option in match_options:
211
+ if option not in valid_options:
212
+ return {
213
+ 'error': f"Invalid MatchOption '{option}' for {filter_type}. Valid values are: {valid_options}"
214
+ }
215
+
216
+ return {}
217
+
218
+
219
+ def validate_expression(
220
+ expression: Dict[str, Any], billing_period_start: str, billing_period_end: str
221
+ ) -> Dict[str, Any]:
222
+ """Recursively validate the filter expression.
223
+
224
+ Args:
225
+ expression: The filter expression to validate
226
+ billing_period_start: Start date of the billing period
227
+ billing_period_end: End date of the billing period
228
+
229
+ Returns:
230
+ Empty dictionary if valid, or an error dictionary
231
+ """
232
+ # Validate date range (no granularity constraint for filter validation)
233
+ is_valid, error_message = validate_date_range(billing_period_start, billing_period_end)
234
+ if not is_valid:
235
+ return {'error': error_message}
236
+
237
+ try:
238
+ if 'Dimensions' in expression:
239
+ dimension = expression['Dimensions']
240
+ if (
241
+ 'Key' not in dimension
242
+ or 'Values' not in dimension
243
+ or 'MatchOptions' not in dimension
244
+ ):
245
+ return {
246
+ 'error': 'Dimensions filter must include "Key", "Values", and "MatchOptions".'
247
+ }
248
+
249
+ # Validate MatchOptions for Dimensions
250
+ match_options_result = validate_match_options(dimension['MatchOptions'], 'Dimensions')
251
+ if 'error' in match_options_result:
252
+ return match_options_result
253
+
254
+ dimension_key = dimension['Key']
255
+ dimension_values = dimension['Values']
256
+ valid_values_response = get_dimension_values(
257
+ dimension_key, billing_period_start, billing_period_end
258
+ )
259
+ if 'error' in valid_values_response:
260
+ return {'error': valid_values_response['error']}
261
+ valid_values = valid_values_response['values']
262
+ for value in dimension_values:
263
+ if value not in valid_values:
264
+ return {
265
+ 'error': f"Invalid value '{value}' for dimension '{dimension_key}'. Valid values are: {valid_values}"
266
+ }
267
+
268
+ if 'Tags' in expression:
269
+ tag = expression['Tags']
270
+ if 'Key' not in tag or 'Values' not in tag or 'MatchOptions' not in tag:
271
+ return {'error': 'Tags filter must include "Key", "Values", and "MatchOptions".'}
272
+
273
+ # Validate MatchOptions for Tags
274
+ match_options_result = validate_match_options(tag['MatchOptions'], 'Tags')
275
+ if 'error' in match_options_result:
276
+ return match_options_result
277
+
278
+ tag_key = tag['Key']
279
+ tag_values = tag['Values']
280
+ valid_tag_values_response = get_tag_values(
281
+ tag_key, billing_period_start, billing_period_end
282
+ )
283
+ if 'error' in valid_tag_values_response:
284
+ return {'error': valid_tag_values_response['error']}
285
+ valid_tag_values = valid_tag_values_response['values']
286
+ for value in tag_values:
287
+ if value not in valid_tag_values:
288
+ return {
289
+ 'error': f"Invalid value '{value}' for tag '{tag_key}'. Valid values are: {valid_tag_values}"
290
+ }
291
+
292
+ if 'CostCategories' in expression:
293
+ cost_category = expression['CostCategories']
294
+ if (
295
+ 'Key' not in cost_category
296
+ or 'Values' not in cost_category
297
+ or 'MatchOptions' not in cost_category
298
+ ):
299
+ return {
300
+ 'error': 'CostCategories filter must include "Key", "Values", and "MatchOptions".'
301
+ }
302
+
303
+ # Validate MatchOptions for CostCategories
304
+ match_options_result = validate_match_options(
305
+ cost_category['MatchOptions'], 'CostCategories'
306
+ )
307
+ if 'error' in match_options_result:
308
+ return match_options_result
309
+
310
+ logical_operators = ['And', 'Or', 'Not']
311
+ logical_count = sum(1 for op in logical_operators if op in expression)
312
+
313
+ if logical_count > 1:
314
+ return {
315
+ 'error': 'Only one logical operator (And, Or, Not) is allowed per expression in filter parameter.'
316
+ }
317
+
318
+ if logical_count == 0 and len(expression) > 1:
319
+ return {
320
+ 'error': 'Filter parameter with multiple expressions require a logical operator (And, Or, Not).'
321
+ }
322
+
323
+ if 'And' in expression:
324
+ if not isinstance(expression['And'], list):
325
+ return {'error': 'And expression must be a list of expressions.'}
326
+ for sub_expression in expression['And']:
327
+ result = validate_expression(
328
+ sub_expression, billing_period_start, billing_period_end
329
+ )
330
+ if 'error' in result:
331
+ return result
332
+
333
+ if 'Or' in expression:
334
+ if not isinstance(expression['Or'], list):
335
+ return {'error': 'Or expression must be a list of expressions.'}
336
+ for sub_expression in expression['Or']:
337
+ result = validate_expression(
338
+ sub_expression, billing_period_start, billing_period_end
339
+ )
340
+ if 'error' in result:
341
+ return result
342
+
343
+ if 'Not' in expression:
344
+ if not isinstance(expression['Not'], dict):
345
+ return {'error': 'Not expression must be a single expression.'}
346
+ result = validate_expression(
347
+ expression['Not'], billing_period_start, billing_period_end
348
+ )
349
+ if 'error' in result:
350
+ return result
351
+
352
+ if not any(
353
+ k in expression for k in ['Dimensions', 'Tags', 'CostCategories', 'And', 'Or', 'Not']
354
+ ):
355
+ return {
356
+ 'error': 'Filter Expression must include at least one of the following keys: "Dimensions", "Tags", "CostCategories", "And", "Or", "Not".'
357
+ }
358
+
359
+ return {}
360
+ except Exception as e:
361
+ return {'error': f'Error validating expression: {str(e)}'}
362
+
363
+
364
+ def validate_group_by(group_by: Optional[Dict[str, Any]]) -> Dict[str, Any]:
365
+ """Validate the group_by parameter.
366
+
367
+ Args:
368
+ group_by: The group_by dictionary to validate
369
+
370
+ Returns:
371
+ Empty dictionary if valid, or an error dictionary
372
+ """
373
+ try:
374
+ if (
375
+ group_by is None
376
+ or not isinstance(group_by, dict)
377
+ or 'Type' not in group_by
378
+ or 'Key' not in group_by
379
+ ):
380
+ return {'error': 'group_by must be a dictionary with "Type" and "Key" keys.'}
381
+
382
+ if group_by['Type'].upper() not in ['DIMENSION', 'TAG', 'COST_CATEGORY']:
383
+ return {
384
+ 'error': 'Invalid group Type. Valid types are DIMENSION, TAG, and COST_CATEGORY.'
385
+ }
386
+
387
+ return {}
388
+ except Exception as e:
389
+ return {'error': f'Error validating group_by: {str(e)}'}