diff --git a/.github/workflows/shared.yml b/.github/workflows/shared.yml index 541fc7bb54..1e750e4ae5 100644 --- a/.github/workflows/shared.yml +++ b/.github/workflows/shared.yml @@ -41,10 +41,11 @@ jobs: with: extra_args: --all-files --verbose + # -X warn_default_encoding -W error::EncodingWarning: reject locale-dependent text I/O in the script (PEP 597). - name: Surface types match vendored schema run: | uv sync --group codegen --frozen - uv run --frozen --group codegen python scripts/gen_surface_types.py --check + uv run --frozen --group codegen python -X warn_default_encoding -W error::EncodingWarning scripts/gen_surface_types.py --check # Resolves only mcp-types' declared dependencies into an empty environment, # so an import of the SDK or anything from its stack fails here. @@ -99,6 +100,9 @@ jobs: # tests/examples/test_stories_smoke.py is gated on this var; it spawns real # stdio + uvicorn subprocesses, so run it on exactly one matrix cell. MCP_EXAMPLES_SMOKE: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' && matrix.dep-resolution.name == 'locked' && '1' || '' }} + # PEP 597: text I/O without encoding= raises EncodingWarning, which pytest's "error" + # filter makes fatal on every cell. An env var (not -X) so pytest-xdist workers inherit it. + PYTHONWARNDEFAULTENCODING: "1" run: | uv run --frozen --no-sync coverage erase uv run --frozen --no-sync coverage run -m pytest -n auto @@ -137,7 +141,7 @@ jobs: run: uv sync --frozen --all-extras --python 3.10 - name: Check README snippets are up to date - run: uv run --frozen scripts/update_readme_snippets.py --check + run: uv run --frozen python -X warn_default_encoding -W error::EncodingWarning scripts/update_readme_snippets.py --check # `scripts/docs/build.sh` is the whole gauntlet: build_config.py fails on # nav entries without a page and pages without a nav entry, `zensical build diff --git a/AGENTS.md b/AGENTS.md index 2812ed6d17..1678b57136 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,10 @@ dependencies and obscure circular-import bugs. Only exception: when a top-level import genuinely can't work (lazy-loading optional deps, or tests that re-import a module). +- Always pass `encoding=` (normally `"utf-8"`) to text-mode `open()`, + `Path.read_text()`/`write_text()`, `tempfile` and `subprocess` text pipes: the + default is the process locale, not UTF-8. CI enforces this via + `PYTHONWARNDEFAULTENCODING=1` (PEP 597) under pytest's `error` filter, plus ruff `PLW1514`. ## Testing diff --git a/docs_src/uri_templates/tutorial002.py b/docs_src/uri_templates/tutorial002.py index 2ad1ec7c1e..3d0dc5c36b 100644 --- a/docs_src/uri_templates/tutorial002.py +++ b/docs_src/uri_templates/tutorial002.py @@ -11,4 +11,4 @@ @mcp.resource("manuals://{+path}") def read_manual(path: str) -> str: """A staff manual page, served from a directory on disk.""" - return safe_join(DOCS_ROOT, path).read_text() + return safe_join(DOCS_ROOT, path).read_text(encoding="utf-8") diff --git a/examples/clients/simple-chatbot/mcp_simple_chatbot/main.py b/examples/clients/simple-chatbot/mcp_simple_chatbot/main.py index 57670dc17d..e8e50fc920 100644 --- a/examples/clients/simple-chatbot/mcp_simple_chatbot/main.py +++ b/examples/clients/simple-chatbot/mcp_simple_chatbot/main.py @@ -44,7 +44,7 @@ def load_config(file_path: str) -> dict[str, Any]: FileNotFoundError: If configuration file doesn't exist. JSONDecodeError: If configuration file is invalid JSON. """ - with open(file_path, "r") as f: + with open(file_path, encoding="utf-8") as f: return json.load(f) @property diff --git a/examples/stories/_harness.py b/examples/stories/_harness.py index 9501fc065a..700d2b57c1 100644 --- a/examples/stories/_harness.py +++ b/examples/stories/_harness.py @@ -123,7 +123,7 @@ async def _self_hosted(name: str, cfg: dict[str, Any]) -> AsyncIterator[str]: def _story_cfg(name: str) -> dict[str, Any]: """The manifest entry for the story ``name`` with ``[defaults]`` applied.""" - manifest: dict[str, Any] = tomllib.loads((Path(__file__).parent / "manifest.toml").read_text()) + manifest: dict[str, Any] = tomllib.loads((Path(__file__).parent / "manifest.toml").read_text(encoding="utf-8")) return manifest["defaults"] | manifest["story"].get(name, {}) diff --git a/pyproject.toml b/pyproject.toml index 3c814106d1..4b1cf88761 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -195,6 +195,10 @@ line-length = 120 target-version = "py310" [tool.ruff.lint] +# preview + explicit-preview-rules enables exactly one preview rule: PLW1514 (text I/O without encoding=). +preview = true +explicit-preview-rules = true +extend-select = ["PLW1514"] select = [ "C4", # flake8-comprehensions "C90", # mccabe @@ -266,6 +270,9 @@ filterwarnings = [ # 2026-07-28 drops ping; Client.send_ping() is advisory-deprecated and the # legacy interaction/transport tests still drive it. "ignore:ping is removed as of 2026-07-28.*:mcp.MCPDeprecationWarning", + # CI and scripts/test set PYTHONWARNDEFAULTENCODING=1, so "error" rejects any text I/O + # of ours that omits encoding=; pytest-examples' own Popen(universal_newlines=True) isn't ours. + "ignore:'encoding' argument not specified:EncodingWarning:pytest_examples", ] [tool.markdown.lint] diff --git a/scripts/gen_surface_types.py b/scripts/gen_surface_types.py index ab8be15cf3..cc1e840c42 100644 --- a/scripts/gen_surface_types.py +++ b/scripts/gen_surface_types.py @@ -124,7 +124,7 @@ def load_pinned() -> list[dict[str, str]]: """Read `schema/PINNED.json` and verify each vendored file's sha256.""" - entries: list[dict[str, str]] = json.loads((SCHEMA_DIR / "PINNED.json").read_text()) + entries: list[dict[str, str]] = json.loads((SCHEMA_DIR / "PINNED.json").read_text(encoding="utf-8")) for entry in entries: path = SCHEMA_DIR / f"{entry['protocol_version']}.json" actual = hashlib.sha256(path.read_bytes()).hexdigest() @@ -189,7 +189,7 @@ def run_codegen(schema_path: Path, output_path: Path) -> None: "--type-mappings", "byte=string", "uri=string", "uri-template=string", "--disable-timestamp", ], - capture_output=True, text=True, + capture_output=True, encoding="utf-8", errors="replace", ) # fmt: on if result.returncode != 0: @@ -222,16 +222,16 @@ def patch(match: re.Match[str]) -> str: def build(entry: dict[str, str]) -> str: """Generate, post-process, and format one version's surface module text.""" version = entry["protocol_version"] - schema = json.loads((SCHEMA_DIR / f"{version}.json").read_text()) + schema = json.loads((SCHEMA_DIR / f"{version}.json").read_text(encoding="utf-8")) patch_schema(schema, SCHEMA_PATCHES.get(version, [])) make_server_info_opaque(schema) with tempfile.TemporaryDirectory() as tmp: patched = Path(tmp) / "schema.json" - patched.write_text(json.dumps(schema)) + patched.write_text(json.dumps(schema), encoding="utf-8") raw = Path(tmp) / "raw.py" run_codegen(patched, raw) - source = raw.read_text() + source = raw.read_text(encoding="utf-8") source = re.sub(r"\A# generated by datamodel-codegen:\n#[^\n]*\n", "", source) 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: staging = TYPES_DIR / f"_staging_{version}.py" try: - staging.write_text(source) + staging.write_text(source, encoding="utf-8") subprocess.run( ["uv", "run", "--frozen", "ruff", "format", "--no-cache", str(staging)], cwd=REPO_ROOT, capture_output=True, check=True, ) # fmt: skip - return staging.read_text() + return staging.read_text(encoding="utf-8") finally: staging.unlink(missing_ok=True) @@ -275,10 +275,10 @@ def main(argv: list[str] | None = None) -> int: candidate = build(entry) if not args.check: target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(candidate) + target.write_text(candidate, encoding="utf-8") print(f"{entry['protocol_version']}: wrote {target.relative_to(REPO_ROOT)} ({len(candidate)} bytes)") continue - committed = target.read_text() if target.is_file() else "" + committed = target.read_text(encoding="utf-8") if target.is_file() else "" if committed != candidate: drift = True sys.stderr.writelines( diff --git a/scripts/test b/scripts/test index dc43f351dd..1b6e6633b1 100755 --- a/scripts/test +++ b/scripts/test @@ -3,7 +3,8 @@ set -ex uv run --frozen coverage erase -uv run --frozen coverage run -m pytest -n auto $@ +# PYTHONWARNDEFAULTENCODING=1 mirrors CI: text I/O without encoding= fails under pytest's "error" filter. +PYTHONWARNDEFAULTENCODING=1 uv run --frozen coverage run -m pytest -n auto $@ uv run --frozen coverage combine uv run --frozen coverage report # strict-no-cover spawns `uv run coverage json` internally without --frozen; diff --git a/scripts/update_readme_snippets.py b/scripts/update_readme_snippets.py index 99e9237a4c..c5cec55913 100755 --- a/scripts/update_readme_snippets.py +++ b/scripts/update_readme_snippets.py @@ -51,7 +51,7 @@ def process_snippet_block(match: re.Match[str], check_mode: bool = False) -> str if not file.exists(): sys.exit(f"Error: snippet-source file not found: {file_path}") - code = file.read_text().rstrip() + code = file.read_text(encoding="utf-8").rstrip() github_url = get_github_url(file_path) # Build the replacement block @@ -107,7 +107,7 @@ def update_readme_snippets(check_mode: bool = False) -> bool: print(f"Error: README file not found: {readme_path}") return False - content = readme_path.read_text() + content = readme_path.read_text(encoding="utf-8") original_content = content # Pattern to match snippet-source blocks @@ -129,14 +129,14 @@ def update_readme_snippets(check_mode: bool = False) -> bool: ) return False else: - print(f"✓ {readme_path} code snippets are up to date") + print(f"{readme_path} code snippets are up to date") return True else: if updated_content != original_content: - readme_path.write_text(updated_content) - print(f"✓ Updated {readme_path}") + readme_path.write_text(updated_content, encoding="utf-8") + print(f"Updated {readme_path}") else: - print(f"✓ {readme_path} already up to date") + print(f"{readme_path} already up to date") return True diff --git a/src/mcp/cli/claude.py b/src/mcp/cli/claude.py index e65379682a..dc0136503c 100644 --- a/src/mcp/cli/claude.py +++ b/src/mcp/cli/claude.py @@ -92,7 +92,7 @@ def update_claude_config( config_file = config_dir / "claude_desktop_config.json" if not config_file.exists(): # pragma: lax no cover try: - config_file.write_text("{}") + config_file.write_text("{}", encoding="utf-8") except Exception: logger.exception( "Failed to create Claude config file", @@ -103,7 +103,7 @@ def update_claude_config( return False try: - config = json.loads(config_file.read_text()) + config = json.loads(config_file.read_text(encoding="utf-8")) if "mcpServers" not in config: config["mcpServers"] = {} @@ -154,7 +154,7 @@ def update_claude_config( config["mcpServers"][server_name] = server_config - config_file.write_text(json.dumps(config, indent=2)) + config_file.write_text(json.dumps(config, indent=2), encoding="utf-8") logger.info( f"Added server '{server_name}' to Claude config", extra={"config_file": str(config_file)}, diff --git a/tests/cli/test_claude.py b/tests/cli/test_claude.py index d0a74e0d00..7b9bb42b10 100644 --- a/tests/cli/test_claude.py +++ b/tests/cli/test_claude.py @@ -64,7 +64,7 @@ def raise_not_found(distribution_name: str) -> str: 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,16 @@ 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 def test_raises_when_config_dir_missing(monkeypatch: pytest.MonkeyPatch): diff --git a/tests/cli/test_utils.py b/tests/cli/test_utils.py index d217d82fc7..309e5035cd 100644 --- a/tests/cli/test_utils.py +++ b/tests/cli/test_utils.py @@ -28,7 +28,7 @@ def fake_version(distribution_name: str) -> str: 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 diff --git a/tests/docs_src/test_apps.py b/tests/docs_src/test_apps.py index 8b692fcc95..e91d12a3ea 100644 --- a/tests/docs_src/test_apps.py +++ b/tests/docs_src/test_apps.py @@ -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") diff --git a/tests/docs_src/test_uri_templates.py b/tests/docs_src/test_uri_templates.py index 4b2b6edaf1..6abe60512d 100644 --- a/tests/docs_src/test_uri_templates.py +++ b/tests/docs_src/test_uri_templates.py @@ -131,7 +131,7 @@ async def test_safe_join_serves_a_file_inside_the_base_directory( ) -> None: """tutorial002: `safe_join(DOCS_ROOT, path).read_text()` 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 diff --git a/tests/examples/conftest.py b/tests/examples/conftest.py index 8874654eda..858a2bd330 100644 --- a/tests/examples/conftest.py +++ b/tests/examples/conftest.py @@ -38,7 +38,7 @@ STORIES_DIR = Path(stories.__file__).parent BASE_URL = "http://127.0.0.1:8000" -MANIFEST = tomllib.loads((STORIES_DIR / "manifest.toml").read_text()) +MANIFEST = tomllib.loads((STORIES_DIR / "manifest.toml").read_text(encoding="utf-8")) DEFAULTS: dict[str, Any] = MANIFEST["defaults"] STORIES: dict[str, dict[str, Any]] = MANIFEST["story"] diff --git a/tests/examples/test_story_shape.py b/tests/examples/test_story_shape.py index d5510923c9..e46fe2120a 100644 --- a/tests/examples/test_story_shape.py +++ b/tests/examples/test_story_shape.py @@ -27,7 +27,7 @@ def _parse(path: Path) -> ast.Module: """Parse ``path`` into an AST module.""" - return ast.parse(path.read_text(), filename=str(path)) + return ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) def _resolve(node: ast.ImportFrom, package: str) -> str: diff --git a/tests/interaction/transports/test_stdio.py b/tests/interaction/transports/test_stdio.py index 8fd21ab426..9066cd9fb5 100644 --- a/tests/interaction/transports/test_stdio.py +++ b/tests/interaction/transports/test_stdio.py @@ -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") as errlog: transport = stdio_client( StdioServerParameters( command=sys.executable, diff --git a/tests/server/mcpserver/resources/test_file_resources.py b/tests/server/mcpserver/resources/test_file_resources.py index 042ea422aa..db9f73e935 100644 --- a/tests/server/mcpserver/resources/test_file_resources.py +++ b/tests/server/mcpserver/resources/test_file_resources.py @@ -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 diff --git a/tests/server/mcpserver/servers/test_file_server.py b/tests/server/mcpserver/servers/test_file_server.py index e06d0bfe2e..77ef93d0ff 100644 --- a/tests/server/mcpserver/servers/test_file_server.py +++ b/tests/server/mcpserver/servers/test_file_server.py @@ -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 list_test_dir() -> list[str]: 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 read_example_py() -> str: 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 read_readme_md() -> str: 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" diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index 48e900dcab..bc3fb14918 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -819,7 +819,7 @@ async def test_file_resource_text(self, tmp_path: Path): # 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) diff --git a/tests/test_types.py b/tests/test_types.py index e338280507..03cc5a7f9a 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -497,7 +497,8 @@ def test_bare_import_mcp_binds_the_types_submodule(): result = subprocess.run( [sys.executable, "-c", "import mcp; print(mcp.types.Tool.__name__)"], capture_output=True, - text=True, + encoding="utf-8", + errors="replace", check=False, timeout=20, )