awslabs.cost-explorer-mcp-server 0.0.1__tar.gz → 0.0.4__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 (29) hide show
  1. awslabs_cost_explorer_mcp_server-0.0.4/.pre-commit-config.yaml +14 -0
  2. awslabs_cost_explorer_mcp_server-0.0.4/Dockerfile +85 -0
  3. {awslabs_cost_explorer_mcp_server-0.0.1 → awslabs_cost_explorer_mcp_server-0.0.4}/PKG-INFO +15 -18
  4. {awslabs_cost_explorer_mcp_server-0.0.1 → awslabs_cost_explorer_mcp_server-0.0.4}/README.md +13 -16
  5. awslabs_cost_explorer_mcp_server-0.0.4/awslabs/__init__.py +17 -0
  6. awslabs_cost_explorer_mcp_server-0.0.4/awslabs/cost_explorer_mcp_server/__init__.py +20 -0
  7. {awslabs_cost_explorer_mcp_server-0.0.1 → awslabs_cost_explorer_mcp_server-0.0.4}/awslabs/cost_explorer_mcp_server/helpers.py +118 -61
  8. {awslabs_cost_explorer_mcp_server-0.0.1 → awslabs_cost_explorer_mcp_server-0.0.4}/awslabs/cost_explorer_mcp_server/server.py +194 -107
  9. awslabs_cost_explorer_mcp_server-0.0.4/docker-healthcheck.sh +26 -0
  10. {awslabs_cost_explorer_mcp_server-0.0.1 → awslabs_cost_explorer_mcp_server-0.0.4}/pyproject.toml +25 -41
  11. awslabs_cost_explorer_mcp_server-0.0.4/tests/__init__.py +15 -0
  12. awslabs_cost_explorer_mcp_server-0.0.4/tests/conftest.py +110 -0
  13. awslabs_cost_explorer_mcp_server-0.0.4/tests/test_helpers.py +666 -0
  14. awslabs_cost_explorer_mcp_server-0.0.4/tests/test_server.py +885 -0
  15. {awslabs_cost_explorer_mcp_server-0.0.1 → awslabs_cost_explorer_mcp_server-0.0.4}/uv.lock +368 -184
  16. awslabs_cost_explorer_mcp_server-0.0.1/.pre-commit-config.yaml +0 -19
  17. awslabs_cost_explorer_mcp_server-0.0.1/Dockerfile +0 -26
  18. awslabs_cost_explorer_mcp_server-0.0.1/awslabs/__init__.py +0 -3
  19. awslabs_cost_explorer_mcp_server-0.0.1/awslabs/cost_explorer_mcp_server/__init__.py +0 -6
  20. awslabs_cost_explorer_mcp_server-0.0.1/docker-healthcheck.sh +0 -9
  21. awslabs_cost_explorer_mcp_server-0.0.1/tests/__init__.py +0 -1
  22. awslabs_cost_explorer_mcp_server-0.0.1/tests/conftest.py +0 -160
  23. awslabs_cost_explorer_mcp_server-0.0.1/tests/test_helpers.py +0 -240
  24. awslabs_cost_explorer_mcp_server-0.0.1/tests/test_server.py +0 -373
  25. {awslabs_cost_explorer_mcp_server-0.0.1 → awslabs_cost_explorer_mcp_server-0.0.4}/.gitignore +0 -0
  26. {awslabs_cost_explorer_mcp_server-0.0.1 → awslabs_cost_explorer_mcp_server-0.0.4}/.python-version +0 -0
  27. {awslabs_cost_explorer_mcp_server-0.0.1 → awslabs_cost_explorer_mcp_server-0.0.4}/CHANGELOG.md +0 -0
  28. {awslabs_cost_explorer_mcp_server-0.0.1 → awslabs_cost_explorer_mcp_server-0.0.4}/LICENSE +0 -0
  29. {awslabs_cost_explorer_mcp_server-0.0.1 → awslabs_cost_explorer_mcp_server-0.0.4}/NOTICE +0 -0
