test: read setup.cfg as utf-8 in the ci-surface testpaths guard - #9243
test: read setup.cfg as utf-8 in the ci-surface testpaths guard#9243leonlaiyc wants to merge 1 commit into
Conversation
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. |
Design Review (Fable 5, fork) — ✅ PASSDesign-level review of Verified against the base tree: Design-Verdict: PASS Root-cause one-argument fix for a verified host-conditioned decode, with a premise guard that keeps it honest on runners that can't reproduce it; proportionate scope. [DESIGN-REVIEWED] 19a19b2 |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed |
|
CI attribution for the red on Causal chain, read rather than inferred:
This branch cannot reach it. The diff is one file, Upstream has already identified this exact failure.
Its diagnosis: the bind helper caught only Two things follow, and the second matters more than the first:
I am therefore not rebasing and not pushing. A rebase here would churn the SHA, discard four clean exact-head reviews (GPT 5.6, Opus 4.8 and Design all report no blocking findings on |
`ConfigParser.read` opens with no encoding, so it decodes with
`locale.getpreferredencoding()` -- UTF-8 on POSIX, the legacy ANSI code
page on Windows. `setup.cfg` carries 48 bytes of non-ASCII (em dashes in
its comments), so on a Windows host whose code page is not UTF-8 the read
raises before the test reaches its own assertion:
UnicodeDecodeError: 'cp950' codec can't decode byte 0xe2 in
position 514: illegal multibyte sequence
Two effects. The suite cannot run clean on such a host, and -- worse --
`test_backend_roots_cover_every_configured_testpath` is the guard that
every `setup.cfg` testpath is a scanned selector root, i.e. the guard
against tests silently never running. On the affected hosts it is itself
silently not running.
Same failure class `scripts/check_subprocess_encoding.py` was added for
(kirodotdev#3219, kirodotdev#3669, kirodotdev#5249), reached through a file read rather than a
subprocess pipe, which that gate does not cover.
Pins the encoding at the read, and adds a test asserting the two facts
that make it load-bearing -- setup.cfg really carries non-ASCII, and those
bytes really are undecodable under a legacy code page -- because a UTF-8
CI runner passes either way and cannot exercise the bug itself.
Deliberately not a repo-wide missing-encoding rule: cross-platform.yml
documents why it ships none, and an AST version means editing
.github/workflows/**. The fixture string at lines 280-282 that spells the
same call is left byte-identical; it is written to a temp file for the
surface selector to match, never executed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7b0db92 to
19a19b2
Compare
|
Correcting my previous comment: the red was a real defect, not an intermittent, and a rebase does clear it. Rebased to I anchored that attribution on
The mechanism: a lock file opened Timeline, which is what makes it a stale base rather than a live defect here:
What stands from the previous comment: the failure is still not branch-caused. This diff is one file, Action taken: rebased onto |
|
Post-rebase CI on The rebase did what it was meant to. What fails now on This PR's entire diff is one file, The decisive evidence is that the identical assertion fails on my #9351, whose diff is disjoint from this one (two kwargs swaps in No product SHA churn for this one, and no rerun requested: a rerun cannot fix an assertion that fails for both branches on the same base. |
Problem / Motivation
test/test_ci_surface_tests.pyreadssetup.cfgthroughconfigparserwithoutnaming an encoding:
ConfigParser.readopens with no encoding, so the file is decoded withlocale.getpreferredencoding()— UTF-8 on POSIX, but the legacy ANSI code pageon Windows.
setup.cfgcontains non-ASCII: 48 bytes of UTF-8 em dashes in itscomments. On a Windows host whose code page is not UTF-8 — any CJK install — that
decode raises.
Measured on
origin/main6598107cb, unmodified, on acp950host:Two consequences, and the second is the one that matters:
python -m pytestasAGENTS.mdprescribes.that every
setup.cfgtestpathsentry is a scanned selector root — a testunder an unenumerated root is not "unclassified but still running", it never
runs at all. The test dies at the read, before it reaches its own assertion, so
on that host it protects nothing while still reporting as a failure for an
unrelated-looking reason.
This is the same failure class the repository already guards elsewhere:
scripts/check_subprocess_encoding.pyexists precisely becauselocale.getpreferredencodingdecoding produced mojibake andUnicodeDecodeErrorfor users (issue #3219, sites fixed in #3669, gate added in #5249). That gate
covers subprocess pipes. This is the same decode reached through a file read,
which it does not cover.
Why it matters
The testing contract in
AGENTS.mdispython -m pytest, unqualified. Acontributor on a CJK Windows box currently gets a red suite out of the box, from a
UnicodeDecodeErrorthat names neither the real cause nor anything they changed —the kind of failure that reads as "the repo is broken here" rather than "one read
is missing an argument".
The silent half is worse than the noisy half.
test_backend_roots_cover_every_configured_testpathis a guard against tests silently never running. On the affected hosts it is
itself silently not running, so the guard and the thing it guards fail together.
What changed (motivation → approach → change)
Root cause: a decode that inherits the host code page for a file that is
committed as UTF-8.
Approach: pin the encoding at the read, and pin the two facts that make it
load-bearing so the guard cannot rot on the machines that never feel it.
parser.read(_REPO_ROOT / "setup.cfg", encoding="utf-8")— the file isUTF-8 in the repository, so it is read as UTF-8 everywhere. One argument, with
a comment recording the failure class and pointing at the existing subprocess
gate so the next reader does not have to rediscover why it is there.
test_setup_cfg_carries_non_ascii_so_its_read_must_pin_utf8— asserts thefile really does carry non-ASCII, that those bytes really are undecodable under
a legacy code page, and that the pinned read works. All three hold on every
host, including the UTF-8 runners that cannot exercise the bug itself.
Deliberately NOT a repo-wide "missing
encoding=" rule..github/workflows/cross-platform.ymlships no such rule and documents why: aline regex breaks on nested calls (
write_text(json.dumps(x), encoding=...)) andon multi-line calls under
--unified=0. An AST version would not have thoselimits, but it means editing
.github/workflows/**, which turns a one-argumentportability fix into a workflow-security change. This stays the size of the
defect; the broader gate is a separate decision for a maintainer.
Scope boundary, stated because the file makes it look tempting. Lines 280–282
of this module contain the string
'PARSER.read(ROOT / "setup.cfg")\n'. That is afixture written to a temp file so the surface selector can pattern-match it —
it is never executed, and editing it would change what
test_own_surface_config_references_stay_single_surfaceasserts. It isintentionally left byte-identical.
Tests
Red-before, on unmodified
origin/main6598107cb:Green-after, same host, same command: 2 passed (the repaired test plus the new
guard). Whole module: 45 passed, 1 failed — and that one failure,
test_explicit_cli_target_bypasses_collect_ignore(
AssertionError: recursive collection should honour collect_ignore), reproducesidentically on pristine
origin/mainwith this branch's changes reverted, so itis inherited by this environment and not attributable to this diff.
The honest limit of the red-before: the defect is host-conditioned. On a UTF-8
runner the repaired test passes before and after, because
locale.getpreferredencoding()already returns UTF-8 there. CI therefore cannotshow this one going red. That is exactly why the second test exists: its three
assertions — non-ASCII present, undecodable under
cp950, decodable as UTF-8 —hold on every host, so if
setup.cfgever becomes pure ASCII the guard failsloudly and can be retired deliberately rather than quietly becoming decoration.
The
cp950decode is asserted withpytest.raises(UnicodeDecodeError)ratherthan assumed, so the guard cannot pass vacuously on a build where those bytes
turned out to be decodable.
Gates:
flake8andisort --check-onlyclean on the changed file.scripts/check_black_formatting.pypasses — the file is in the repository's blackbaseline (its base revision is already unformatted), so it is deliberately not
reformatted; running
blackon it would bury a 42-line diff under an unrelatedrewrite.
Manual verification
N/A — unit coverage sufficient: the defect reproduces and is fixed under
python -m pyteston the affected host, which is the same command a contributorwould run by hand.
Related Issues
None. Found while auditing
locale.getpreferredencodingdecode sites for thefailure class
scripts/check_subprocess_encoding.pyguards (#3219 / #3669 /#5249), for the case where that decode is reached through a file read rather than
a subprocess pipe.
File-level overlap, disclosed: open PR #9223 (
feat(aws-control): the crew container image and its runtime) also edits this file. The hunks are disjoint —#9223 touches the imports, a reformat around line 318, and
test_explicit_cli_target_bypasses_collect_ignorearound line 438; this PRtouches the
setup.cfgread around line 134 and adds a test beside it. Whicheverlands second should need only a line-offset rebase. Worth noting that #9223's own
hunk at line 318 already reads with
read_text(encoding="utf-8"), which is thesame discipline this PR applies to the one site in the module that still lacked
it.
Pattern harvest
Rule candidate:
review-promptPattern: a missing
encoding=is only latent until you can name a writer thatemits non-ASCII. Most of this repository's unpinned reads are genuinely harmless
because
json.dumpsdefaults toensure_ascii=True, so the bytes on disk areASCII whatever the code page. The way to tell a real one from a theoretical one is
not to count call sites — it is to open the target file and look for a byte above
0x7F. Here there were 48 of them, committed, insetup.cfg.Second lesson, about where these hide: a test that dies before its assertion is
a guard that is not running. The visible symptom was a red suite; the invisible
one was that a contract test against "tests that silently never run" was itself
silently not running. When triaging a decode error inside a test, ask what that
test was supposed to be checking before deciding the failure is cosmetic.
Third, for review prompts: an existing gate marks a class the project has
already agreed is real, and its scope boundary is where the next bug lives. The
subprocess encoding gate exists because this exact decode reached users. Its
edge — file reads — is where this one was.
Checklist
feat|fix|docs|style|refactor|perf|test|chore|ci|build|revert: ...)Contribution License Agreement
🤖 Generated with Claude Code