Pin text I/O to UTF-8 and fail CI on locale-dependent reads/writes (#3296)
Co-authored-by: ShuQingDollarVoyager <57471784+ShuQingDollarVoyager@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
52ad0a8876
commit
5285e936a9
9 changed files with 76 additions and 57 deletions
|
|
@ -64,7 +64,7 @@ def test_mcp_requirement_falls_back_when_mcp_is_not_installed(monkeypatch: pytes
|
|||
|
||||
|
||||
def _read_server(config_dir: Path, name: str) -> dict[str, Any]:
|
||||
config = json.loads((config_dir / "claude_desktop_config.json").read_text())
|
||||
config = json.loads((config_dir / "claude_desktop_config.json").read_text(encoding="utf-8"))
|
||||
return config["mcpServers"][name]
|
||||
|
||||
|
||||
|
|
@ -121,7 +121,7 @@ def test_env_vars_written(config_dir: Path):
|
|||
def test_existing_env_vars_merged_new_wins(config_dir: Path):
|
||||
"""Re-installing should merge env vars, with new values overriding existing ones."""
|
||||
(config_dir / "claude_desktop_config.json").write_text(
|
||||
json.dumps({"mcpServers": {"s": {"env": {"OLD": "keep", "KEY": "old"}}}})
|
||||
json.dumps({"mcpServers": {"s": {"env": {"OLD": "keep", "KEY": "old"}}}}), encoding="utf-8"
|
||||
)
|
||||
|
||||
assert update_claude_config(file_spec="s.py:app", server_name="s", env_vars={"KEY": "new"})
|
||||
|
|
@ -131,7 +131,9 @@ def test_existing_env_vars_merged_new_wins(config_dir: Path):
|
|||
|
||||
def test_existing_env_vars_preserved_without_new(config_dir: Path):
|
||||
"""Re-installing without env_vars should keep the existing env block intact."""
|
||||
(config_dir / "claude_desktop_config.json").write_text(json.dumps({"mcpServers": {"s": {"env": {"KEEP": "me"}}}}))
|
||||
(config_dir / "claude_desktop_config.json").write_text(
|
||||
json.dumps({"mcpServers": {"s": {"env": {"KEEP": "me"}}}}), encoding="utf-8"
|
||||
)
|
||||
|
||||
assert update_claude_config(file_spec="s.py:app", server_name="s")
|
||||
|
||||
|
|
@ -139,14 +141,27 @@ def test_existing_env_vars_preserved_without_new(config_dir: Path):
|
|||
|
||||
|
||||
def test_other_servers_preserved(config_dir: Path):
|
||||
"""Installing a new server should not clobber existing mcpServers entries."""
|
||||
(config_dir / "claude_desktop_config.json").write_text(json.dumps({"mcpServers": {"other": {"command": "x"}}}))
|
||||
"""Installing a new server must not clobber existing entries, non-ASCII text included (the file is UTF-8)."""
|
||||
other = {"command": "C:\\Users\\张伟\\uv.exe", "env": {"CITY": "Zürich"}}
|
||||
config_file = config_dir / "claude_desktop_config.json"
|
||||
config_file.write_text(json.dumps({"mcpServers": {"文件": other}}, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
assert update_claude_config(file_spec="s.py:app", server_name="s")
|
||||
|
||||
config = json.loads((config_dir / "claude_desktop_config.json").read_text())
|
||||
assert set(config["mcpServers"]) == {"other", "s"}
|
||||
assert config["mcpServers"]["other"] == {"command": "x"}
|
||||
config = json.loads(config_file.read_text(encoding="utf-8"))
|
||||
assert set(config["mcpServers"]) == {"文件", "s"}
|
||||
assert config["mcpServers"]["文件"] == other
|
||||
|
||||
|
||||
@pytest.mark.parametrize("codec", ["utf-8-sig", "utf-16"])
|
||||
def test_existing_config_with_a_bom_is_accepted(config_dir: Path, codec: str):
|
||||
"""A config saved by Windows tooling (UTF-8 with BOM, or PowerShell 5's UTF-16 `>`) can still be installed into."""
|
||||
config_file = config_dir / "claude_desktop_config.json"
|
||||
config_file.write_bytes(json.dumps({"mcpServers": {"other": {"command": "x"}}}).encode(codec))
|
||||
|
||||
assert update_claude_config(file_spec="s.py:app", server_name="s")
|
||||
|
||||
assert set(json.loads(config_file.read_bytes())["mcpServers"]) == {"other", "s"}
|
||||
|
||||
|
||||
def test_raises_when_config_dir_missing(monkeypatch: pytest.MonkeyPatch):
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ def _set_mcp_version(monkeypatch: pytest.MonkeyPatch, version: str) -> None:
|
|||
def test_parse_file_path_accepts_valid_specs(tmp_path: Path, spec: str, expected_obj: str | None):
|
||||
"""Should accept valid file specs."""
|
||||
file = tmp_path / spec.split(":")[0]
|
||||
file.write_text("x = 1")
|
||||
file.write_text("x = 1", encoding="utf-8")
|
||||
path, obj = _parse_file_path(f"{file}:{expected_obj}" if ":" in spec else str(file))
|
||||
assert path == file.resolve()
|
||||
assert obj == expected_obj
|
||||
|
|
|
|||
|
|
@ -100,4 +100,4 @@ async def test_a_file_resource_is_served_with_the_app_mime_type_filled_in() -> N
|
|||
contents = result.contents[0]
|
||||
assert isinstance(contents, TextResourceContents)
|
||||
assert contents.mime_type == APP_MIME_TYPE
|
||||
assert contents.text == tutorial003.REPORT_HTML.read_text()
|
||||
assert contents.text == tutorial003.REPORT_HTML.read_text(encoding="utf-8")
|
||||
|
|
|
|||
|
|
@ -129,9 +129,9 @@ def test_dotdot_is_a_component_check_not_a_substring_scan() -> None:
|
|||
async def test_safe_join_serves_a_file_inside_the_base_directory(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""tutorial002: `safe_join(DOCS_ROOT, path).read_text()` returns the file under the base."""
|
||||
"""tutorial002: `safe_join(DOCS_ROOT, path).read_text(encoding="utf-8")` returns the file under the base."""
|
||||
(tmp_path / "printing").mkdir()
|
||||
(tmp_path / "printing" / "setup.md").write_text("# Printer setup")
|
||||
(tmp_path / "printing" / "setup.md").write_text("# Printer setup", encoding="utf-8")
|
||||
monkeypatch.setattr(tutorial002, "DOCS_ROOT", tmp_path)
|
||||
async with Client(tutorial002.mcp) as client:
|
||||
(content,) = (await client.read_resource("manuals://printing/setup.md")).contents
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ async def test_tool_call_and_notification_round_trip_over_a_stdio_subprocess(
|
|||
async def collect(params: LoggingMessageNotificationParams) -> None:
|
||||
received.append(params)
|
||||
|
||||
with tempfile.TemporaryFile(mode="w+") as errlog:
|
||||
with tempfile.TemporaryFile(mode="w+", encoding="utf-8", errors="replace") as errlog:
|
||||
transport = stdio_client(
|
||||
StdioServerParameters(
|
||||
command=sys.executable,
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ def temp_file():
|
|||
File is automatically cleaned up after the test if it still exists.
|
||||
"""
|
||||
content = "test content"
|
||||
with NamedTemporaryFile(mode="w", delete=False) as f:
|
||||
with NamedTemporaryFile(mode="w", encoding="utf-8", delete=False) as f:
|
||||
f.write(content)
|
||||
path = Path(f.name).resolve()
|
||||
yield path
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@ def test_dir(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
|||
tmp = tmp_path_factory.mktemp("test_files")
|
||||
|
||||
# Create test files
|
||||
(tmp / "example.py").write_text("print('hello world')")
|
||||
(tmp / "readme.md").write_text("# Test Directory\nThis is a test.")
|
||||
(tmp / "config.json").write_text('{"test": true}')
|
||||
(tmp / "example.py").write_text("print('hello world')", encoding="utf-8")
|
||||
(tmp / "readme.md").write_text("# Test Directory\nThis is a test.", encoding="utf-8")
|
||||
(tmp / "config.json").write_text('{"test": true}', encoding="utf-8")
|
||||
|
||||
return tmp
|
||||
|
||||
|
|
@ -38,7 +38,7 @@ def resources(mcp: MCPServer, test_dir: Path) -> MCPServer:
|
|||
def read_example_py() -> str:
|
||||
"""Read the example.py file"""
|
||||
try:
|
||||
return (test_dir / "example.py").read_text()
|
||||
return (test_dir / "example.py").read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
return "File not found"
|
||||
|
||||
|
|
@ -46,7 +46,7 @@ def resources(mcp: MCPServer, test_dir: Path) -> MCPServer:
|
|||
def read_readme_md() -> str:
|
||||
"""Read the readme.md file"""
|
||||
try: # pragma: no cover
|
||||
return (test_dir / "readme.md").read_text()
|
||||
return (test_dir / "readme.md").read_text(encoding="utf-8")
|
||||
except FileNotFoundError: # pragma: no cover
|
||||
return "File not found"
|
||||
|
||||
|
|
@ -54,7 +54,7 @@ def resources(mcp: MCPServer, test_dir: Path) -> MCPServer:
|
|||
def read_config_json() -> str:
|
||||
"""Read the config.json file"""
|
||||
try: # pragma: no cover
|
||||
return (test_dir / "config.json").read_text()
|
||||
return (test_dir / "config.json").read_text(encoding="utf-8")
|
||||
except FileNotFoundError: # pragma: no cover
|
||||
return "File not found"
|
||||
|
||||
|
|
|
|||
|
|
@ -819,7 +819,7 @@ class TestServerResources:
|
|||
|
||||
# Create a text file
|
||||
text_file = tmp_path / "test.txt"
|
||||
text_file.write_text("Hello from file!")
|
||||
text_file.write_text("Hello from file!", encoding="utf-8")
|
||||
|
||||
resource = FileResource(uri="file://test.txt", name="test.txt", path=text_file)
|
||||
mcp.add_resource(resource)
|
||||
|
|
|
|||
|
|
@ -495,9 +495,9 @@ def test_bare_import_mcp_binds_the_types_submodule():
|
|||
# A regression hangs forever, so the bound only has to beat never (matches the suite's
|
||||
# other subprocess.run calls).
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", "import mcp; print(mcp.types.Tool.__name__)"],
|
||||
[sys.executable, "-X", "utf8", "-c", "import mcp; print(mcp.types.Tool.__name__)"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=False,
|
||||
timeout=20,
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue