ascii: make the fallback reachable, and the clone survive the gateway
Browse filesTWO UNRELATED THINGS THE LAST RED PIPELINE TAUGHT, one of which was not a
bug in this repository at all.
THE PIPELINE DID NOT FAIL ON A TEST. Job 16530892473 never ran a line of
script: the runner could not reach GitLab's own CI gateway, spent 130 seconds
on a connect timeout, and git exited 128. The API reports that as
`script_failure`, which is how it read as a code regression at the exact commit
that reworked the banner -- it was a coincidence, and `ascii` was simply the
job that drew the short straw. GET_SOURCES_ATTEMPTS makes the runner retry the
fetch, which is the narrow fix; `retry:when: script_failure` would also re-run
genuinely failing tests until they passed once.
THE FALLBACK WAS NEVER EXERCISED. Chasing the above turned up the real defect.
_enable_utf8 calls stdout.reconfigure(encoding="utf-8"), which SUCCEEDS on a
pipe whatever PYTHONIOENCODING asked for -- so UNICODE was always True, the
`ascii` job spent its whole life on the UTF-8 branch it was written to avoid,
and it would have passed with BANNER_ASCII deleted. Confirmed by running it in
python:3.11-slim: the blocks printed as blocks under ascii:replace instead of
degrading to `?`. test_env.py already documented this as intended, so it is
a design decision reversed here rather than an oversight caught.
An explicit PYTHONIOENCODING is now read as a statement about what the far end
can take, not a default worth improving on. Only a DECLARED encoding gets that
treatment: a Windows console reporting cp1252 because that is the machine's
code page has declared nothing and is still reconfigured, so the rescue this
function was written for does not become dead code. The probe is GLYPHS, every
non-ASCII character the CLI can print, because a stdout that cannot hold the
tick cannot hold the fallback's promise either.
The job asserts the fallback was REACHED rather than merely survived, by
grepping for the bar only the ASCII spelling draws -- verified to fail when the
encoding is utf-8, so it has teeth. And it declares `ascii`, not
`ascii:replace`: under :replace a glyph escaping the UNICODE gate is rewritten
to `?` and the job stays green while the thing it tests is already broken.
Measured: all four jobs pass in python:3.11-slim, the suite on both matrix
legs, 82% coverage, and the ASCII wordmark now reaches a declared-ascii stdout.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- .gitlab-ci.yml +29 -1
- nexa.py +36 -0
- tests/test_cli.py +27 -0
- tests/test_env.py +89 -5
|
@@ -17,6 +17,18 @@
|
|
| 17 |
|
| 18 |
stages: [corpus, test]
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
default:
|
| 21 |
image: python:3.11-slim
|
| 22 |
interruptible: true
|
|
@@ -92,11 +104,27 @@ ascii:
|
|
| 92 |
TERM: "dumb"
|
| 93 |
# Force the legacy-code-page path. This is what a Windows console stuck on
|
| 94 |
# cp437 hands the banner, and the ASCII fallback exists for exactly it.
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
script:
|
| 97 |
- python nexa.py
|
| 98 |
- python nexa.py help ask
|
| 99 |
- python nexa.py validate
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
# Colour off means no escape sequences anywhere in the output. printf is
|
| 101 |
# used to build the escape because the shell here is dash, which has no
|
| 102 |
# $'...' syntax.
|
|
|
|
| 17 |
|
| 18 |
stages: [corpus, test]
|
| 19 |
|
| 20 |
+
# The clone, not the script. A pipeline was already lost to the runner failing
|
| 21 |
+
# to reach GitLab's own CI gateway -- 130 seconds of connect timeout, git exiting
|
| 22 |
+
# 128, and the job reported as a script_failure that never ran a line of script.
|
| 23 |
+
# The runner retries fetching sources by itself if asked, and this is the narrow
|
| 24 |
+
# way to ask: `retry:when: script_failure` would also silently re-run genuinely
|
| 25 |
+
# failing tests until they passed once.
|
| 26 |
+
#
|
| 27 |
+
# Global rather than under `default:`, which takes no variables, and global
|
| 28 |
+
# rather than per-job, because every job in the file pays the same clone.
|
| 29 |
+
variables:
|
| 30 |
+
GET_SOURCES_ATTEMPTS: 3
|
| 31 |
+
|
| 32 |
default:
|
| 33 |
image: python:3.11-slim
|
| 34 |
interruptible: true
|
|
|
|
| 104 |
TERM: "dumb"
|
| 105 |
# Force the legacy-code-page path. This is what a Windows console stuck on
|
| 106 |
# cp437 hands the banner, and the ASCII fallback exists for exactly it.
|
| 107 |
+
#
|
| 108 |
+
# Strict, not `ascii:replace`. Under :replace a glyph that escaped the
|
| 109 |
+
# UNICODE gate is silently rewritten to `?` and the job stays green while
|
| 110 |
+
# the fallback it is testing is already broken. Strict turns that same
|
| 111 |
+
# glyph into a UnicodeEncodeError and a red job, which is the only version
|
| 112 |
+
# of this that is worth running.
|
| 113 |
+
PYTHONIOENCODING: "ascii"
|
| 114 |
script:
|
| 115 |
- python nexa.py
|
| 116 |
- python nexa.py help ask
|
| 117 |
- python nexa.py validate
|
| 118 |
+
# The fallback has to be REACHED, not merely survived. An explicit
|
| 119 |
+
# PYTHONIOENCODING used to be overridden by stdout.reconfigure, so this job
|
| 120 |
+
# spent its whole life on the UTF-8 branch it was written to avoid and
|
| 121 |
+
# would have passed with the ASCII art deleted. This is the bar the ASCII
|
| 122 |
+
# spelling draws and the Unicode one does not.
|
| 123 |
+
- |
|
| 124 |
+
if ! python nexa.py | grep -qF '########++++++++++............'; then
|
| 125 |
+
echo "the ASCII wordmark did not appear; the fallback was not reached"
|
| 126 |
+
exit 1
|
| 127 |
+
fi
|
| 128 |
# Colour off means no escape sequences anywhere in the output. printf is
|
| 129 |
# used to build the escape because the shell here is dash, which has no
|
| 130 |
# $'...' syntax.
|
|
@@ -38,6 +38,24 @@ ADVISE_MODEL = "arch-advisor"
|
|
| 38 |
|
| 39 |
# --------------------------------------------------------------------- colour
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
def _enable_utf8():
|
| 42 |
"""Windows consoles default to a legacy code page that cannot encode the
|
| 43 |
block-drawing characters in the banner. Reconfigure stdout if possible;
|
|
@@ -46,6 +64,24 @@ def _enable_utf8():
|
|
| 46 |
encoding = (getattr(sys.stdout, "encoding", "") or "").lower()
|
| 47 |
if encoding.replace("-", "") in ("utf8", "utf8mb4"):
|
| 48 |
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
try:
|
| 50 |
sys.stdout.reconfigure(encoding="utf-8")
|
| 51 |
sys.stderr.reconfigure(encoding="utf-8")
|
|
|
|
| 38 |
|
| 39 |
# --------------------------------------------------------------------- colour
|
| 40 |
|
| 41 |
+
# Every non-ASCII character the CLI can print: the wordmark's four blocks and
|
| 42 |
+
# the seven glyphs defined against UNICODE further down. _enable_utf8 probes a
|
| 43 |
+
# declared encoding with these rather than with the banner alone, because a
|
| 44 |
+
# stdout that cannot hold the tick cannot hold the fallback's promise either.
|
| 45 |
+
# A test asserts this string stays a superset of what the two actually use.
|
| 46 |
+
GLYPHS = "█▓▒░─·▸◆◇✓✗"
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _declared_encoding():
|
| 50 |
+
"""The codec named in PYTHONIOENCODING, without its error handler.
|
| 51 |
+
|
| 52 |
+
The variable is `<encoding>[:<errors>]` and either half may be empty, so
|
| 53 |
+
a bare `:replace` declares a handler and no codec and must not be read as
|
| 54 |
+
one.
|
| 55 |
+
"""
|
| 56 |
+
return os.environ.get("PYTHONIOENCODING", "").split(":")[0].strip()
|
| 57 |
+
|
| 58 |
+
|
| 59 |
def _enable_utf8():
|
| 60 |
"""Windows consoles default to a legacy code page that cannot encode the
|
| 61 |
block-drawing characters in the banner. Reconfigure stdout if possible;
|
|
|
|
| 64 |
encoding = (getattr(sys.stdout, "encoding", "") or "").lower()
|
| 65 |
if encoding.replace("-", "") in ("utf8", "utf8mb4"):
|
| 66 |
return True
|
| 67 |
+
# An explicit PYTHONIOENCODING is a statement about what the far end can
|
| 68 |
+
# take, not a default worth improving on. reconfigure() re-encodes a pipe
|
| 69 |
+
# whatever was asked for, so without this check the declaration is silently
|
| 70 |
+
# overridden and the blocks reach a sink that already said it cannot hold
|
| 71 |
+
# them -- and the ASCII branch becomes unreachable from outside a
|
| 72 |
+
# monkeypatch. The CI job that declares ascii to exercise the fallback
|
| 73 |
+
# spent its whole life on the UTF-8 branch it was written to avoid, and
|
| 74 |
+
# would have passed with the ASCII art deleted.
|
| 75 |
+
#
|
| 76 |
+
# Only a DECLARED encoding gets this treatment. A Windows console reporting
|
| 77 |
+
# cp1252 because that is the machine's code page has declared nothing, and
|
| 78 |
+
# is still rescued below.
|
| 79 |
+
declared = _declared_encoding()
|
| 80 |
+
if declared:
|
| 81 |
+
try:
|
| 82 |
+
GLYPHS.encode(declared)
|
| 83 |
+
except (UnicodeEncodeError, LookupError):
|
| 84 |
+
return False
|
| 85 |
try:
|
| 86 |
sys.stdout.reconfigure(encoding="utf-8")
|
| 87 |
sys.stderr.reconfigure(encoding="utf-8")
|
|
@@ -199,6 +199,33 @@ def test_the_ascii_fallback_is_actually_ascii():
|
|
| 199 |
assert nexa.BANNER_ASCII.isascii()
|
| 200 |
|
| 201 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
def _cube(n):
|
| 203 |
"""An xterm-256 cube index as its (r, g, b) coordinates, each 0-5."""
|
| 204 |
n -= 16
|
|
|
|
| 199 |
assert nexa.BANNER_ASCII.isascii()
|
| 200 |
|
| 201 |
|
| 202 |
+
def test_glyphs_lists_every_non_ascii_character_the_module_can_print():
|
| 203 |
+
"""GLYPHS is what _enable_utf8 probes a declared encoding with.
|
| 204 |
+
|
| 205 |
+
A glyph added to the UI and not to GLYPHS makes the probe pass on an
|
| 206 |
+
encoding that cannot hold it, and the character reaches the terminal
|
| 207 |
+
anyway -- the exact crash the fallback exists to prevent, reintroduced one
|
| 208 |
+
character at a time. Asserted against the source rather than against the
|
| 209 |
+
constants, because those are already degraded to ASCII when UNICODE is
|
| 210 |
+
False and would have nothing to say in the run that matters.
|
| 211 |
+
|
| 212 |
+
Comments are excluded. They are never printed, and the ones describing the
|
| 213 |
+
bar name the very characters this is looking for.
|
| 214 |
+
"""
|
| 215 |
+
import io
|
| 216 |
+
import tokenize
|
| 217 |
+
with open(os.path.join(ROOT, "nexa.py"), encoding="utf-8") as handle:
|
| 218 |
+
source = handle.read()
|
| 219 |
+
printable = set()
|
| 220 |
+
for token in tokenize.generate_tokens(io.StringIO(source).readline):
|
| 221 |
+
if token.type == tokenize.COMMENT:
|
| 222 |
+
continue
|
| 223 |
+
printable.update(ch for ch in token.string if ord(ch) > 127)
|
| 224 |
+
assert printable, "the scan found nothing, so it is not reading the art"
|
| 225 |
+
missing = sorted(printable - set(nexa.GLYPHS))
|
| 226 |
+
assert not missing, "used but not in nexa.GLYPHS: " + " ".join(missing)
|
| 227 |
+
|
| 228 |
+
|
| 229 |
def _cube(n):
|
| 230 |
"""An xterm-256 cube index as its (r, g, b) coordinates, each 0-5."""
|
| 231 |
n -= 16
|
|
@@ -258,18 +258,102 @@ def test_colour_is_off_when_stdout_is_not_a_terminal():
|
|
| 258 |
assert "\033" not in out
|
| 259 |
|
| 260 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
def test_a_legacy_code_page_does_not_crash_the_banner():
|
| 262 |
"""chcp 437, in the form that is portable to a test.
|
| 263 |
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
"""
|
| 270 |
code, out = cli([], {"PYTHONIOENCODING": "cp437:replace"})
|
| 271 |
assert code == 0
|
| 272 |
assert "NEXA" in out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
|
| 274 |
|
| 275 |
def test_ascii_banner_when_stdout_cannot_be_made_utf8(monkeypatch):
|
|
|
|
| 258 |
assert "\033" not in out
|
| 259 |
|
| 260 |
|
| 261 |
+
def _art_rows(art):
|
| 262 |
+
"""The art's rows, indentation intact, without the blank lines the triple-
|
| 263 |
+
quoted literal starts and ends with. The same shape test_cli.py uses, kept
|
| 264 |
+
local because these tests read the art out of a subprocess's stdout rather
|
| 265 |
+
than out of the module."""
|
| 266 |
+
return [row for row in art.splitlines() if row.strip()]
|
| 267 |
+
|
| 268 |
+
|
| 269 |
def test_a_legacy_code_page_does_not_crash_the_banner():
|
| 270 |
"""chcp 437, in the form that is portable to a test.
|
| 271 |
|
| 272 |
+
This used to be unable to assert which banner appeared: reconfigure()
|
| 273 |
+
re-encodes a pipe whatever PYTHONIOENCODING asked for, so _enable_utf8
|
| 274 |
+
succeeded and the cp437 declaration was overridden. A declared encoding is
|
| 275 |
+
now read as a statement about the far end, so the fallback is reached from
|
| 276 |
+
outside a monkeypatch and the test can say so.
|
| 277 |
"""
|
| 278 |
code, out = cli([], {"PYTHONIOENCODING": "cp437:replace"})
|
| 279 |
assert code == 0
|
| 280 |
assert "NEXA" in out
|
| 281 |
+
import nexa
|
| 282 |
+
for row in _art_rows(nexa.BANNER_ASCII):
|
| 283 |
+
assert row in out, "the ASCII wordmark did not reach a cp437 stdout"
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def test_a_declared_ascii_stdout_gets_the_ascii_wordmark():
|
| 287 |
+
"""The CI job's case, and the one the fallback is named for.
|
| 288 |
+
|
| 289 |
+
Strict `ascii` rather than `ascii:replace`: under :replace a glyph that
|
| 290 |
+
escaped the UNICODE gate comes back as `?` and nothing fails, which is how
|
| 291 |
+
a fallback rots while its job stays green.
|
| 292 |
+
"""
|
| 293 |
+
code, out = cli([], {"PYTHONIOENCODING": "ascii"})
|
| 294 |
+
assert code == 0
|
| 295 |
+
assert out.isascii(), "a non-ASCII character survived a declared ASCII stdout"
|
| 296 |
+
import nexa
|
| 297 |
+
for row in _art_rows(nexa.BANNER_ASCII):
|
| 298 |
+
assert row in out
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def test_a_declared_utf8_still_gets_the_unicode_wordmark():
|
| 302 |
+
"""The common case, asserted so the narrowing above cannot swallow it."""
|
| 303 |
+
import nexa
|
| 304 |
+
code, out = cli([], {"PYTHONIOENCODING": "utf-8"})
|
| 305 |
+
assert code == 0
|
| 306 |
+
for row in _art_rows(nexa.BANNER_UNICODE):
|
| 307 |
+
assert row in out
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def test_an_undeclared_code_page_is_still_rescued():
|
| 311 |
+
"""The Windows console this was written for declares nothing.
|
| 312 |
+
|
| 313 |
+
cp1252 as the machine's code page is not a statement about the far end, so
|
| 314 |
+
it is still reconfigured to UTF-8. The check keys on PYTHONIOENCODING, not
|
| 315 |
+
on the encoding stdout happens to report -- otherwise every Windows console
|
| 316 |
+
would drop to the fallback and the rescue would be dead code.
|
| 317 |
+
|
| 318 |
+
In-process because `cli` always names an encoding, and the situation here
|
| 319 |
+
is the one where nobody has.
|
| 320 |
+
"""
|
| 321 |
+
import nexa
|
| 322 |
+
|
| 323 |
+
class MachineCodePage(object):
|
| 324 |
+
"""Reports cp1252 because that is the machine's code page, and agrees
|
| 325 |
+
to be re-encoded, which is what a real console does."""
|
| 326 |
+
encoding = "cp1252"
|
| 327 |
+
reconfigured = False
|
| 328 |
+
|
| 329 |
+
def reconfigure(self, **kwargs):
|
| 330 |
+
MachineCodePage.reconfigured = True
|
| 331 |
+
|
| 332 |
+
monkey = MachineCodePage()
|
| 333 |
+
saved_out, saved_err = nexa.sys.stdout, nexa.sys.stderr
|
| 334 |
+
declared = os.environ.pop("PYTHONIOENCODING", None)
|
| 335 |
+
try:
|
| 336 |
+
nexa.sys.stdout = nexa.sys.stderr = monkey
|
| 337 |
+
assert nexa._enable_utf8() is True
|
| 338 |
+
assert MachineCodePage.reconfigured, "the console was never rescued"
|
| 339 |
+
finally:
|
| 340 |
+
nexa.sys.stdout, nexa.sys.stderr = saved_out, saved_err
|
| 341 |
+
if declared is not None:
|
| 342 |
+
os.environ["PYTHONIOENCODING"] = declared
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
def test_a_bare_error_handler_declares_no_codec():
|
| 346 |
+
"""`:replace` is a handler and no encoding. Read as a codec it raises
|
| 347 |
+
LookupError, which would send every such run to the fallback."""
|
| 348 |
+
import nexa
|
| 349 |
+
for value, expected in (
|
| 350 |
+
(":replace", ""), ("", ""), ("utf-8", "utf-8"),
|
| 351 |
+
("utf-8:replace", "utf-8"), (" ascii : strict ", "ascii")):
|
| 352 |
+
os.environ["PYTHONIOENCODING"] = value
|
| 353 |
+
try:
|
| 354 |
+
assert nexa._declared_encoding() == expected, value
|
| 355 |
+
finally:
|
| 356 |
+
del os.environ["PYTHONIOENCODING"]
|
| 357 |
|
| 358 |
|
| 359 |
def test_ascii_banner_when_stdout_cannot_be_made_utf8(monkeypatch):
|