Skip to content

Commit eeef9fa

Browse files
authored
Make the test suite environment-independent, fix UTF-8 redirection, and a UI-call race (#1752)
* Render test output independently of the caller's environment Rich and cmd2 consult several environment variables when deciding whether to emit styling, and the suite inherited them. Exporting any one of them made large numbers of unrelated tests fail depending on who ran the suite: NO_COLOR failed 15 tests, and FORCE_COLOR and TTY_COMPATIBLE 53 each. The failures look like product regressions, which makes them expensive to diagnose. Neutralize them for every test. Tests that exercise these variables set them explicitly, which still works because a test's own monkeypatching runs after the fixture. A guard test fails if any of them reaches a test again. * Write redirected and piped output as UTF-8 Command output is rendered by Rich and routinely contains non-ASCII, but redirection targets and pipes were opened with the locale's encoding. On any system whose default is not UTF-8 -- a Windows console using a legacy code page, for instance -- redirecting output raised UnicodeEncodeError, and the user was left with an empty file and advice to set PYTHONIOENCODING. Open both with UTF-8 explicitly. Two tests that read redirected output back were relying on the locale encoding for decoding as well, so they now name it too. * Fix a race that reported a timeout for a successful UI call _call_in_ui() polls the pending future with a 0.1s timeout and, on expiry, re-raises when the future is already done. That branch exists because concurrent.futures.TimeoutError is TimeoutError on Python 3.11+, so a callback raising a timeout of its own cannot be told apart by type from the poll expiring. Re-raising the caught exception conflates the two. When the callback completes in the window between the poll expiring and the future being inspected, the caller is told the call timed out even though it succeeded. Ask the future for its outcome instead: a callback that raised a timeout still propagates it, and one that produced a value now returns it. Found while investigating an intermittent failure of test_command_toolbar_ui_call_propagates_failures, which reproduced once in 60 runs before this change and not once in 120 after. The added regression test drives the interleaving deterministically rather than relying on timing. (cherry picked from commit 84bc19e) * Run the pipe encoding test through this interpreter, not cat The test piped through `cat`, which cmd.exe does not provide. On a Windows system without Unix utilities installed it would fail before reaching the encoding behavior it exists to check -- and Windows is exactly what the UTF-8 redirection fix targets. Use a sys.executable pass-through instead, matching the pipe tests already in tests/test_command_toolbar.py. Reverting either the pipe or the redirect encoding still fails these tests.
1 parent dee4c9b commit eeef9fa

7 files changed

Lines changed: 137 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
## 4.3.0 (TBD)
22

33
- Bug Fixes
4+
- Fixed output redirection and piping failing on systems whose default encoding is not UTF-8,
5+
such as a Windows console using a legacy code page. Command output is rendered by Rich and
6+
routinely contains non-ASCII, so redirecting it raised `UnicodeEncodeError` and left an empty
7+
file behind. Redirection targets and pipes now use UTF-8 explicitly
48
- Fixed the right prompt being redrawn beside every accepted command line in the scrollback.
59
prompt-toolkit includes it in the final frame of each prompt, which is the frame left on the
610
terminal; it is now hidden there, as the bottom toolbar already was, and stays on the live

cmd2/cmd2.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3403,9 +3403,11 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState:
34033403
# Create a pipe with read and write sides
34043404
read_fd, write_fd = os.pipe()
34053405

3406-
# Open each side of the pipe
3407-
subproc_stdin = open(read_fd) # noqa: SIM115
3408-
new_stdout: TextIO = cast(TextIO, open(write_fd, "w")) # noqa: SIM115
3406+
# Open each side of the pipe. Both ends are given an explicit encoding:
3407+
# command output is rendered by Rich and routinely contains non-ASCII, which
3408+
# the locale encoding cannot always represent.
3409+
subproc_stdin = open(read_fd, encoding="utf-8") # noqa: SIM115
3410+
new_stdout: TextIO = cast(TextIO, open(write_fd, "w", encoding="utf-8")) # noqa: SIM115
34093411

34103412
# Create pipe process in a separate group to isolate our signals from it. If a Ctrl-C event occurs,
34113413
# our sigint handler will forward it only to the most recent pipe process. This makes sure pipe
@@ -3472,8 +3474,15 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState:
34723474
# statement.output can only contain REDIRECTION_APPEND or REDIRECTION_OUTPUT
34733475
mode = "a" if statement.redirector == constants.REDIRECTION_APPEND else "w"
34743476
try:
3475-
# Use line buffering
3476-
new_stdout = cast(TextIO, open(su.strip_quotes(statement.redirect_to), mode=mode, buffering=1)) # noqa: SIM115
3477+
# Use line buffering. The encoding is explicit rather than the
3478+
# locale's: command output is rendered by Rich and routinely contains
3479+
# non-ASCII, so on a non-UTF-8 system -- a default Windows console,
3480+
# for instance -- redirection would otherwise fail and leave an empty
3481+
# file behind.
3482+
new_stdout = cast(
3483+
TextIO,
3484+
open(su.strip_quotes(statement.redirect_to), mode=mode, buffering=1, encoding="utf-8"), # noqa: SIM115
3485+
)
34773486
except OSError as ex:
34783487
raise RedirectionError("Failed to redirect output") from ex
34793488

cmd2/command_toolbar.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,12 @@ def call() -> None:
376376
value = result.result(timeout=0.1)
377377
except FutureTimeoutError:
378378
if result.done():
379-
raise
379+
# The callback finished while this poll was expiring, or raised a
380+
# TimeoutError of its own -- indistinguishable here, because
381+
# concurrent.futures.TimeoutError is TimeoutError on Python 3.11+.
382+
# Ask the future for its outcome rather than re-raising this poll's
383+
# timeout, which would report a failure for a call that succeeded.
384+
return result.result()
380385
self._check_running()
381386
else:
382387
return value

tests/conftest.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,23 @@ def run_cmd(app: cmd2.Cmd, cmd: str) -> tuple[list[str], list[str]]:
8686
return normalize(out), normalize(err)
8787

8888

89+
#: Environment variables that change how Rich and cmd2 render output. Left inherited,
90+
#: they make unrelated tests fail depending on who runs the suite: NO_COLOR fails 15
91+
#: tests, FORCE_COLOR and TTY_COMPATIBLE 53 each.
92+
COLOR_ENVIRONMENT = ("NO_COLOR", "FORCE_COLOR", "TTY_COMPATIBLE", "TTY_INTERACTIVE")
93+
94+
95+
@pytest.fixture(autouse=True)
96+
def neutral_color_environment(monkeypatch: pytest.MonkeyPatch) -> None:
97+
"""Render output the same way regardless of the caller's environment.
98+
99+
Tests that exercise these variables set them explicitly, which still works because
100+
a test's own monkeypatching runs after this fixture.
101+
"""
102+
for name in COLOR_ENVIRONMENT:
103+
monkeypatch.delenv(name, raising=False)
104+
105+
89106
@pytest.fixture
90107
def base_app() -> cmd2.Cmd:
91108
return cmd2.Cmd(include_py=True, include_ipy=True)

tests/test_command_toolbar.py

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
import sys
44
import threading
55
import time
6-
from concurrent.futures import ThreadPoolExecutor
6+
from concurrent.futures import Future, ThreadPoolExecutor
7+
from concurrent.futures import TimeoutError as FutureTimeoutError
78
from types import SimpleNamespace
89
from unittest import mock
910

@@ -58,7 +59,7 @@ def test_command_toolbar_redirected_output(toolbar_app, tmp_path) -> None:
5859
destination = tmp_path / "help.txt"
5960
with app._command_toolbar_context():
6061
app.onecmd_plus_hooks(f'help > "{destination}"')
61-
text = destination.read_text()
62+
text = destination.read_text(encoding="utf-8")
6263
assert "Cmd2 Commands" in text
6364
assert "STATUS" not in text
6465
assert "Cmd2 Commands" not in output.getvalue()
@@ -79,7 +80,7 @@ def command(statement, **kwargs):
7980
app.onecmd_plus_hooks(f'custom > "{destination}"')
8081
app.poutput("terminal output")
8182

82-
assert destination.read_text() == "before\nduring\nafter\n"
83+
assert destination.read_text(encoding="utf-8") == "before\nduring\nafter\n"
8384
assert "before" not in output.getvalue()
8485
assert "during" not in output.getvalue()
8586
assert "after" not in output.getvalue()
@@ -134,7 +135,7 @@ def command(statement, **kwargs):
134135
assert running == [False]
135136
# A process given the terminal writes to it directly instead of through a captured pipe.
136137
assert readers[0]._proc.stdout is None
137-
assert "PIPED" in destination.read_text()
138+
assert "PIPED" in destination.read_text(encoding="utf-8")
138139

139140

140141
def test_command_toolbar_binary_output(toolbar_app) -> None:
@@ -431,6 +432,36 @@ def fail(exception: BaseException) -> None:
431432
assert toolbar._call_in_ui(lambda: time.sleep(0.2) or "finished") == "finished"
432433

433434

435+
def test_command_toolbar_ui_call_returns_a_result_that_lands_during_the_poll(toolbar_app, monkeypatch) -> None:
436+
"""A callback finishing while the poll expires must return its value, not a timeout.
437+
438+
`concurrent.futures.TimeoutError` is `TimeoutError` on Python 3.11+, so the poll
439+
expiring and the callback raising a timeout of its own are indistinguishable by type.
440+
Re-raising the caught exception once the future is done therefore reports a timeout for
441+
a call that actually succeeded.
442+
"""
443+
app, _, _ = toolbar_app
444+
445+
class RacyFuture(Future):
446+
"""Completes, and only then reports the poll as having expired."""
447+
448+
def __init__(self) -> None:
449+
super().__init__()
450+
self._polled = False
451+
452+
def result(self, timeout=None): # type: ignore[no-untyped-def]
453+
if timeout is not None and not self._polled:
454+
self._polled = True
455+
super().result(timeout=5) # let the callback finish first
456+
raise FutureTimeoutError # then act as though the poll had expired
457+
return super().result(timeout)
458+
459+
monkeypatch.setattr(command_toolbar, "Future", RacyFuture)
460+
with app._command_toolbar_context():
461+
toolbar = app._command_toolbar
462+
assert toolbar._call_in_ui(lambda: "finished") == "finished"
463+
464+
434465
def test_command_toolbar_ui_call_after_display_stopped(toolbar_app) -> None:
435466
app, pipe, _ = toolbar_app
436467
with app._command_toolbar_context():
@@ -773,5 +804,5 @@ def test_builtin_pager_does_not_capture_redirected_output(toolbar_app, monkeypat
773804
with mock.patch("cmd2.command_toolbar.Pager") as pager, app._command_toolbar_context():
774805
app.onecmd_plus_hooks(f'help > "{target}"')
775806
pager.assert_not_called()
776-
assert "Cmd2 Commands" in target.read_text()
807+
assert "Cmd2 Commands" in target.read_text(encoding="utf-8")
777808
assert "Cmd2 Commands" not in output.getvalue()

tests/test_run_pyscript.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ def test_run_pyscript_print_redirection(base_app, request, tmp_path, capsys) ->
246246
out, err = capsys.readouterr()
247247

248248
# Verify the output file contains what we expect from print()
249-
content = pathlib.Path(out_file).read_text()
249+
content = pathlib.Path(out_file).read_text(encoding="utf-8")
250250

251251
# Look for everything written to self.stdout
252252
assert len(content.splitlines()) == 4

tests/test_suite_environment.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Guards that the suite renders output independently of the developer's environment.
2+
3+
Rich and cmd2 both consult environment variables when deciding whether to emit styling.
4+
Inheriting them makes large numbers of unrelated tests fail depending on who runs them,
5+
which is expensive to diagnose because the failures look like product regressions.
6+
"""
7+
8+
import os
9+
import sys
10+
11+
import pytest
12+
13+
import cmd2
14+
15+
#: Variables that change how output is rendered. Rich reads all of these; cmd2 reads
16+
#: NO_COLOR directly. Tests that exercise them set them explicitly instead.
17+
COLOR_ENVIRONMENT = ("NO_COLOR", "FORCE_COLOR", "TTY_COMPATIBLE", "TTY_INTERACTIVE")
18+
19+
20+
@pytest.mark.parametrize("name", COLOR_ENVIRONMENT)
21+
def test_color_environment_does_not_leak_into_tests(name: str) -> None:
22+
"""A developer exporting any of these must not change the suite's results."""
23+
assert name not in os.environ, (
24+
f"{name} leaked into the test environment; output-rendering assertions would depend on who is running the suite"
25+
)
26+
27+
28+
class EncodingProbe(cmd2.Cmd):
29+
"""Reports the encoding of whatever stream output is currently going to."""
30+
31+
def do_show_encoding(self, _: str) -> None:
32+
"""Print the current output stream's encoding."""
33+
self.poutput(f"ENCODING={getattr(self.stdout, 'encoding', None)}")
34+
35+
36+
def test_redirection_to_a_file_uses_utf8(tmp_path) -> None:
37+
"""cmd2 renders non-ASCII, so a redirect target must not use the locale encoding.
38+
39+
Opened with the locale encoding, redirecting styled output raises UnicodeEncodeError
40+
on any non-UTF-8 system -- which includes a default Windows console -- leaving the
41+
user an empty file and an error.
42+
"""
43+
app = EncodingProbe(allow_cli_args=False)
44+
target = tmp_path / "out.txt"
45+
app.onecmd_plus_hooks(f'show_encoding > "{target}"')
46+
assert "ENCODING=utf-8" in target.read_text(encoding="utf-8")
47+
48+
49+
#: A pass-through filter, run with this interpreter so the test does not depend on Unix
50+
#: utilities being installed. `cmd.exe` has no `cat`, and this fix exists for Windows.
51+
PASS_THROUGH = "import sys; sys.stdin.reconfigure(encoding='utf-8'); sys.stdout.write(sys.stdin.read())"
52+
53+
54+
def test_piping_uses_utf8(tmp_path) -> None:
55+
"""The same applies to the pipe the subprocess reads from."""
56+
app = EncodingProbe(allow_cli_args=False)
57+
target = tmp_path / "piped.txt"
58+
app.onecmd_plus_hooks(f'show_encoding | "{sys.executable}" -c "{PASS_THROUGH}" > "{target}"')
59+
assert "ENCODING=utf-8" in target.read_text(encoding="utf-8")

0 commit comments

Comments
 (0)