Skip to content

Commit 2e878ed

Browse files
anxkhncopybara-github
authored andcommitted
fix: read and write eval data files as utf-8
Merge #6298 PiperOrigin-RevId: 963885296
1 parent 18903ca commit 2e878ed

4 files changed

Lines changed: 158 additions & 4 deletions

File tree

src/google/adk/evaluation/agent_evaluator.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ def __call__(self) -> Awaitable[tuple[BaseAgent, object]]:
9494

9595

9696
def load_json(file_path: str) -> Union[Dict[str, Any], List[Any]]:
97-
with open(file_path, "r") as f:
97+
with open(file_path, "r", encoding="utf-8") as f:
9898
return cast(Union[Dict[str, Any], List[Any]], json.load(f))
9999

100100

@@ -365,7 +365,7 @@ def migrate_eval_data_to_new_schema(
365365
old_eval_data_file, eval_config, initial_session
366366
)
367367

368-
with open(new_eval_data_file, "w") as f:
368+
with open(new_eval_data_file, "w", encoding="utf-8") as f:
369369
f.write(eval_set.model_dump_json(indent=2))
370370

371371
@staticmethod
@@ -424,7 +424,7 @@ def _get_initial_session(
424424
) -> dict[str, Any]:
425425
initial_session: dict[str, Any] = {}
426426
if initial_session_file:
427-
with open(initial_session_file, "r") as f:
427+
with open(initial_session_file, "r", encoding="utf-8") as f:
428428
initial_session = json.loads(f.read())
429429
return initial_session
430430

