Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,7 @@ async def cost_comparison(
metric_for_comparison,
today,
)
validate_comparison_filter(parse_json(filter, 'filter'))
validate_comparison_filter(parse_filter(filter))

# Initialize Cost Explorer client using shared utility
ce_client = create_aws_client('ce', region_name='us-east-1')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,7 @@ async def get_tags(
end_date: Optional[str] = None,
search_string: Optional[str] = None,
tag_key: Optional[str] = None,
filter_expr: Optional[Union[str, Dict[str, Any]]] = None,
next_token: Optional[str] = None,
max_items: Optional[int] = None,
billing_view_arn: Optional[str] = None,
Expand All @@ -673,6 +674,7 @@ async def get_tags(
end_date: End date in YYYY-MM-DD format (exclusive)
search_string: Optional string to filter results
tag_key: Optional specific tag key to get values for
filter_expr: Optional filters, as an object or JSON string
next_token: Continuation token from a previous response
max_items: Most items to return; None fetches every page
billing_view_arn: Optional ARN of a billing view to scope the query.
Expand All @@ -699,6 +701,10 @@ async def get_tags(
if tag_key:
request_params['TagKey'] = str(tag_key)

filters = parse_filter(filter_expr)
if filters is not None:
request_params['Filter'] = filters

if billing_view_arn:
request_params['BillingViewArn'] = billing_view_arn

Expand Down Expand Up @@ -731,6 +737,7 @@ async def get_cost_categories(
end_date: Optional[str] = None,
search_string: Optional[str] = None,
cost_category_name: Optional[str] = None,
filter_expr: Optional[Union[str, Dict[str, Any]]] = None,
next_token: Optional[str] = None,
max_items: Optional[int] = None,
billing_view_arn: Optional[str] = None,
Expand All @@ -744,6 +751,7 @@ async def get_cost_categories(
end_date: End date in YYYY-MM-DD format (exclusive)
search_string: Optional string to filter results
cost_category_name: Optional specific cost category to get values for
filter_expr: Optional filters, as an object or JSON string
next_token: Continuation token from a previous response
max_items: Most items to return; None fetches every page
billing_view_arn: Optional ARN of a billing view to scope the query.
Expand Down Expand Up @@ -773,6 +781,10 @@ async def get_cost_categories(
if cost_category_name:
request_params['CostCategoryName'] = str(cost_category_name)

filters = parse_filter(filter_expr)
if filters is not None:
request_params['Filter'] = filters

if billing_view_arn:
request_params['BillingViewArn'] = billing_view_arn

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@
"""

import json
from ..utilities.aws_service_base import create_aws_client, format_response, handle_aws_error
from ..utilities.aws_service_base import (
create_aws_client,
format_response,
handle_aws_error,
parse_json,
)
from ..utilities.constants import PAGINATION_LIMIT_TEXT
from .cost_explorer_operations import (
get_cost_and_usage,
Expand Down Expand Up @@ -183,7 +188,8 @@ def _with_example_dates(description: str) -> str:
where the tag or cost category key is not present, so there is no value to compare against:
omit `Values` entirely (an empty `Values` list is rejected too).

Tags leaves in getCostAndUsage, getCostAndUsageWithResources and getDimensionValues filters:
Tags leaves in getCostAndUsage, getCostAndUsageWithResources, getDimensionValues,
getTagsOrValues and getCostCategories filters:
- `Key` is the tag key as getTagsOrValues lists it, with no `user:` prefix even where billing
exports show one: `Environment`, not `user:Environment` (which matches nothing, with no
error). The forecasts accept either form. Tags that AWS applies itself keep
Expand All @@ -196,10 +202,11 @@ def _with_example_dates(description: str) -> str:
- getCostAndUsage only: `{"Tags": {"Key": "", "Values": []}}` (empty key, empty list) matches
costs that carry no activated cost allocation tag (user-defined or AWS-generated), as does
`{"Tags": {"MatchOptions": ["ABSENT"]}}` with no Key. An empty key with any value, or with
Values omitted, is rejected, and getDimensionValues and getCostAndUsageWithResources reject
an empty key.
- A wrong key is not an error. With `Values` it matches nothing ($0); with `ABSENT` it matches
every cost, since no cost carries that key. Copy the key from getTagsOrValues.
Values omitted, is rejected, and getCostAndUsageWithResources, getDimensionValues,
getTagsOrValues and getCostCategories reject an empty key.
- A wrong key is not an error. With `Values` it matches nothing ($0, or an empty list); with
`ABSENT` it matches every cost, since no cost carries that key. Copy the key from
getTagsOrValues.

Correct filter examples:
- `{"Dimensions": {"Key": "SERVICE", "Values": ["Amazon Relational Database Service"]}}`
Expand All @@ -211,7 +218,8 @@ def _with_example_dates(description: str) -> str:
- Every operation: at most 6,000 values in total, counted across all leaves of the filter.
- getCostAndUsage, getCostAndUsageWithResources: `And`, `Or` and `Not` nest up to 4 levels
deep; a 5th is rejected.
- getDimensionValues (default COST_AND_USAGE context): `And` only; `Or` and `Not` are rejected.
- getDimensionValues (default COST_AND_USAGE context), getTagsOrValues and getCostCategories:
`And` of leaves only (an `And` inside it is rejected); `Or` and `Not` are rejected.
- getSavingsPlansUtilization: `And` and `Not` only; `Or` is rejected.

## OPERATIONS
Expand Down Expand Up @@ -260,19 +268,19 @@ def _with_example_dates(description: str) -> str:

6. getTagsOrValues - Available cost allocation tags or values
Required: operation="getTagsOrValues"
Optional: start_date, end_date, tag_key, search_string, max_items, next_token, billing_view_arn
Optional: start_date, end_date, tag_key, search_string, filter, max_items, next_token, billing_view_arn
Defaults: If start_date is omitted, the tool uses 30 days before today; if end_date is omitted, it uses today (exclusive, so today is not included).
Example 1: {"operation": "getTagsOrValues"}
Example 2: {"operation": "getTagsOrValues", "tag_key": "Environment"}
Returns: List of available cost allocation tags (see PAGINATION). If tag values for a particular key are needed, pass the tag key as a parameter. The tag key list holds at most 5,000 keys in total.
Returns: List of available cost allocation tags (see PAGINATION). If tag values for a particular key are needed, pass the tag key as a parameter. The tag key list holds at most 5,000 keys in total. A filter keeps only the keys (or values) found on costs that match it, e.g. the tags used in one Region.

7. getCostCategories - Available cost categories or values inside a category
Required: operation="getCostCategories"
Optional: start_date, end_date, cost_category_name, search_string, max_items, next_token, billing_view_arn
Optional: start_date, end_date, cost_category_name, search_string, filter, max_items, next_token, billing_view_arn
Defaults: If start_date is omitted, the tool uses 30 days before today; if end_date is omitted, it uses today (exclusive, so today is not included).
Example 1 (list category names): {"operation": "getCostCategories", "start_date": "__SIX_MONTHS_AGO_START__", "end_date": "__MONTH_START__"}
Example 2 (list values inside a category): {"operation": "getCostCategories", "cost_category_name": "CostCenters", "start_date": "__SIX_MONTHS_AGO_START__", "end_date": "__MONTH_START__"}
Notes: Without cost_category_name the response contains CostCategoryNames. With cost_category_name set the response contains CostCategoryValues for that category. The category name list holds at most 5,000 names in total. See PAGINATION.
Notes: Without cost_category_name the response contains CostCategoryNames. With cost_category_name set the response contains CostCategoryValues for that category. The category name list holds at most 5,000 names in total. A filter keeps only the names (or values) found on costs that match it. See PAGINATION.

8. getSavingsPlansUtilization - Savings Plans utilization data
Required: operation="getSavingsPlansUtilization"
Expand Down Expand Up @@ -423,10 +431,10 @@ def _tag_leaf_hints(key: str, values: List[Any], absent: bool) -> List[str]:
def _tag_filter_hint(filter: Optional[Union[str, Dict[str, Any]]]) -> Optional[str]:
"""Return a hint for a Tags filter that Cost Explorer accepts but that cannot match.

getCostAndUsage, getCostAndUsageWithResources and getDimensionValues add the `user:`
prefix to user-defined tag keys themselves, so a `user:` key matches no tagged cost, and a TAG group_by label
(`key$value`) is not a key or value. With no error, Values then match nothing and
ABSENT matches every cost.
getCostAndUsage, getCostAndUsageWithResources, getDimensionValues, getTagsOrValues and
getCostCategories add the `user:` prefix to user-defined tag keys themselves, so a
`user:` key matches no tagged cost, and a TAG group_by label (`key$value`) is not a key
or value. With no error, Values then match nothing and ABSENT matches every cost.
"""
try:
if isinstance(filter, str):
Expand All @@ -447,6 +455,19 @@ def _tag_filter_hint(filter: Optional[Union[str, Dict[str, Any]]]) -> Optional[s
return ' '.join(hints) or None


def _asks_for_grouping(group_by: Any) -> bool:
"""Return whether group_by asks for grouping.

An empty or falsy JSON value ('[]', 'null', '{}') does not, as getCostAndUsage reads
it. A group_by that is not JSON still does, so it gets the not-supported error rather
than a JSON one.
"""
try:
return bool(parse_json(group_by, 'group_by'))
except ValueError:
return True


def _with_hint(result: Dict[str, Any], hint: Optional[str]) -> Dict[str, Any]:
"""Add the hint to a successful response.

Expand Down Expand Up @@ -543,7 +564,7 @@ async def cost_explorer(
tag_hint = _tag_filter_hint(filter)
await ctx.info(f'Routing to operation: {operation}')

if group_by and operation in OPERATIONS_WITHOUT_GROUP_BY:
if operation in OPERATIONS_WITHOUT_GROUP_BY and _asks_for_grouping(group_by):
return format_response(
'error',
{
Expand Down Expand Up @@ -722,32 +743,40 @@ async def cost_explorer(
)

elif operation == 'getTagsOrValues':
return await get_tags(
ctx,
ce_client,
start_date,
end_date,
search_string,
tag_key,
next_token,
max_items,
billing_view_arn,
return _with_hint(
await get_tags(
ctx,
ce_client,
start_date,
end_date,
search_string,
tag_key,
filter,
next_token,
max_items,
billing_view_arn,
),
tag_hint,
)

elif operation == 'getCostCategories':
# When `cost_category_name` is provided, the same AWS API
# (GetCostCategories) returns the values inside that category
# instead of the list of category names.
return await get_cost_categories(
ctx,
ce_client,
start_date,
end_date,
search_string,
cost_category_name,
next_token,
max_items,
billing_view_arn,
return _with_hint(
await get_cost_categories(
ctx,
ce_client,
start_date,
end_date,
search_string,
cost_category_name,
filter,
next_token,
max_items,
billing_view_arn,
),
tag_hint,
)

elif operation == 'getSavingsPlansUtilization':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -233,8 +233,9 @@ def parse_filter(
) -> Any:
"""Parse a filter expression, rejecting an empty one.

An empty filter (``''``, ``{}``, ``[]``, ``null``) is an error rather than "no filter":
dropping it would run the query unfiltered, and the result would read as a filtered one.
An empty filter (``''``, ``""`` or another blank JSON string, ``{}``, ``[]``, ``null``)
is an error rather than "no filter": dropping it would run the query unfiltered, and the
result would read as a filtered one.

Args:
filter_expr: The filter, as an object or a JSON string. None means no filter.
Expand All @@ -252,7 +253,7 @@ def parse_filter(
if isinstance(filter_expr, str):
filter_expr = filter_expr.strip()
parsed = parse_json(filter_expr, parameter_name)
if parsed is None or parsed == {} or parsed == []:
if parsed in (None, {}, []) or (isinstance(parsed, str) and not parsed.strip()):
raise ValueError(
f'{parameter_name} is empty. Omit {parameter_name} to query without one, or pass '
'a filter expression.'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1528,6 +1528,19 @@ async def test_invalid_filter_json_is_a_validation_error(
)
_assert_local_validation_error(result, dispatch_ce_client, 'Invalid JSON format')

@pytest.mark.parametrize('flt', [' ', '{}', '""'], ids=repr)
async def test_empty_filter_is_called_empty(self, mock_context, dispatch_ce_client, flt):
"""A blank or empty filter gets the "filter is empty" error, not a JSON error."""
result = await _call(
mock_context,
dispatch_ce_client,
'getCostAndUsageComparisons',
month(2),
month(1),
filter=flt,
)
_assert_local_validation_error(result, dispatch_ce_client, 'filter is empty')


class TestValidateComparisonRequest:
"""Boundaries of the rules, at a fixed date."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
from unittest.mock import AsyncMock, MagicMock, patch


REGION_FILTER = {'Dimensions': {'Key': 'REGION', 'Values': ['us-east-1']}}


@pytest.fixture
def mock_context():
"""Create a mock MCP context."""
Expand Down Expand Up @@ -1559,6 +1562,34 @@ async def test_with_search_string(self, mock_context, mock_ce_client):
assert 'SearchString' in call_kwargs
assert call_kwargs['SearchString'] == 'Env'

@pytest.mark.parametrize('filter_expr', [REGION_FILTER, json.dumps(REGION_FILTER)])
@pytest.mark.parametrize('tag_key', [None, 'Environment'], ids=['keys', 'values'])
async def test_filter_is_sent(self, mock_context, mock_ce_client, filter_expr, tag_key):
"""The filter reaches GetTags as Filter, for tag keys and for one key's values.

Dropping it would return the unfiltered list as if it were filtered.
"""
result = await get_tags(
mock_context, mock_ce_client, tag_key=tag_key, filter_expr=filter_expr
)

assert result['status'] == 'success'
assert mock_ce_client.get_tags.call_args[1]['Filter'] == REGION_FILTER

async def test_no_filter_sends_none(self, mock_context, mock_ce_client):
"""Without a filter, no Filter key is sent."""
await get_tags(mock_context, mock_ce_client)

assert 'Filter' not in mock_ce_client.get_tags.call_args[1]

async def test_empty_filter_is_rejected_before_aws(self, mock_context, mock_ce_client):
"""An empty filter is an error, not an unfiltered call."""
result = await get_tags(mock_context, mock_ce_client, filter_expr='{}')

assert result['status'] == 'error'
assert 'filter is empty' in result['message']
mock_ce_client.get_tags.assert_not_called()

async def test_error_handling(self, mock_context, mock_ce_client):
"""Test error handling in get_tags."""
# Setup error
Expand Down Expand Up @@ -1672,6 +1703,34 @@ async def test_with_search_string(self, mock_context, mock_ce_client):
assert 'SearchString' in call_kwargs
assert call_kwargs['SearchString'] == 'Dep'

@pytest.mark.parametrize('filter_expr', [REGION_FILTER, json.dumps(REGION_FILTER)])
@pytest.mark.parametrize('name', [None, 'Department'], ids=['names', 'values'])
async def test_filter_is_sent(self, mock_context, mock_ce_client, filter_expr, name):
"""The filter reaches GetCostCategories as Filter, for names and for one category's values.

Dropping it would return the unfiltered list as if it were filtered.
"""
result = await get_cost_categories(
mock_context, mock_ce_client, cost_category_name=name, filter_expr=filter_expr
)

assert result['status'] == 'success'
assert mock_ce_client.get_cost_categories.call_args[1]['Filter'] == REGION_FILTER

async def test_no_filter_sends_none(self, mock_context, mock_ce_client):
"""Without a filter, no Filter key is sent."""
await get_cost_categories(mock_context, mock_ce_client)

assert 'Filter' not in mock_ce_client.get_cost_categories.call_args[1]

async def test_empty_filter_is_rejected_before_aws(self, mock_context, mock_ce_client):
"""An empty filter is an error, not an unfiltered call."""
result = await get_cost_categories(mock_context, mock_ce_client, filter_expr='{}')

assert result['status'] == 'error'
assert 'filter is empty' in result['message']
mock_ce_client.get_cost_categories.assert_not_called()

async def test_error_handling(self, mock_context, mock_ce_client):
"""Test error handling in get_cost_categories."""
# Setup error
Expand Down
Loading
Loading