Skip to content

Commit fd924a0

Browse files
committed
fix(eval): read and write eval data files as utf-8
Commit fc85348 standardized the eval subsystem on utf-8 for file based reads and writes, but a few eval data file operations were missed and still relied on the platform default encoding: - agent_evaluator.load_json (reads eval datasets) - agent_evaluator.migrate_eval_data_to_new_schema (writes the new file) - agent_evaluator._get_initial_session (reads the initial session file) - evaluation_generator.generate_responses_from_session (reads a session) On platforms whose default encoding is not utf-8 (for example cp1252 on Windows), loading or writing eval datasets or sessions that contain non-ASCII characters (emoji, CJK, accents) raises UnicodeDecodeError or UnicodeEncodeError, and the write path was inconsistent with the utf-8 reads elsewhere in the same module (agent_evaluator.py already opens the eval set file with encoding="utf-8"). Add encoding="utf-8" to these four open() calls so all eval data I/O is utf-8 consistent, and add regression tests that force a non-utf-8 default encoding to confirm non-ASCII eval data and sessions round-trip.
1 parent e6df097 commit fd924a0

4 files changed

Lines changed: 182 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
@@ -78,7 +78,7 @@
7878

7979

8080
def load_json(file_path: str) -> Union[Dict, List]:
81-
with open(file_path, "r") as f:
81+
with open(file_path, "r", encoding="utf-8") as f:
8282
return json.load(f)
8383

8484

@@ -266,7 +266,7 @@ def migrate_eval_data_to_new_schema(
266266
old_eval_data_file, eval_config, initial_session
267267
)
268268

269-
with open(new_eval_data_file, "w") as f:
269+
with open(new_eval_data_file, "w", encoding="utf-8") as f:
270270
f.write(eval_set.model_dump_json(indent=2))
271271

