Skip to content

Commit 205d9f9

Browse files
fix(billing-cost-management-mcp-server): read rec-details templates as UTF-8 (#4815)
* fix(billing-cost-management-mcp-server): read rec-details templates as UTF-8 get_template_for_recommendation opened the bundled recommendation templates with the locale's default encoding. All nine templates contain non-ASCII characters, so on a host whose default is not UTF-8 (for example Windows with cp1252) eight of them came back with mojibake, and the ecs_service template could not be decoded at all. The surrounding except/pass turned that into None, so rec-details silently dropped the template for EcsService recommendations. Open the template with encoding='utf-8'. Add tests that load every bundled template under a simulated cp1252 default, so they also fail on UTF-8 platforms without the fix, and a CHANGELOG entry. * test(billing-cost-management-mcp-server): correct legacy_default_encoding docstring The docstring said the fixture also changes Path.read_text(). It does only on Python 3.11+: on 3.10 pathlib binds io.open at import time, so patching io.open does not reach it. The tests exercise the builtin open() that get_template_for_recommendation uses and fail without the fix on 3.10, 3.11 and 3.12; only the description was wrong. --------- Co-authored-by: finklen-amazon <finklen@amazon.com>
1 parent 549416f commit 205d9f9

3 files changed

Lines changed: 80 additions & 1 deletion

File tree

‎src/billing-cost-management-mcp-server/CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5959
### Fixed
6060
- Cost Explorer `getCostAndUsage` and `getCostAndUsageWithResources` now validate `metrics` (a list of metric names or a JSON array string) and reject anything else with a `validation_error` before calling Cost Explorer. The parsed list, with the `["UnblendedCost"]` default applied, is now what is recorded with a result stored to session SQL; previously the raw argument was recorded, so an omitted `metrics` was stored as `null` and a JSON string as a string.
6161
- **Breaking:** `aws-pricing` `get_pricing_from_api` results returned inline now keep each product's `terms` as the Price List API returned it. Each term was reduced to `effectiveDate` and, per price dimension, `description`, `unit` and `pricePerUnit`, which dropped `termAttributes` (`LeaseContractLength`, `PurchaseOption`, `OfferingClass`), `beginRange`/`endRange`, `rateCode`, `offerTermCode`, `sku` and `appliesTo`, so Reserved prices for different lease lengths and purchase options could not be told apart and tiered prices lost their tier bounds. `terms` is now keyed by offer term and price dimension code, as in the API, and inline results are larger. Results stored in session SQL already kept the full terms and are unchanged.
62+
- The `rec-details` tool reads its recommendation templates as UTF-8 instead of the host's default encoding. On a host whose default is not UTF-8 (for example Windows with cp1252) most templates were returned with garbled characters, and the `ecs_service` template was dropped without any error.
6263
- Cost Explorer (`getCostAndUsage`, `getCostAndUsageWithResources`, `getDimensionValues`, `getTagsOrValues`, `getCostCategories`), Cost Optimization Hub list operations and Compute Optimizer `get_*_recommendations` returned only the first page unless the caller passed `max_pages` or `next_token`, so a truncated result looked complete. They now fetch every page unless `max_items` is passed.
6364
- `cost-comparison` requested 10 comparisons per page although the API allows 2,000 (10 for drivers), multiplying the number of paid Cost Explorer requests.
6465
- `list-cost-allocation-tags` and `list-cost-category-definitions` paged to the end with no limit whenever a `next_token` was passed without `max_pages`.

‎src/billing-cost-management-mcp-server/awslabs/billing_cost_management_mcp_server/tools/recommendation_details_tools.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -442,7 +442,7 @@ def get_template_for_recommendation(
442442
)
443443

444444
if os.path.exists(template_path):
445-
with open(template_path, 'r') as f:
445+
with open(template_path, 'r', encoding='utf-8') as f:
446446
return f.read()
447447
except Exception:
448448
# Template loading is optional, so we'll silently continue if not found

‎src/billing-cost-management-mcp-server/tests/tools/test_recommendation_details_tools.py‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@
2424
"""
2525

2626
import botocore.session
27+
import builtins
28+
import io
2729
import pytest
30+
from awslabs.billing_cost_management_mcp_server.tools import recommendation_details_tools
2831
from awslabs.billing_cost_management_mcp_server.tools.recommendation_details_tools import (
2932
format_base_recommendation,
3033
format_timestamp,
@@ -42,6 +45,7 @@
4245
)
4346
from datetime import datetime, timezone
4447
from fastmcp import Context
48+
from pathlib import Path
4549
from unittest.mock import AsyncMock, MagicMock, mock_open, patch
4650

4751

@@ -1203,6 +1207,80 @@ def test_get_template_for_recommendation_unsupported_type(self):
12031207
assert result is None
12041208

12051209

1210+
TEMPLATES_DIR = (
1211+
Path(recommendation_details_tools.__file__).parent.parent
1212+
/ 'templates'
1213+
/ 'recommendation_templates'
1214+
)
1215+
1216+
# Recommendation that selects each bundled template
1217+
TEMPLATE_CASES = {
1218+
'savings_plans': {'actionType': 'PurchaseSavingsPlans'},
1219+
'reserved_instances': {'actionType': 'PurchaseReservedInstances'},
1220+
'idle': {'actionType': 'Stop'},
1221+
'ec2_instance': {'currentResourceType': 'Ec2Instance'},
1222+
'ec2_asg': {'currentResourceType': 'Ec2AutoScalingGroup'},
1223+
'ebs_volume': {'currentResourceType': 'EbsVolume'},
1224+
'ecs_service': {'currentResourceType': 'EcsService'},
1225+
'lambda_function': {'currentResourceType': 'LambdaFunction'},
1226+
'rds_database': {'currentResourceType': 'RdsDbInstance'},
1227+
}
1228+
1229+
1230+
@pytest.fixture
1231+
def legacy_default_encoding(monkeypatch):
1232+
"""Make text-mode file I/O behave like a Windows host whose default encoding is cp1252.
1233+
1234+
``open()`` called without an ``encoding`` uses cp1252 (and, on Python 3.11+,
1235+
``Path.read_text()`` too; on 3.10 ``pathlib`` binds ``io.open`` at import time, so only
1236+
the builtin ``open()`` is affected, which is what the code under test uses). This
1237+
reproduces such a host on any platform, including when the test run itself has
1238+
UTF-8 mode enabled.
1239+
"""
1240+
real_open = io.open
1241+
1242+
def legacy_open(
1243+
file,
1244+
mode='r',
1245+
buffering=-1,
1246+
encoding=None,
1247+
errors=None,
1248+
newline=None,
1249+
closefd=True,
1250+
opener=None,
1251+
):
1252+
if 'b' not in mode and encoding in (None, 'locale'):
1253+
encoding = 'cp1252'
1254+
return real_open(file, mode, buffering, encoding, errors, newline, closefd, opener)
1255+
1256+
def legacy_text_encoding(encoding, stacklevel=2):
1257+
return 'locale' if encoding is None else encoding
1258+
1259+
monkeypatch.setattr(builtins, 'open', legacy_open)
1260+
monkeypatch.setattr(io, 'open', legacy_open)
1261+
monkeypatch.setattr(io, 'text_encoding', legacy_text_encoding)
1262+
1263+
1264+
class TestGetTemplateForRecommendationEncoding:
1265+
"""The bundled templates are UTF-8 and must read the same whatever the host's default is."""
1266+
1267+
def test_every_bundled_template_has_a_case(self):
1268+
"""Guard: a new template file must be added to TEMPLATE_CASES."""
1269+
assert {path.stem for path in TEMPLATES_DIR.glob('*.template')} == set(TEMPLATE_CASES)
1270+
1271+
@pytest.mark.parametrize('name', sorted(TEMPLATE_CASES))
1272+
def test_template_is_read_as_utf8(self, name, legacy_default_encoding):
1273+
"""Each template is returned exactly as stored, not as None or mojibake."""
1274+
expected = (TEMPLATES_DIR / f'{name}.template').read_text(encoding='utf-8')
1275+
1276+
result = get_template_for_recommendation(TEMPLATE_CASES[name], {})
1277+
1278+
assert result == expected
1279+
# Every bundled template has non-ASCII characters; make sure the comparison above
1280+
# is exercising a decoding difference.
1281+
assert not expected.isascii()
1282+
1283+
12061284
@pytest.mark.asyncio
12071285
class TestProcessRecommendation:
12081286
"""Tests for process_recommendation function."""

0 commit comments

Comments
 (0)