Skip to content

Commit 5285e93

Browse files
maxisbeyShuQingDollarVoyagerclaude
authored
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>
1 parent 52ad0a8 commit 5285e93

20 files changed

Lines changed: 76 additions & 57 deletions

File tree

‎.github/workflows/shared.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,9 @@ jobs:
9999
# tests/examples/test_stories_smoke.py is gated on this var; it spawns real
100100
# stdio + uvicorn subprocesses, so run it on exactly one matrix cell.
101101
MCP_EXAMPLES_SMOKE: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' && matrix.dep-resolution.name == 'locked' && '1' || '' }}
102+
# PEP 597: text I/O without encoding= raises EncodingWarning, which pytest's "error"
103+
# filter makes fatal on every cell. An env var (not -X) so pytest-xdist workers inherit it.
104+
PYTHONWARNDEFAULTENCODING: "1"
102105
run: |
103106
uv run --frozen --no-sync coverage erase
104107
uv run --frozen --no-sync coverage run -m pytest -n auto

‎AGENTS.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@
4646
dependencies and obscure circular-import bugs. Only exception: when a
4747
top-level import genuinely can't work (lazy-loading optional deps, or
4848
tests that re-import a module).
49+
- Always pass `encoding=` to text-mode `open()`, `Path.read_text()`/`write_text()`,
50+
`tempfile` and `subprocess` text pipes — normally `"utf-8"`, or `"locale"` when the
51+
platform encoding is genuinely intended; the default is the process locale, not UTF-8.
52+
CI and `scripts/test` run pytest with `PYTHONWARNDEFAULTENCODING=1` (PEP 597), which
53+
makes any omission an error under the `error` filter.
4954

5055
## Testing
5156

‎docs_src/uri_templates/tutorial002.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,4 @@
1111
@mcp.resource("manuals://{+path}")
1212
def read_manual(path: str) -> str:
1313
"""A staff manual page, served from a directory on disk."""
14-
return safe_join(DOCS_ROOT, path).read_text()
14+
return safe_join(DOCS_ROOT, path).read_text(encoding="utf-8")

‎examples/clients/simple-chatbot/mcp_simple_chatbot/main.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ def load_config(file_path: str) -> dict[str, Any]:
4444
FileNotFoundError: If configuration file doesn't exist.
4545
JSONDecodeError: If configuration file is invalid JSON.
4646
"""
47-
with open(file_path, "r") as f:
47+
with open(file_path, encoding="utf-8") as f:
4848
return json.load(f)
4949

5050
@property

‎examples/stories/_harness.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ async def _self_hosted(name: str, cfg: dict[str, Any]) -> AsyncIterator[str]:
123123

124124
def _story_cfg(name: str) -> dict[str, Any]:
125125
"""The manifest entry for the story ``name`` with ``[defaults]`` applied."""
126-
manifest: dict[str, Any] = tomllib.loads((Path(__file__).parent / "manifest.toml").read_text())
126+
manifest: dict[str, Any] = tomllib.loads((Path(__file__).parent / "manifest.toml").read_text(encoding="utf-8"))
127127
return manifest["defaults"] | manifest["story"].get(name, {})
128128

129129

‎pyproject.toml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,9 @@ filterwarnings = [
279279
# 2026-07-28 drops ping; Client.send_ping() is advisory-deprecated and the
280280
# legacy interaction/transport tests still drive it.
281281
"ignore:ping is removed as of 2026-07-28.*:mcp.MCPDeprecationWarning",
282+
# CI and scripts/test set PYTHONWARNDEFAULTENCODING=1, so "error" rejects any text I/O
283+
# of ours that omits encoding=; pytest-examples' own unguarded text I/O isn't ours.
284+
"ignore:'encoding' argument not specified:EncodingWarning:pytest_examples",
282285
]
283286

284287
[tool.markdown.lint]

‎scripts/gen_surface_types.py‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@
124124

125125
def load_pinned() -> list[dict[str, str]]:
126126
"""Read `schema/PINNED.json` and verify each vendored file's sha256."""
127-
entries: list[dict[str, str]] = json.loads((SCHEMA_DIR / "PINNED.json").read_text())
127+
entries: list[dict[str, str]] = json.loads((SCHEMA_DIR / "PINNED.json").read_text(encoding="utf-8"))
128128
for entry in entries:
129129
path = SCHEMA_DIR / f"{entry['protocol_version']}.json"
130130
actual = hashlib.sha256(path.read_bytes()).hexdigest()
@@ -189,7 +189,7 @@ def run_codegen(schema_path: Path, output_path: Path) -> None:
189189
"--type-mappings", "byte=string", "uri=string", "uri-template=string",
190190
"--disable-timestamp",
191191
],
192-
capture_output=True, text=True,
192+
capture_output=True, encoding="utf-8", errors="replace",
193193
)
194194
# fmt: on
195195
if result.returncode != 0:
@@ -222,16 +222,16 @@ def patch(match: re.Match[str]) -> str:
222222
def build(entry: dict[str, str]) -> str:
223223
"""Generate, post-process, and format one version's surface module text."""
224224
version = entry["protocol_version"]
225-
schema = json.loads((SCHEMA_DIR / f"{version}.json").read_text())
225+
schema = json.loads((SCHEMA_DIR / f"{version}.json").read_text(encoding="utf-8"))
226226
patch_schema(schema, SCHEMA_PATCHES.get(version, []))
227227
make_server_info_opaque(schema)
228228

