Files
西风逍遥游 31880f0021 feat(examples): gate examples in CI and make repository scripts UTF-8 on every host
`examples/<name>/` was a tested surface in name only. The source-quality job had
no examples check at all, and scripts/smoke_examples.sh accepted any process that
had not printed one of its error markers: a project that died during startup,
exited after a single frame, or crashed without a marker was reported as PASS.
Nothing stopped a malformed or unregistered example from reaching dev, which is
how 14 examples arrived without READMEs and 20 were never registered in the
overview table.

Source side
- scripts/check_examples.py classifies every directory under examples/ as either
  runnable (main.nut plus a config.nut that assigns the engine `config` table) or
  as a reviewed non-runnable sample listed in NON_RUNNABLE_EXAMPLES with a
  reason. Both kinds must own a README.md and appear in examples/README.md.
  Stale exemptions and overview links to missing directories fail as well.
- `make check/examples` and a new source-quality CI step run the gate.
- scripts/tests/test_check_examples.py covers it and pins each failure mode
  against fixtures.

Runtime side
- scripts/smoke_examples.sh now enforces liveness, not just marker absence: every
  runnable example must still be running after MIN_RUN_SECONDS (default 2s; CI
  passes 2s), and a process that dies inside that window fails whatever its exit
  code. A clean self-exit after the window passes with an explicit note instead
  of being silently accepted as a pass.
- The Linux CI step sets MIN_RUN_SECONDS=2 and keeps RUN_SECONDS=6, so the job's
  wall clock is unchanged (13.7 min of its 20 min budget on dev).

