Skip to content

Commit e8b63be

Browse files
fix: download_file/download_files crash on binary content with UnicodeDecodeError (#257)
download_file() and download_files() crashed with UnicodeDecodeError when the API returned binary content (PNG, JPEG, PDF) as base64-encoded blobs, because .decode("utf-8") was called unconditionally on decoded blob bytes. Fix uses try/except to decode UTF-8 where possible (preserving str return for backwards compatibility) and falls back to raw bytes for true binary content. Return type annotations updated to Union[str, bytes]. Add tests covering both the binary-returns-bytes and utf8-blob-returns-str paths for download_file and download_files. Co-authored-by: Kevin Orellana <keorel@amazon.com>
1 parent 9bd2623 commit e8b63be

2 files changed

Lines changed: 158 additions & 9 deletions

File tree

src/bedrock_agentcore/tools/code_interpreter_client.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -600,14 +600,15 @@ def install_packages(
600600
def download_file(
601601
self,
602602
path: str,
603-
) -> str:
603+
) -> Union[str, bytes]:
604604
"""Download/read a file from the code interpreter environment.
605605
606606
Args:
607607
path: Path to the file to read.
608608
609609
Returns:
610-
File content as string.
610+
File content as string, or bytes if the file contains binary content
611+
(images, PDFs, etc.).
611612
612613
Raises:
613614
FileNotFoundError: If the file doesn't exist.
@@ -631,21 +632,26 @@ def download_file(
631632
if "text" in resource:
632633
return resource["text"]
633634
elif "blob" in resource:
634-
return base64.b64decode(resource["blob"]).decode("utf-8")
635+
raw = base64.b64decode(resource["blob"])
636+
try:
637+
return raw.decode("utf-8")
638+
except (UnicodeDecodeError, ValueError):
639+
return raw
635640

636641
raise FileNotFoundError(f"Could not read file: {path}")
637642

638643
def download_files(
639644
self,
640645
paths: List[str],
641-
) -> Dict[str, str]:
646+
) -> Dict[str, Union[str, bytes]]:
642647
"""Download/read multiple files from the code interpreter environment.
643648
644649
Args:
645650
paths: List of file paths to read.
646651
647652
Returns:
648-
Dict mapping file paths to their contents.
653+
Dict mapping file paths to their contents. Values are strings for
654+
text files, or bytes for binary files (images, PDFs, etc.).
649655
650656
Example:
651657
>>> files = client.download_files(['data.csv', 'results.json'])
@@ -667,7 +673,11 @@ def download_files(
667673
if "text" in resource:
668674
files[file_path] = resource["text"]
669675
elif "blob" in resource:
670-
files[file_path] = base64.b64decode(resource["blob"]).decode("utf-8")
676+
raw = base64.b64decode(resource["blob"])
677+
try:
678+
files[file_path] = raw.decode("utf-8")
679+
except (UnicodeDecodeError, ValueError):
680+
files[file_path] = raw
671681

672682
return files
673683

tests/bedrock_agentcore/tools/test_code_interpreter_client.py

Lines changed: 142 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1052,8 +1052,44 @@ def test_download_file_binary(self, mock_boto3, mock_get_data_endpoint, mock_get
10521052
client.identifier = "test.identifier"
10531053
client.session_id = "test-session-id"
10541054

1055-
original_content = "binary content as text"
1056-
encoded_content = base64.b64encode(original_content.encode("utf-8")).decode("utf-8")
1055+
binary_content = b"\x89PNG\r\n\x1a\n" # PNG header bytes
1056+
encoded_content = base64.b64encode(binary_content).decode("utf-8")
1057+
1058+
mock_response = {
1059+
"stream": [
1060+
{
1061+
"result": {
1062+
"content": [
1063+
{"type": "resource", "resource": {"uri": "file://image.png", "blob": encoded_content}}
1064+
]
1065+
}
1066+
}
1067+
]
1068+
}
1069+
client.data_plane_client.invoke_code_interpreter.return_value = mock_response
1070+
1071+
# Act
1072+
result = client.download_file("image.png")
1073+
1074+
# Assert
1075+
assert result == binary_content
1076+
assert isinstance(result, bytes)
1077+
1078+
@patch("bedrock_agentcore.tools.code_interpreter_client.get_control_plane_endpoint")
1079+
@patch("bedrock_agentcore.tools.code_interpreter_client.get_data_plane_endpoint")
1080+
@patch("bedrock_agentcore.tools.code_interpreter_client.boto3")
1081+
def test_download_file_blob_utf8_returns_str(self, mock_boto3, mock_get_data_endpoint, mock_get_control_endpoint):
1082+
"""Blob content that is valid UTF-8 should be decoded and returned as str."""
1083+
# Arrange
1084+
mock_session = MagicMock()
1085+
mock_session.client.return_value = MagicMock()
1086+
mock_boto3.Session.return_value = mock_session
1087+
client = CodeInterpreter("us-west-2")
1088+
client.identifier = "test.identifier"
1089+
client.session_id = "test-session-id"
1090+
1091+
text_content = "hello world"
1092+
encoded_content = base64.b64encode(text_content.encode("utf-8")).decode("utf-8")
10571093

10581094
mock_response = {
10591095
"stream": [
@@ -1072,7 +1108,8 @@ def test_download_file_binary(self, mock_boto3, mock_get_data_endpoint, mock_get
10721108
result = client.download_file("data.bin")
10731109

10741110
# Assert
1075-
assert result == original_content
1111+
assert result == text_content
1112+
assert isinstance(result, str)
10761113

10771114
@patch("bedrock_agentcore.tools.code_interpreter_client.get_control_plane_endpoint")
10781115
@patch("bedrock_agentcore.tools.code_interpreter_client.get_data_plane_endpoint")
@@ -1152,6 +1189,108 @@ def test_download_files_empty_result(self, mock_boto3, mock_get_data_endpoint, m
11521189
# Assert
11531190
assert result == {}
11541191

1192+
@patch("bedrock_agentcore.tools.code_interpreter_client.get_control_plane_endpoint")
1193+
@patch("bedrock_agentcore.tools.code_interpreter_client.get_data_plane_endpoint")
1194+
@patch("bedrock_agentcore.tools.code_interpreter_client.boto3")
1195+
def test_download_files_binary(self, mock_boto3, mock_get_data_endpoint, mock_get_control_endpoint):
1196+
# Arrange
1197+
mock_session = MagicMock()
1198+
mock_session.client.return_value = MagicMock()
1199+
mock_boto3.Session.return_value = mock_session
1200+
client = CodeInterpreter("us-west-2")
1201+
client.identifier = "test.identifier"
1202+
client.session_id = "test-session-id"
1203+
1204+
binary_content = b"\x89PNG\r\n\x1a\n" # PNG header bytes
1205+
encoded_binary = base64.b64encode(binary_content).decode("utf-8")
1206+
1207+
mock_response = {
1208+
"stream": [
1209+
{
1210+
"result": {
1211+
"content": [
1212+
{
1213+
"type": "resource",
1214+
"resource": {
1215+
"uri": "file:///opt/amazon/genesis1p-tools/var/data.csv",
1216+
"text": "col1,col2\n1,2",
1217+
},
1218+
},
1219+
{
1220+
"type": "resource",
1221+
"resource": {
1222+
"uri": "file:///opt/amazon/genesis1p-tools/var/chart.png",
1223+
"blob": encoded_binary,
1224+
},
1225+
},
1226+
]
1227+
}
1228+
}
1229+
]
1230+
}
1231+
client.data_plane_client.invoke_code_interpreter.return_value = mock_response
1232+
1233+
# Act
1234+
result = client.download_files(["data.csv", "chart.png"])
1235+
1236+
# Assert
1237+
assert result["/opt/amazon/genesis1p-tools/var/data.csv"] == "col1,col2\n1,2"
1238+
assert result["/opt/amazon/genesis1p-tools/var/chart.png"] == binary_content
1239+
assert isinstance(result["/opt/amazon/genesis1p-tools/var/chart.png"], bytes)
1240+
1241+
@patch("bedrock_agentcore.tools.code_interpreter_client.get_control_plane_endpoint")
1242+
@patch("bedrock_agentcore.tools.code_interpreter_client.get_data_plane_endpoint")
1243+
@patch("bedrock_agentcore.tools.code_interpreter_client.boto3")
1244+
def test_download_files_blob_utf8_returns_str(self, mock_boto3, mock_get_data_endpoint, mock_get_control_endpoint):
1245+
"""Blob content that is valid UTF-8 should be decoded and returned as str in multi-file download."""
1246+
# Arrange
1247+
mock_session = MagicMock()
1248+
mock_session.client.return_value = MagicMock()
1249+
mock_boto3.Session.return_value = mock_session
1250+
client = CodeInterpreter("us-west-2")
1251+
client.identifier = "test.identifier"
1252+
client.session_id = "test-session-id"
1253+
1254+
text_content = "some utf-8 blob content"
1255+
encoded_text = base64.b64encode(text_content.encode("utf-8")).decode("utf-8")
1256+
binary_content = b"\x89PNG\r\n\x1a\n"
1257+
encoded_binary = base64.b64encode(binary_content).decode("utf-8")
1258+
1259+
mock_response = {
1260+
"stream": [
1261+
{
1262+
"result": {
1263+
"content": [
1264+
{
1265+
"type": "resource",
1266+
"resource": {
1267+
"uri": "file:///opt/amazon/genesis1p-tools/var/data.bin",
1268+
"blob": encoded_text,
1269+
},
1270+
},
1271+
{
1272+
"type": "resource",
1273+
"resource": {
1274+
"uri": "file:///opt/amazon/genesis1p-tools/var/chart.png",
1275+
"blob": encoded_binary,
1276+
},
1277+
},
1278+
]
1279+
}
1280+
}
1281+
]
1282+
}
1283+
client.data_plane_client.invoke_code_interpreter.return_value = mock_response
1284+
1285+
# Act
1286+
result = client.download_files(["data.bin", "chart.png"])
1287+
1288+
# Assert — UTF-8-valid blob comes back as str, invalid UTF-8 blob comes back as bytes
1289+
assert result["/opt/amazon/genesis1p-tools/var/data.bin"] == text_content
1290+
assert isinstance(result["/opt/amazon/genesis1p-tools/var/data.bin"], str)
1291+
assert result["/opt/amazon/genesis1p-tools/var/chart.png"] == binary_content
1292+
assert isinstance(result["/opt/amazon/genesis1p-tools/var/chart.png"], bytes)
1293+
11551294
@patch("bedrock_agentcore.tools.code_interpreter_client.get_control_plane_endpoint")
11561295
@patch("bedrock_agentcore.tools.code_interpreter_client.get_data_plane_endpoint")
11571296
@patch("bedrock_agentcore.tools.code_interpreter_client.boto3")

0 commit comments

Comments
 (0)