src/google/adk/evaluation/evaluation_generator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -596,7 +596,7 @@ def generate_responses_from_session(
596596
"""
597597
results = []
598598

599-
with open(session_path, "r") as f:
599+
with open(session_path, "r", encoding="utf-8") as f:
600600
session_data = Session.model_validate_json(f.read())
601601
logger.info("Loaded session %s", session_path)
602602

tests/unittests/evaluation/test_agent_evaluator.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from __future__ import annotations
1818

19+
import builtins
1920
import json
2021
import os
2122
from pathlib import Path
@@ -25,8 +26,10 @@
2526
from google.adk.agents.base_agent import BaseAgent
2627
from google.adk.apps.app import App
2728
from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService
29+
from google.adk.evaluation import agent_evaluator as agent_evaluator_module
2830
from google.adk.evaluation.agent_evaluator import _EvalMetricResultWithInvocation
2931
from google.adk.evaluation.agent_evaluator import AgentEvaluator
32+
from google.adk.evaluation.agent_evaluator import load_json
3033
from google.adk.evaluation.eval_case import EvalCase
3134
from google.adk.evaluation.eval_case import Invocation
3235
from google.adk.evaluation.eval_config import EvalConfig
@@ -42,6 +45,9 @@
4245
import pandas as pd
4346
import pytest
4447

48+
_NON_ASCII_TEXT = "😀 你好 café"
49+
_real_open = builtins.open
50+
4551

4652
def _make_eval_set() -> EvalSet:
4753
return EvalSet(
@@ -979,5 +985,89 @@ async def test_evaluate_keeps_positional_initial_session_file_and_print_flag(
979985
)
980986

981987

988+
def _non_utf8_default_open(file, mode="r", *args, **kwargs):
989+
"""Emulates a platform whose default text encoding is not UTF-8.
990+
991+
On such platforms (for example Windows, where the default is cp1252),
992+
`open()` calls that omit `encoding=` inherit that non-UTF-8 default. This
993+
wrapper reproduces that behaviour on any platform by falling back to ASCII
994+
when a text-mode open does not specify an encoding, so a missing
995+
`encoding="utf-8"` argument raises instead of silently depending on the
996+
host locale.
997+
"""
998+
if "b" not in mode and "encoding" not in kwargs:
999+
kwargs["encoding"] = "ascii"
1000+
return _real_open(file, mode, *args, **kwargs)
1001+
1002+
1003+
def test_load_json_reads_non_ascii_with_non_utf8_default(tmp_path, mocker):
1004+
"""`load_json` must decode eval data as UTF-8 regardless of platform locale."""
1005+
file_path = tmp_path / "eval.json"
1006+
file_path.write_text(
1007+
json.dumps([{"query": _NON_ASCII_TEXT}], ensure_ascii=False),
1008+
encoding="utf-8",
1009+
)
1010+
1011+
mocker.patch.object(
1012+
agent_evaluator_module, "open", _non_utf8_default_open, create=True
1013+
)
1014+
1015+
assert load_json(str(file_path)) == [{"query": _NON_ASCII_TEXT}]
1016+
1017+
1018+
def test_get_initial_session_reads_non_ascii_with_non_utf8_default(
1019+
tmp_path, mocker
1020+
):
1021+
"""`_get_initial_session` must decode the session file as UTF-8."""
1022+
session_file = tmp_path / "initial_session.json"
1023+
session_file.write_text(
1024+
json.dumps({"state": {"city": _NON_ASCII_TEXT}}, ensure_ascii=False),
1025+
encoding="utf-8",
1026+
)
1027+
1028+
mocker.patch.object(
1029+
agent_evaluator_module, "open", _non_utf8_default_open, create=True
1030+
)
1031+
1032+
initial_session = AgentEvaluator._get_initial_session(str(session_file))
1033+
1034+
assert initial_session == {"state": {"city": _NON_ASCII_TEXT}}
1035+
1036+
1037+
def test_migrate_eval_data_round_trips_non_ascii_with_non_utf8_default(
1038+
tmp_path, mocker
1039+
):
1040+
"""Migration must read the old file and write the new file as UTF-8.
1041+
1042+
This exercises both the read (`load_json`) and the write
1043+
(`model_dump_json`) of eval data, which must stay UTF-8 consistent so that
1044+
datasets containing non-ASCII characters survive migration on any platform.
1045+
"""
1046+
old_eval_data_file = tmp_path / "old_format.test.json"
1047+
old_eval_data_file.write_text(
1048+
json.dumps(
1049+
[{
1050+
"query": _NON_ASCII_TEXT,
1051+
"reference": _NON_ASCII_TEXT,
1052+
"expected_tool_use": [],
1053+
}],
1054+
ensure_ascii=False,
1055+
),
1056+
encoding="utf-8",
1057+
)
1058+
new_eval_data_file = tmp_path / "new_format.json"
1059+
1060+
mocker.patch.object(
1061+
agent_evaluator_module, "open", _non_utf8_default_open, create=True
1062+
)
1063+
1064+
AgentEvaluator.migrate_eval_data_to_new_schema(
1065+
str(old_eval_data_file), str(new_eval_data_file)
1066+
)
1067+
1068+
migrated = json.loads(new_eval_data_file.read_text(encoding="utf-8"))
1069+
assert _NON_ASCII_TEXT in json.dumps(migrated, ensure_ascii=False)
1070+
1071+
9821072
if __name__ == "__main__":
9831073
raise SystemExit(pytest.main([__file__, "-v"]))

tests/unittests/evaluation/test_evaluation_generator.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@
1515
from __future__ import annotations
1616

1717
import asyncio
18+
import builtins
1819

1920
from google.adk.agents.base_agent import BaseAgent
2021
from google.adk.apps.app import App
22+
from google.adk.evaluation import evaluation_generator as evaluation_generator_module
2123
from google.adk.evaluation.app_details import AgentDetails
2224
from google.adk.evaluation.app_details import AppDetails
2325
from google.adk.evaluation.conversation_scenarios import ConversationScenario
@@ -1991,3 +1993,65 @@ def test_generate_responses_from_session_scopes_by_invocation_id(tmp_path):
19911993

19921994
assert results[0][0]["actual_tool_use"] == []
19931995
assert results[0][0]["response"] == "I rolled a 4."
1996+
1997+
1998+
_real_open = builtins.open
1999+
2000+
2001+
def _non_utf8_default_open(file, mode="r", *args, **kwargs):
2002+
"""Emulates a platform whose default text encoding is not UTF-8.
2003+
2004+
Falls back to ASCII when a text-mode open does not specify an encoding, so a
2005+
missing `encoding="utf-8"` argument raises instead of silently depending on
2006+
the host locale (for example cp1252 on Windows).
2007+
"""
2008+
if "b" not in mode and "encoding" not in kwargs:
2009+
kwargs["encoding"] = "ascii"
2010+
return _real_open(file, mode, *args, **kwargs)
2011+
2012+
2013+
def test_generate_responses_from_session_reads_non_ascii_with_non_utf8_default(
2014+
tmp_path, mocker
2015+
):
2016+
"""The session file must be read as UTF-8 regardless of platform locale.
2017+
2018+
Session files serialized via `model_dump_json` contain raw (unescaped)
2019+
non-ASCII characters, so reading them without an explicit UTF-8 encoding
2020+
fails on platforms whose default encoding is not UTF-8.
2021+
"""
2022+
non_ascii_text = "😀 你好 café"
2023+
session = Session(
2024+
id="s1",
2025+
app_name="app",
2026+
user_id="u1",
2027+
events=[
2028+
Event(
2029+
author="user",
2030+
invocation_id="inv1",
2031+
content=types.Content(
2032+
role="user", parts=[types.Part(text=non_ascii_text)]
2033+
),
2034+
),
2035+
Event(
2036+
author="agent",
2037+
invocation_id="inv1",
2038+
content=types.Content(
2039+
role="model",
2040+
parts=[types.Part(text="response " + non_ascii_text)],
2041+
),
2042+
),
2043+
],
2044+
)
2045+
session_path = tmp_path / "session.json"
2046+
session_path.write_text(session.model_dump_json(), encoding="utf-8")
2047+
2048+
mocker.patch.object(
2049+
evaluation_generator_module, "open", _non_utf8_default_open, create=True
2050+
)
2051+
2052+
results = EvaluationGenerator.generate_responses_from_session(
2053+
str(session_path), [[{"query": non_ascii_text}]]
2054+
)
2055+
2056+
assert results[0][0]["query"] == non_ascii_text
2057+
assert results[0][0]["response"] == "response " + non_ascii_text

0 commit comments

Comments
 (0)