Windows / GBK
- scripts/utf8_stdio.py re-encodes stdout/stderr as UTF-8 and every scripts/*.py
  program calls it as the first statement of its __main__ block. On a Chinese
  Windows a redirected stream falls back to the ANSI code page (cp936), and
  printing a character that code page cannot encode (arrows, superscripts, bullet
  separators, emoji - all common in this repository) raised UnicodeEncodeError
  and turned a checker into a traceback.
- The mirror image is input: read_text() without encoding= decoded UTF-8
  repository text as cp936 and raised UnicodeDecodeError.
- scripts/tests/test_utf8_stdio.py enforces both conventions for every script and
  test module; text file I/O and child-process decoding now pass encoding="utf-8"
  explicitly.
- Unit-test invocations run with `python3 -X utf8` so the runner's own output
  never follows the host code page.

Violations the new gates found on top of dev, fixed here: 14 examples shipped
without a README (fluid3d, mesh-impact-lab, mesh-modifier-lab, spline-tube-lab on
the earlier dev tip, plus pcg-depth-of-field, pcg-detail-overwrite,
pcg-layer-culling, pcg-location-system, pcg-runtime-orchestration,
pcg-scene-player-culling, pcg-snow-wind, pcg-thunder-strike, pcg-ui-scaler and
pcg-underwater added by #397), 20 were missing from the overview table (those 14
plus archspace-editor, geometry-stroke-lab, interior-mapping, layered-animation,
triplanar-decal, terrain-stamping), and scripts/export_meshret_onnx.py had no
UTF-8 stdio guard.

Verified locally: `python -m ruff check scripts` (All checks passed);
scripts/check_test_manifest.py; scripts/check_quality_metadata.py;
scripts/check_architecture_contracts.py --base HEAD; scripts/check_nodiscard.py;
`make -n check/examples`; scripts/check_examples.py (125 examples: 118 runnable,
7 reviewed non-runnable); and `python -X utf8 -m unittest discover -s scripts/tests
-p "test_*.py"` (155 tests, OK). The runtime half of the contract needs a Linux
headless build, so it is verified by the CI examples smoke step rather than
locally.
2026-09-15 20:20:23 +08:00

293 lines
11 KiB
Python

"""Tests for scripts/utf8_stdio.py and the convention it enforces.
The failure being guarded is Windows-local and depends on redirection: CI runs
the checkers on Linux where stdout is UTF-8, but on a Windows host a *piped*
stdout is encoded with the ANSI code page from the locale -- cp936 (GBK) on a
Chinese Windows. A checker that prints text read back out of the tree (an
example README title containing "↔", for instance) then dies with
``UnicodeEncodeError`` instead of reporting its verdict.
Two halves are covered here:
* the helper's behaviour, including a real child process running under a forced
CP936 stdio encoding, and
* the repository conventions -- every ``scripts/*.py`` program (a module with a
``__main__`` guard) calls the helper, every module that is *not* a program
keeps its import side-effect free by not printing at all, and no script or
script test inherits the host locale for text file I/O. That last rule is the
input-side twin of the stdio problem: ``Path.read_text()`` without
``encoding=`` decodes UTF-8 repository text as CP936 on a Chinese Windows and
raises ``UnicodeDecodeError``.
The test-runner side (``python3 -m unittest``) is covered by the ``-X utf8``
flag on the Makefile/CI invocations, because a test module is imported by the
runner rather than being its own entry point.
"""
from __future__ import annotations
import ast
import codecs
import os
import subprocess
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
SCRIPTS = ROOT / "scripts"
sys.path.insert(0, str(SCRIPTS))
import utf8_stdio # noqa: E402
# Text that CP936 cannot encode, so the encoding in effect is observable.
ARROW_TEXT = "2D \u2194 2.5D"
GBK = "cp936"
def have_gbk() -> bool:
try:
codecs.lookup(GBK)
except LookupError:
return False
return True
def run_child(source: str) -> subprocess.CompletedProcess[bytes]:
"""Run ``source`` in a child interpreter whose stdio encoding is CP936."""
env = dict(os.environ)
env["PYTHONIOENCODING"] = GBK
env.pop("PYTHONUTF8", None)
parts = [str(SCRIPTS), env.get("PYTHONPATH", "")]
env["PYTHONPATH"] = os.pathsep.join(part for part in parts if part)
return subprocess.run(
[sys.executable, "-c", source],
capture_output=True,
cwd=str(ROOT),
env=env,
check=False,
)
def module_guard(tree: ast.Module) -> ast.If | None:
"""The top-level ``if __name__ == "__main__":`` block, when present."""
for node in tree.body:
if isinstance(node, ast.If) and "__main__" in ast.dump(node.test):
return node
return None
def calls_enable_utf8(node: ast.AST) -> bool:
"""True when ``node`` contains a call to ``<encoding module>.enable_utf8_stdio()``."""
for child in ast.walk(node):
if not isinstance(child, ast.Call):
continue
func = child.func
if isinstance(func, ast.Attribute) and func.attr == "enable_utf8_stdio":
return True
return False
def prints_or_writes_streams(node: ast.AST) -> list[int]:
"""Line numbers of ``print(...)`` / ``sys.stdout`` / ``sys.stderr`` uses."""
lines: list[int] = []
for child in ast.walk(node):
if isinstance(child, ast.Call) and isinstance(child.func, ast.Name):
if child.func.id == "print":
lines.append(child.lineno)
elif isinstance(child, ast.Attribute) and child.attr in {"stdout", "stderr"}:
if isinstance(child.value, ast.Name) and child.value.id == "sys":
lines.append(child.lineno)
return sorted(set(lines))
def is_binary_open(node: ast.Call) -> bool:
"""True when an ``open(...)`` call explicitly asks for a binary mode."""
mode = ""
for index, arg in enumerate(node.args):
if index == 1 and isinstance(arg, ast.Constant) and isinstance(arg.value, str):
mode = arg.value
for keyword in node.keywords:
if keyword.arg == "mode" and isinstance(keyword.value, ast.Constant):
mode = str(keyword.value.value)
return any(char in mode for char in "bx")
def text_io_without_encoding(tree: ast.Module) -> list[str]:
"""Text file I/O that would inherit the host locale encoding.
``Image.open`` and ``ZipFile.open`` share the name ``open`` but are not text
file I/O, so attribute calls are ignored; a module-local ``read_text`` helper
is not ``Path.read_text`` and is excluded as well.
"""
local = {
node.name
for node in tree.body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
}
found: list[str] = []
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
if any(keyword.arg == "encoding" for keyword in node.keywords):
continue
func = node.func
if isinstance(func, ast.Attribute) and func.attr in {"read_text", "write_text"}:
if func.attr not in local:
found.append(f"{node.lineno}: {func.attr}() without encoding")
elif isinstance(func, ast.Name) and func.id == "open" and func.id not in local:
if not is_binary_open(node):
found.append(f"{node.lineno}: open() without encoding")
return sorted(found)
class HelperBehaviourTests(unittest.TestCase):
def test_piped_output_is_utf8_under_a_cp936_locale(self):
"""With the guard, text CP936 cannot encode survives a CP936 stdout."""
if not have_gbk():
self.skipTest(f"{GBK} codec unavailable")
source = (
"import utf8_stdio\n"
"utf8_stdio.enable_utf8_stdio()\n"
f"print({ARROW_TEXT!r})\n"
)
result = run_child(source)
self.assertEqual(0, result.returncode, result.stderr.decode("utf-8", "replace"))
self.assertEqual(ARROW_TEXT, result.stdout.decode("utf-8").strip())
def test_without_the_guard_the_same_line_aborts(self):
"""Pins the motivation: the un-guarded child dies instead of printing."""
if not have_gbk():
self.skipTest(f"{GBK} codec unavailable")
result = run_child(f"print({ARROW_TEXT!r})\n")
self.assertNotEqual(0, result.returncode)
self.assertIn(b"UnicodeEncodeError", result.stderr)
def test_stderr_is_covered_too(self):
if not have_gbk():
self.skipTest(f"{GBK} codec unavailable")
source = (
"import sys\n"
"import utf8_stdio\n"
"utf8_stdio.enable_utf8_stdio()\n"
"print('error: ' + %r, file=sys.stderr)\n" % ARROW_TEXT
)
result = run_child(source)
self.assertEqual(0, result.returncode, result.stderr.decode("utf-8", "replace"))
self.assertIn(ARROW_TEXT, result.stderr.decode("utf-8"))
def test_enable_is_idempotent(self):
utf8_stdio.enable_utf8_stdio()
utf8_stdio.enable_utf8_stdio()
encoding = (sys.stdout.encoding or "").lower().replace("_", "-")
self.assertTrue(encoding.startswith("utf-8"), encoding)
def test_plain_streams_without_reconfigure_are_ignored(self):
class Plain:
def write(self, text):
return len(text)
original_out, original_err = sys.stdout, sys.stderr
try:
sys.stdout = Plain() # type: ignore[assignment]
sys.stderr = Plain() # type: ignore[assignment]
utf8_stdio.enable_utf8_stdio()
finally:
sys.stdout, sys.stderr = original_out, original_err
def test_a_reconfigure_that_fails_is_not_fatal(self):
class Detached:
def reconfigure(self, **kwargs):
raise ValueError("underlying buffer has been detached")
original = sys.stdout
try:
sys.stdout = Detached() # type: ignore[assignment]
utf8_stdio.enable_utf8_stdio()
finally:
sys.stdout = original
class RepositoryConventionTests(unittest.TestCase):
"""scripts/*.py programs must guard their own stdio, and no Python code under
scripts/ may inherit the host locale for text file I/O."""
def script_paths(self) -> list[Path]:
return [
path
for path in sorted(SCRIPTS.glob("*.py"))
if path.name != "utf8_stdio.py"
]
def python_paths(self) -> list[Path]:
"""Scripts plus their tests: every module that touches repository text."""
return self.script_paths() + sorted((SCRIPTS / "tests").glob("*.py"))
def test_no_module_inherits_the_locale_for_text_io(self):
offenders: list[str] = []
for path in self.python_paths():
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
for problem in text_io_without_encoding(tree):
offenders.append(f"{path.name} -> {problem}")
self.assertEqual(
[],
offenders,
"text file I/O must pass encoding='utf-8': on a Chinese Windows the "
"locale default is cp936, which mangles or rejects UTF-8 repository "
"text (an unencoded Path.read_text() there broke three tests in "
"scripts/resource_format_suite.py)",
)
def test_every_script_program_enables_utf8_stdio(self):
missing: list[str] = []
for path in self.script_paths():
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
guard = module_guard(tree)
if guard is None:
continue
if not calls_enable_utf8(guard):
missing.append(path.name)
self.assertEqual(
[],
missing,
"these scripts/*.py programs must call "
"utf8_stdio.enable_utf8_stdio() in their __main__ block",
)
def test_the_guard_is_not_installed_as_an_import_side_effect(self):
offenders: list[str] = []
for path in self.script_paths():
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
for node in tree.body:
if node is module_guard(tree):
continue
if calls_enable_utf8(node):
offenders.append(path.name)
self.assertEqual(
[],
offenders,
"calling utf8_stdio.enable_utf8_stdio() at import time would make "
"importing a checker module reconfigure its importer's stdio",
)
def test_modules_without_an_entry_point_do_not_print(self):
"""The exemption for non-program modules has to stay justified."""
offenders: list[str] = []
for path in self.script_paths():
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
if module_guard(tree) is not None:
continue
lines = prints_or_writes_streams(tree)
if lines:
offenders.append(f"{path.name}:{lines[0]}")
self.assertEqual(
[],
offenders,
"a scripts/*.py module that writes to stdout/stderr needs a "
"__main__ guard plus utf8_stdio.enable_utf8_stdio()",
)
if __name__ == "__main__":
unittest.main()