272272
@staticmethod
@@ -323,7 +323,7 @@ def _get_eval_set_from_old_format(
323323
def _get_initial_session(initial_session_file: Optional[str] = None):
324324
initial_session = {}
325325
if initial_session_file:
326-
with open(initial_session_file, "r") as f:
326+
with open(initial_session_file, "r", encoding="utf-8") as f:
327327
initial_session = json.loads(f.read())
328328
return initial_session
329329

src/google/adk/evaluation/evaluation_generator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,7 @@ def generate_responses_from_session(session_path, eval_dataset):
315315
"""
316316
results = []
317317

318-
with open(session_path, "r") as f:
318+
with open(session_path, "r", encoding="utf-8") as f:
319319
session_data = Session.model_validate_json(f.read())
320320
logger.info("Loaded session %s", session_path)
321321

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# Copyright 2026 Google LLC
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 __future__ import annotations
16+
17+
import builtins
18+
import json
19+
20+
from google.adk.evaluation import agent_evaluator as agent_evaluator_module
21+
from google.adk.evaluation.agent_evaluator import AgentEvaluator
22+
from google.adk.evaluation.agent_evaluator import load_json
23+
24+
# Non-ASCII payload (emoji, CJK, accented characters) that only survives a
25+
# UTF-8 round-trip. Written to eval-data files by the eval subsystem's Pydantic
26+
# `model_dump_json` (which does not escape non-ASCII).
27+
_NON_ASCII_TEXT = "\U0001f600 \u4f60\u597d caf\u00e9"
28+
29+
_real_open = builtins.open
30+
31+
32+
def _non_utf8_default_open(file, mode="r", *args, **kwargs):
33+
"""Emulates a platform whose default text encoding is not UTF-8.
34+
35+
On such platforms (for example Windows, where the default is cp1252),
36+
`open()` calls that omit `encoding=` inherit that non-UTF-8 default. This
37+
wrapper reproduces that behaviour on any platform by falling back to ASCII
38+
when a text-mode open does not specify an encoding, so a missing
39+
`encoding="utf-8"` argument raises instead of silently depending on the
40+
host locale.
41+
"""
42+
if "b" not in mode and "encoding" not in kwargs:
43+
kwargs["encoding"] = "ascii"
44+
return _real_open(file, mode, *args, **kwargs)
45+
46+
47+
def test_load_json_reads_non_ascii_with_non_utf8_default(tmp_path, mocker):
48+
"""`load_json` must decode eval data as UTF-8 regardless of platform locale."""
49+
file_path = tmp_path / "eval.json"
50+
file_path.write_text(
51+
json.dumps([{"query": _NON_ASCII_TEXT}], ensure_ascii=False),
52+
encoding="utf-8",
53+
)
54+
55+
mocker.patch.object(
56+
agent_evaluator_module, "open", _non_utf8_default_open, create=True
57+
)
58+
59+
assert load_json(str(file_path)) == [{"query": _NON_ASCII_TEXT}]
60+
61+
62+
def test_get_initial_session_reads_non_ascii_with_non_utf8_default(
63+
tmp_path, mocker
64+
):
65+
"""`_get_initial_session` must decode the session file as UTF-8."""
66+
session_file = tmp_path / "initial_session.json"
67+
session_file.write_text(
68+
json.dumps({"state": {"city": _NON_ASCII_TEXT}}, ensure_ascii=False),
69+
encoding="utf-8",
70+
)
71+
72+
mocker.patch.object(
73+
agent_evaluator_module, "open", _non_utf8_default_open, create=True
74+
)
75+
76+
initial_session = AgentEvaluator._get_initial_session(str(session_file))
77+
78+
assert initial_session == {"state": {"city": _NON_ASCII_TEXT}}
79+
80+
81+
def test_migrate_eval_data_round_trips_non_ascii_with_non_utf8_default(
82+
tmp_path, mocker
83+
):
84+
"""Migration must read the old file and write the new file as UTF-8.
85+
86+
This exercises both the read (`load_json`) and the write
87+
(`model_dump_json`) of eval data, which must stay UTF-8 consistent so that
88+
datasets containing non-ASCII characters survive migration on any platform.
89+
"""
90+
old_eval_data_file = tmp_path / "old_format.test.json"
91+
old_eval_data_file.write_text(
92+
json.dumps(
93+
[{
94+
"query": _NON_ASCII_TEXT,
95+
"reference": _NON_ASCII_TEXT,
96+
"expected_tool_use": [],
97+
}],
98+
ensure_ascii=False,
99+
),
100+
encoding="utf-8",
101+
)
102+
new_eval_data_file = tmp_path / "new_format.json"
103+
104+
mocker.patch.object(
105+
agent_evaluator_module, "open", _non_utf8_default_open, create=True
106+
)
107+
108+
AgentEvaluator.migrate_eval_data_to_new_schema(
109+
str(old_eval_data_file), str(new_eval_data_file)
110+
)
111+
112+
migrated = json.loads(new_eval_data_file.read_text(encoding="utf-8"))
113+
assert _NON_ASCII_TEXT in json.dumps(migrated, ensure_ascii=False)

tests/unittests/evaluation/test_evaluation_generator.py

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

1717
import asyncio
18+
import builtins
1819

20+
from google.adk.evaluation import evaluation_generator as evaluation_generator_module
1921
from google.adk.evaluation.app_details import AgentDetails
2022
from google.adk.evaluation.app_details import AppDetails
2123
from google.adk.evaluation.conversation_scenarios import ConversationScenario
@@ -33,6 +35,7 @@
3335
from google.adk.events.event import Event
3436
from google.adk.events.event_actions import EventActions
3537
from google.adk.models.llm_request import LlmRequest
38+
from google.adk.sessions.session import Session
3639
from google.genai import types
3740
import pytest
3841

@@ -966,3 +969,65 @@ def test_convert_events_preserves_tool_calls_when_skip_summarization():
966969
assert len(tool_calls) == 1
967970
assert tool_calls[0].name == "execute_sql"
968971
assert tool_calls[0].args == {"project_id": "my-proj", "query": "SELECT 1"}
972+
973+
974+
_real_open = builtins.open
975+
976+
977+
def _non_utf8_default_open(file, mode="r", *args, **kwargs):
978+
"""Emulates a platform whose default text encoding is not UTF-8.
979+
980+
Falls back to ASCII when a text-mode open does not specify an encoding, so a
981+
missing `encoding="utf-8"` argument raises instead of silently depending on
982+
the host locale (for example cp1252 on Windows).
983+
"""
984+
if "b" not in mode and "encoding" not in kwargs:
985+
kwargs["encoding"] = "ascii"
986+
return _real_open(file, mode, *args, **kwargs)
987+
988+
989+
def test_generate_responses_from_session_reads_non_ascii_with_non_utf8_default(
990+
tmp_path, mocker
991+
):
992+
"""The session file must be read as UTF-8 regardless of platform locale.
993+
994+
Session files serialized via `model_dump_json` contain raw (unescaped)
995+
non-ASCII characters, so reading them without an explicit UTF-8 encoding
996+
fails on platforms whose default encoding is not UTF-8.
997+
"""
998+
non_ascii_text = "\U0001f600 \u4f60\u597d caf\u00e9"
999+
session = Session(
1000+
id="s1",
1001+
app_name="app",
1002+
user_id="u1",
1003+
events=[
1004+
Event(
1005+
author="user",
1006+
invocation_id="inv1",
1007+
content=types.Content(
1008+
role="user", parts=[types.Part(text=non_ascii_text)]
1009+
),
1010+
),
1011+
Event(
1012+
author="agent",
1013+
invocation_id="inv1",
1014+
content=types.Content(
1015+
role="model",
1016+
parts=[types.Part(text="response " + non_ascii_text)],
1017+
),
1018+
),
1019+
],
1020+
)
1021+
session_path = tmp_path / "session.json"
1022+
session_path.write_text(session.model_dump_json(), encoding="utf-8")
1023+
1024+
mocker.patch.object(
1025+
evaluation_generator_module, "open", _non_utf8_default_open, create=True
1026+
)
1027+
1028+
results = EvaluationGenerator.generate_responses_from_session(
1029+
str(session_path), [[{"query": non_ascii_text}]]
1030+
)
1031+
1032+
assert results[0][0]["query"] == non_ascii_text
1033+
assert results[0][0]["response"] == "response " + non_ascii_text

0 commit comments

Comments
 (0)