@@ -0,0 +1,14 @@
1
+ repos:
2
+ - repo: https://github.com/astral-sh/ruff-pre-commit
3
+ rev: v0.9.6
4
+ hooks:
5
+ - id: ruff
6
+ args: [--fix]
7
+ - id: ruff-format
8
+
9
+ - repo: https://github.com/commitizen-tools/commitizen
10
+ rev: v3.13.0
11
+ hooks:
12
+ - id: commitizen
13
+ - id: commitizen-branch
14
+ stages: [pre-push]
@@ -0,0 +1,85 @@
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
+ FROM public.ecr.aws/sam/build-python3.10@sha256:e78695db10ca8cb129e59e30f7dc9789b0dbd0181dba195d68419c72bac51ac1 AS uv
16
+
17
+ # Install the project into `/app`
18
+ WORKDIR /app
19
+
20
+ # Enable bytecode compilation
21
+ ENV UV_COMPILE_BYTECODE=1
22
+
23
+ # Copy from the cache instead of linking since it's a mounted volume
24
+ ENV UV_LINK_MODE=copy
25
+
26
+ # Prefer the system python
27
+ ENV UV_PYTHON_PREFERENCE=only-system
28
+
29
+ # Run without updating the uv.lock file like running with `--frozen`
30
+ ENV UV_FROZEN=true
31
+
32
+ # Copy the required files first
33
+ COPY pyproject.toml uv.lock ./
34
+
35
+ # Install the project's dependencies using the lockfile and settings
36
+ RUN --mount=type=cache,target=/root/.cache/uv \
37
+ pip install uv && \
38
+ uv sync --frozen --no-install-project --no-dev --no-editable
39
+
40
+ # Then, add the rest of the project source code and install it
41
+ # Installing separately from its dependencies allows optimal layer caching
42
+ COPY . /app
43
+ RUN --mount=type=cache,target=/root/.cache/uv \
44
+ uv sync --frozen --no-dev --no-editable
45
+
46
+ # Make the directory just in case it doesn't exist
47
+ RUN mkdir -p /root/.local
48
+
49
+ FROM public.ecr.aws/sam/build-python3.10@sha256:e78695db10ca8cb129e59e30f7dc9789b0dbd0181dba195d68419c72bac51ac1
50
+
51
+ # Place executables in the environment at the front of the path and include other binaries
52
+ ENV PATH="/app/.venv/bin:$PATH:/usr/sbin"
53
+
54
+ # Set environment variables
55
+ ENV PYTHONUNBUFFERED=1 \
56
+ PYTHONDONTWRITEBYTECODE=1 \
57
+ FASTMCP_LOG_LEVEL=INFO
58
+
59
+ # Install lsof for the healthcheck
60
+ # Install other tools as needed for the MCP server
61
+ # Add non-root user and ability to change directory into /root
62
+ RUN yum update -y && \
63
+ yum install -y lsof && \
64
+ yum clean all -y && \
65
+ rm -rf /var/cache/yum && \
66
+ groupadd --force --system app && \
67
+ useradd app -g app -d /app && \
68
+ chmod o+x /root
69
+
70
+ # Get the project from the uv layer
71
+ COPY --from=uv --chown=app:app /root/.local /root/.local
72
+ COPY --from=uv --chown=app:app /app/.venv /app/.venv
73
+
74
+ # Get healthcheck script
75
+ COPY ./docker-healthcheck.sh /usr/local/bin/docker-healthcheck.sh
76
+ RUN chmod +x /usr/local/bin/docker-healthcheck.sh
77
+
78
+ # Run as non-root
79
+ USER app
80
+
81
+ # Health check
82
+ HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 CMD [ "docker-healthcheck.sh" ]
83
+
84
+ # Use entrypoint instead of CMD
85
+ ENTRYPOINT ["awslabs.cost-explorer-mcp-server"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: awslabs.cost-explorer-mcp-server
3
- Version: 0.0.1
3
+ Version: 0.0.4
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,7 @@ 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: mcp[cli]>=1.6.0
26
26
  Requires-Dist: pandas>=2.2.3
27
27
  Requires-Dist: pydantic>=2.10.6
28
28
  Description-Content-Type: text/markdown
@@ -127,6 +127,18 @@ The MCP server uses the AWS profile specified in the `AWS_PROFILE` environment v
127
127
  ```
128
128
 
129
129
  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.
130
+
131
+ ## Cost Considerations
132
+
133
+ **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.
134
+
135
+ - **Cost Explorer API Pricing:** The AWS Cost Explorer API lets you directly access the interactive, ad-hoc query engine that powers AWS Cost Explorer. Each request will incur a cost of $0.01.
136
+ - Each tool invocation that queries Cost Explorer (get_dimension_values, get_tag_values, get_cost_and_usage) will generate at least one billable API request
137
+ - Complex queries with multiple filters or large date ranges may result in multiple API calls
138
+
139
+ For current pricing information, please refer to the [AWS Cost Explorer Pricing page](https://aws.amazon.com/aws-cost-management/aws-cost-explorer/pricing/).
140
+
141
+
130
142
  ## Security Considerations
131
143
 
132
144
  ### Required IAM Permissions
@@ -135,22 +147,7 @@ The following IAM permissions are required for this MCP server:
135
147
  - ce:GetDimensionValues
136
148
  - ce:GetTags
137
149
 
138
- Example IAM policy:
139
- json
140
- {
141
- "Version": "2012-10-17",
142
- "Statement": [
143
- {
144
- "Effect": "Allow",
145
- "Action": [
146
- "ce:GetCostAndUsage",
147
- "ce:GetDimensionValues",
148
- "ce:GetTags"
149
- ],
150
- "Resource": "*"
151
- }
152
- ]
153
- }
150
+
154
151
 
155
152
  ## Available Tools
156
153
 
@@ -98,6 +98,18 @@ The MCP server uses the AWS profile specified in the `AWS_PROFILE` environment v
98
98
  ```
99
99
 
100
100
  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.
101
+
102
+ ## Cost Considerations
103
+
104
+ **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.
105
+
106
+ - **Cost Explorer API Pricing:** The AWS Cost Explorer API lets you directly access the interactive, ad-hoc query engine that powers AWS Cost Explorer. Each request will incur a cost of $0.01.
107
+ - Each tool invocation that queries Cost Explorer (get_dimension_values, get_tag_values, get_cost_and_usage) will generate at least one billable API request
108
+ - Complex queries with multiple filters or large date ranges may result in multiple API calls
109
+
110
+ For current pricing information, please refer to the [AWS Cost Explorer Pricing page](https://aws.amazon.com/aws-cost-management/aws-cost-explorer/pricing/).
111
+
112
+
101
113
  ## Security Considerations
102
114
 
103
115
  ### Required IAM Permissions
@@ -106,22 +118,7 @@ The following IAM permissions are required for this MCP server:
106
118
  - ce:GetDimensionValues
107
119
  - ce:GetTags
108
120
 
109
- Example IAM policy:
110
- json
111
- {
112
- "Version": "2012-10-17",
113
- "Statement": [
114
- {
115
- "Effect": "Allow",
116
- "Action": [
117
- "ce:GetCostAndUsage",
118
- "ce:GetDimensionValues",
119
- "ce:GetTags"
120
- ],
121
- "Resource": "*"
122
- }
123
- ]
124
- }
121
+
125
122
 
126
123
  ## Available Tools
127
124
 
@@ -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'
@@ -1,10 +1,25 @@
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
+
1
15
  """Helper functions for the Cost Explorer MCP server."""
2
16
 
17
+ import boto3
3
18
  import logging
4
19
  import re
5
- from typing import Dict, Any, List, Tuple
6
- import boto3
7
20
  from datetime import datetime
21
+ from typing import Any, Dict, Optional, Tuple
22
+
8
23
 
9
24
  # Set up logging
10
25
  logger = logging.getLogger(__name__)
@@ -12,96 +27,101 @@ logger = logging.getLogger(__name__)
12
27
  # Initialize AWS Cost Explorer client
13
28
  ce = boto3.client('ce')
14
29
 
30
+
15
31
  def validate_date_format(date_str: str) -> Tuple[bool, str]:
16
- """
17
- Validate that a date string is in YYYY-MM-DD format and is a valid date.
18
-
32
+ """Validate that a date string is in YYYY-MM-DD format and is a valid date.
33
+
19
34
  Args:
20
35
  date_str: The date string to validate
21
-
36
+
22
37
  Returns:
23
38
  Tuple of (is_valid, error_message)
24
39
  """
25
40
  # Check format with regex
26
41
  if not re.match(r'^\d{4}-\d{2}-\d{2}$', date_str):
27
42
  return False, f"Date '{date_str}' is not in YYYY-MM-DD format"
28
-
43
+
29
44
  # Check if it's a valid date
30
45
  try:
31
46
  datetime.strptime(date_str, '%Y-%m-%d')
32
- return True, ""
47
+ return True, ''
33
48
  except ValueError as e:
34
49
  return False, f"Invalid date '{date_str}': {str(e)}"
35
50
 
36
- def get_dimension_values(key: str, billing_period_start: str, billing_period_end: str) -> Dict[str, Any]:
51
+
52
+ def get_dimension_values(
53
+ key: str, billing_period_start: str, billing_period_end: str
54
+ ) -> Dict[str, Any]:
37
55
  """Get available values for a specific dimension."""
38
56
  # Validate date formats
39
57
  is_valid_start, error_start = validate_date_format(billing_period_start)
40
58
  if not is_valid_start:
41
59
  return {'error': error_start}
42
-
60
+
43
61
  is_valid_end, error_end = validate_date_format(billing_period_end)
44
62
  if not is_valid_end:
45
63
  return {'error': error_end}
46
-
64
+
47
65
  # Validate date range
48
66
  if billing_period_start > billing_period_end:
49
- return {'error': f"Start date '{billing_period_start}' cannot be after end date '{billing_period_end}'"}
50
-
67
+ return {
68
+ 'error': f"Start date '{billing_period_start}' cannot be after end date '{billing_period_end}'"
69
+ }
70
+
51
71
  try:
52
72
  response = ce.get_dimension_values(
53
- TimePeriod={
54
- 'Start': billing_period_start,
55
- 'End': billing_period_end
56
- },
57
- Dimension=key.upper()
73
+ TimePeriod={'Start': billing_period_start, 'End': billing_period_end},
74
+ Dimension=key.upper(),
58
75
  )
59
76
  dimension_values = response['DimensionValues']
60
77
  values = [value['Value'] for value in dimension_values]
61
78
  return {'dimension': key.upper(), 'values': values}
62
79
  except Exception as e:
63
- logger.error(f"Error getting dimension values: {e}")
80
+ logger.error(f'Error getting dimension values: {e}')
64
81
  return {'error': str(e)}
65
82
 
66
83
 
67
- def get_tag_values(tag_key: str, billing_period_start: str, billing_period_end: str) -> Dict[str, Any]:
84
+ def get_tag_values(
85
+ tag_key: str, billing_period_start: str, billing_period_end: str
86
+ ) -> Dict[str, Any]:
68
87
  """Get available values for a specific tag key."""
69
88
  # Validate date formats
70
89
  is_valid_start, error_start = validate_date_format(billing_period_start)
71
90
  if not is_valid_start:
72
91
  return {'error': error_start}
73
-
92
+
74
93
  is_valid_end, error_end = validate_date_format(billing_period_end)
75
94
  if not is_valid_end:
76
95
  return {'error': error_end}
77
-
96
+
78
97
  # Validate date range
79
98
  if billing_period_start > billing_period_end:
80
- return {'error': f"Start date '{billing_period_start}' cannot be after end date '{billing_period_end}'"}
81
-
99
+ return {
100
+ 'error': f"Start date '{billing_period_start}' cannot be after end date '{billing_period_end}'"
101
+ }
102
+
82
103
  try:
83
104
  response = ce.get_tags(
84
- TimePeriod={'Start': billing_period_start,
85
- 'End': billing_period_end},
86
- TagKey=tag_key
105
+ TimePeriod={'Start': billing_period_start, 'End': billing_period_end},
106
+ TagKey=tag_key,
87
107
  )
88
108
  tag_values = response['Tags']
89
109
  return {'tag_key': tag_key, 'values': tag_values}
90
110
  except Exception as e:
91
- logger.error(f"Error getting tag values: {e}")
111
+ logger.error(f'Error getting tag values: {e}')
92
112
  return {'error': str(e)}
93
113
 
94
114
 
115
+ def validate_expression(
116
+ expression: Dict[str, Any], billing_period_start: str, billing_period_end: str
117
+ ) -> Dict[str, Any]:
118
+ """Recursively validate the filter expression.
95
119
 
96
- def validate_expression(expression: Dict[str, Any], billing_period_start: str, billing_period_end: str) -> Dict[str, Any]:
97
- """
98
- Recursively validate the filter expression.
99
-
100
120
  Args:
101
121
  expression: The filter expression to validate
102
122
  billing_period_start: Start date of the billing period
103
123
  billing_period_end: End date of the billing period
104
-
124
+
105
125
  Returns:
106
126
  Empty dictionary if valid, or an error dictionary
107
127
  """
@@ -109,31 +129,42 @@ def validate_expression(expression: Dict[str, Any], billing_period_start: str, b
109
129
  is_valid_start, error_start = validate_date_format(billing_period_start)
110
130
  if not is_valid_start:
111
131
  return {'error': error_start}
112
-
132
+
113
133
  is_valid_end, error_end = validate_date_format(billing_period_end)
114
134
  if not is_valid_end:
115
135
  return {'error': error_end}
116
-
136
+
117
137
  # Validate date range
118
138
  if billing_period_start > billing_period_end:
119
- return {'error': f"Start date '{billing_period_start}' cannot be after end date '{billing_period_end}'"}
120
-
139
+ return {
140
+ 'error': f"Start date '{billing_period_start}' cannot be after end date '{billing_period_end}'"
141
+ }
142
+
121
143
  try:
122
144
  if 'Dimensions' in expression:
123
145
  dimension = expression['Dimensions']
124
- if 'Key' not in dimension or 'Values' not in dimension or 'MatchOptions' not in dimension:
125
- return {'error': 'Dimensions filter must include "Key", "Values", and "MatchOptions".'}
146
+ if (
147
+ 'Key' not in dimension
148
+ or 'Values' not in dimension
149
+ or 'MatchOptions' not in dimension
150
+ ):
151
+ return {
152
+ 'error': 'Dimensions filter must include "Key", "Values", and "MatchOptions".'
153
+ }
126
154
 
127
155
  dimension_key = dimension['Key']
128
156
  dimension_values = dimension['Values']
129
157
  valid_values_response = get_dimension_values(
130
- dimension_key, billing_period_start, billing_period_end)
158
+ dimension_key, billing_period_start, billing_period_end
159
+ )
131
160
  if 'error' in valid_values_response:
132
161
  return {'error': valid_values_response['error']}
133
162
  valid_values = valid_values_response['values']
134
163
  for value in dimension_values:
135
164
  if value not in valid_values:
136
- return {'error': f"Invalid value '{value}' for dimension '{dimension_key}'. Valid values are: {valid_values}"}
165
+ return {
166
+ 'error': f"Invalid value '{value}' for dimension '{dimension_key}'. Valid values are: {valid_values}"
167
+ }
137
168
 
138
169
  if 'Tags' in expression:
139
170
  tag = expression['Tags']
@@ -143,34 +174,48 @@ def validate_expression(expression: Dict[str, Any], billing_period_start: str, b
143
174
  tag_key = tag['Key']
144
175
  tag_values = tag['Values']
145
176
  valid_tag_values_response = get_tag_values(
146
- tag_key, billing_period_start, billing_period_end)
177
+ tag_key, billing_period_start, billing_period_end
178
+ )
147
179
  if 'error' in valid_tag_values_response:
148
180
  return {'error': valid_tag_values_response['error']}
149
181
  valid_tag_values = valid_tag_values_response['values']
150
182
  for value in tag_values:
151
183
  if value not in valid_tag_values:
152
- return {'error': f"Invalid value '{value}' for tag '{tag_key}'. Valid values are: {valid_tag_values}"}
184
+ return {
185
+ 'error': f"Invalid value '{value}' for tag '{tag_key}'. Valid values are: {valid_tag_values}"
186
+ }
153
187
 
154
188
  if 'CostCategories' in expression:
155
189
  cost_category = expression['CostCategories']
156
- if 'Key' not in cost_category or 'Values' not in cost_category or 'MatchOptions' not in cost_category:
157
- return {'error': 'CostCategories filter must include "Key", "Values", and "MatchOptions".'}
190
+ if (
191
+ 'Key' not in cost_category
192
+ or 'Values' not in cost_category
193
+ or 'MatchOptions' not in cost_category
194
+ ):
195
+ return {
196
+ 'error': 'CostCategories filter must include "Key", "Values", and "MatchOptions".'
197
+ }
158
198
 
159
199
  logical_operators = ['And', 'Or', 'Not']
160
200
  logical_count = sum(1 for op in logical_operators if op in expression)
161
201
 
162
202
  if logical_count > 1:
163
- return {'error': 'Only one logical operator (And, Or, Not) is allowed per expression in filter parameter.'}
203
+ return {
204
+ 'error': 'Only one logical operator (And, Or, Not) is allowed per expression in filter parameter.'
205
+ }
164
206
 
165
207
  if logical_count == 0 and len(expression) > 1:
166
- return {'error': 'Filter parameter with multiple expressions require a logical operator (And, Or, Not).'}
208
+ return {
209
+ 'error': 'Filter parameter with multiple expressions require a logical operator (And, Or, Not).'
210
+ }
167
211
 
168
212
  if 'And' in expression:
169
213
  if not isinstance(expression['And'], list):
170
214
  return {'error': 'And expression must be a list of expressions.'}
171
215
  for sub_expression in expression['And']:
172
216
  result = validate_expression(
173
- sub_expression, billing_period_start, billing_period_end)
217
+ sub_expression, billing_period_start, billing_period_end
218
+ )
174
219
  if 'error' in result:
175
220
  return result
176
221
 
@@ -179,7 +224,8 @@ def validate_expression(expression: Dict[str, Any], billing_period_start: str, b
179
224
  return {'error': 'Or expression must be a list of expressions.'}
180
225
  for sub_expression in expression['Or']:
181
226
  result = validate_expression(
182
- sub_expression, billing_period_start, billing_period_end)
227
+ sub_expression, billing_period_start, billing_period_end
228
+ )
183
229
  if 'error' in result:
184
230
  return result
185
231
 
@@ -187,35 +233,46 @@ def validate_expression(expression: Dict[str, Any], billing_period_start: str, b
187
233
  if not isinstance(expression['Not'], dict):
188
234
  return {'error': 'Not expression must be a single expression.'}
189
235
  result = validate_expression(
190
- expression['Not'], billing_period_start, billing_period_end)
236
+ expression['Not'], billing_period_start, billing_period_end
237
+ )
191
238
  if 'error' in result:
192
239
  return result
193
240
 
194
- if not any(k in expression for k in ['Dimensions', 'Tags', 'CostCategories', 'And', 'Or', 'Not']):
195
- return {'error': 'Filter Expression must include at least one of the following keys: "Dimensions", "Tags", "CostCategories", "And", "Or", "Not".'}
241
+ if not any(
242
+ k in expression for k in ['Dimensions', 'Tags', 'CostCategories', 'And', 'Or', 'Not']
243
+ ):
244
+ return {
245
+ 'error': 'Filter Expression must include at least one of the following keys: "Dimensions", "Tags", "CostCategories", "And", "Or", "Not".'
246
+ }
196
247
 
197
248
  return {}
198
249
  except Exception as e:
199
250
  return {'error': f'Error validating expression: {str(e)}'}
200
251
 
201
252
 
202
- def validate_group_by(group_by: Dict[str, Any]) -> Dict[str, Any]:
203
- """
204
- Validate the group_by parameter.
205
-
253
+ def validate_group_by(group_by: Optional[Dict[str, Any]]) -> Dict[str, Any]:
254
+ """Validate the group_by parameter.
255
+
206
256
  Args:
207
257
  group_by: The group_by dictionary to validate
208
-
258
+
209
259
  Returns:
210
260
  Empty dictionary if valid, or an error dictionary
211
261
  """
212
262
  try:
213
- if not isinstance(group_by, dict) or 'Type' not in group_by or 'Key' not in group_by:
263
+ if (
264
+ group_by is None
265
+ or not isinstance(group_by, dict)
266
+ or 'Type' not in group_by
267
+ or 'Key' not in group_by
268
+ ):
214
269
  return {'error': 'group_by must be a dictionary with "Type" and "Key" keys.'}
215
-
270
+
216
271
  if group_by['Type'].upper() not in ['DIMENSION', 'TAG', 'COST_CATEGORY']:
217
- return {'error': 'Invalid group Type. Valid types are DIMENSION, TAG, and COST_CATEGORY.'}
218
-
272
+ return {
273
+ 'error': 'Invalid group Type. Valid types are DIMENSION, TAG, and COST_CATEGORY.'
274
+ }
275
+
219
276
  return {}
220
277
  except Exception as e:
221
278
  return {'error': f'Error validating group_by: {str(e)}'}