229229
with tempfile.TemporaryDirectory() as tmp:
230230
patched = Path(tmp) / "schema.json"
231-
patched.write_text(json.dumps(schema))
231+
patched.write_text(json.dumps(schema), encoding="utf-8")
232232
raw = Path(tmp) / "raw.py"
233233
run_codegen(patched, raw)
234-
source = raw.read_text()
234+
source = raw.read_text(encoding="utf-8")
235235

236236
source = re.sub(r"\A# generated by datamodel-codegen:\n#[^\n]*\n", "", source)
237237
source = re.sub(r"^class Model\(RootModel\[Any\]\):\n {4}root: Any\n+", "", source, count=1, flags=re.MULTILINE)
@@ -253,12 +253,12 @@ def build(entry: dict[str, str]) -> str:
253253

254254
staging = TYPES_DIR / f"_staging_{version}.py"
255255
try:
256-
staging.write_text(source)
256+
staging.write_text(source, encoding="utf-8")
257257
subprocess.run(
258258
["uv", "run", "--frozen", "ruff", "format", "--no-cache", str(staging)],
259259
cwd=REPO_ROOT, capture_output=True, check=True,
260260
) # fmt: skip
261-
return staging.read_text()
261+
return staging.read_text(encoding="utf-8")
262262
finally:
263263
staging.unlink(missing_ok=True)
264264

