DBax127 Claude Opus 5 commited on
Commit
3432acf
·
1 Parent(s): 8d155aa

ascii: make the fallback reachable, and the clone survive the gateway

Browse files

TWO 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>

Files changed (4) hide show
  1. .gitlab-ci.yml +29 -1
  2. nexa.py +36 -0
  3. tests/test_cli.py +27 -0
  4. tests/test_env.py +89 -5
.gitlab-ci.yml CHANGED
@@ -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
- PYTHONIOENCODING: "ascii:replace"
 
 
 
 
 
 
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.
nexa.py CHANGED
@@ -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")
tests/test_cli.py CHANGED
@@ -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
tests/test_env.py CHANGED
@@ -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
- Not an assertion about which banner appears: with a cp437 stdout,
265
- _enable_utf8 still succeeds, because stdout.reconfigure can re-encode a
266
- pipe whatever PYTHONIOENCODING asked for. What matters is that the command
267
- completes instead of dying on a UnicodeEncodeError halfway through a
268
- wordmark, which is what the fallback exists to prevent.
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):