@@ -275,10 +275,10 @@ def main(argv: list[str] | None = None) -> int:
275275
candidate = build(entry)
276276
if not args.check:
277277
target.parent.mkdir(parents=True, exist_ok=True)
278-
target.write_text(candidate)
278+
target.write_text(candidate, encoding="utf-8")
279279
print(f"{entry['protocol_version']}: wrote {target.relative_to(REPO_ROOT)} ({len(candidate)} bytes)")
280280
continue
281-
committed = target.read_text() if target.is_file() else ""
281+
committed = target.read_text(encoding="utf-8") if target.is_file() else ""
282282
if committed != candidate:
283283
drift = True
284284
sys.stderr.writelines(

‎scripts/test‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
set -ex
44

55
uv run --frozen coverage erase
6-
uv run --frozen coverage run -m pytest -n auto $@
6+
# PYTHONWARNDEFAULTENCODING=1 mirrors CI: text I/O without encoding= fails under pytest's "error" filter.
7+
PYTHONWARNDEFAULTENCODING=1 uv run --frozen coverage run -m pytest -n auto "$@"
78
uv run --frozen coverage combine
89
uv run --frozen coverage report
910
# strict-no-cover spawns `uv run coverage json` internally without --frozen;

‎scripts/update_readme_snippets.py‎

Lines changed: 9 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -43,15 +43,7 @@ def process_snippet_block(match: re.Match[str], check_mode: bool = False) -> str
4343
file_path = match.group(2)
4444

4545
try:
46-
# Read the entire file. A missing source file must be fatal: a "Warning"
47-
# that returns the stale block lets --check pass with exit 0, so a
48-
# renamed or deleted snippet is invisible to CI. SystemExit deliberately
49-
# escapes the `except Exception` below.
50-
file = Path(file_path)
51-
if not file.exists():
52-
sys.exit(f"Error: snippet-source file not found: {file_path}")
53-
54-
code = file.read_text().rstrip()
46+
code = Path(file_path).read_text(encoding="utf-8").rstrip()
5547
github_url = get_github_url(file_path)
5648

5749
# Build the replacement block
@@ -88,9 +80,9 @@ def process_snippet_block(match: re.Match[str], check_mode: bool = False) -> str
8880

8981
return replacement
9082

91-
except Exception as e:
92-
print(f"Error processing {file_path}: {e}")
93-
return full_match
83+
except (OSError, UnicodeDecodeError) as e:
84+
# Fatal, not "warn and keep the stale block": that would let --check pass with exit 0.
85+
sys.exit(f"Error processing {file_path}: {e}")
9486

9587

9688
def update_readme_snippets(check_mode: bool = False) -> bool:
@@ -107,7 +99,7 @@ def update_readme_snippets(check_mode: bool = False) -> bool:
10799
print(f"Error: README file not found: {readme_path}")
108100
return False
109101

110-
content = readme_path.read_text()
102+
content = readme_path.read_text(encoding="utf-8")
111103
original_content = content
112104

113105
# Pattern to match snippet-source blocks
@@ -129,14 +121,14 @@ def update_readme_snippets(check_mode: bool = False) -> bool:
129121
)
130122
return False
131123
else:
132-
print(f"✓ {readme_path} code snippets are up to date")
124+
print(f"{readme_path} code snippets are up to date")
133125
return True
134126
else:
135127
if updated_content != original_content:
136-
readme_path.write_text(updated_content)
137-
print(f"✓ Updated {readme_path}")
128+
readme_path.write_text(updated_content, encoding="utf-8")
129+
print(f"Updated {readme_path}")
138130
else:
139-
print(f"✓ {readme_path} already up to date")
131+
print(f"{readme_path} already up to date")
140132
return True
141133

142134

‎src/mcp/cli/claude.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ def update_claude_config(
9292
config_file = config_dir / "claude_desktop_config.json"
9393
if not config_file.exists(): # pragma: lax no cover
9494
try:
95-
config_file.write_text("{}")
95+
config_file.write_text("{}", encoding="utf-8")
9696
except Exception:
9797
logger.exception(
9898
"Failed to create Claude config file",
@@ -103,7 +103,7 @@ def update_claude_config(
103103
return False
104104

105105
try:
106-
config = json.loads(config_file.read_text())
106+
config = json.loads(config_file.read_bytes())
107107
if "mcpServers" not in config:
108108
config["mcpServers"] = {}
109109

@@ -154,7 +154,7 @@ def update_claude_config(
154154

155155
config["mcpServers"][server_name] = server_config
156156

157-
config_file.write_text(json.dumps(config, indent=2))
157+
config_file.write_text(json.dumps(config, indent=2), encoding="utf-8")
158158
logger.info(
159159
f"Added server '{server_name}' to Claude config",
160160
extra={"config_file": str(config_file)},

0 commit comments

Comments
 (0)