Skip to content

Commit 3b670cd

Browse files
authored
In test I/O utility, restore the old stdin/stdout instead of the "true" I/O streams (#5049)
I got a little bit nerdsniped by the problems observed in #5027. In short, my high-level diagnosis in #5027 (comment) seems to have been correct: other tests were suppressing the legitimate failure of a flaky test. I found the problem by running other tests before the problem test, like this: ``` $ pytest -k 'test_nonexistant_db or test_delete_removes_item' test/test_ui.py ``` When running `test_nonexistant_db` alone, it fails. When running it like this with another test that goes first, it passes. That's the problem. However, `test_delete_removes_item` is just one example that works to make this problem happen. It appeared that _any_ test in a class that used our `_common.TestCase` base class had this power. I tracked down the issue to our `DummyIO` utility, which was having an unintentional effect even when it was never actually used. Here's the solution. Instead of restoring `sys.stdin` to `sys.__stdin__`, we now restore it to whatever it was before we installed out dummy I/O hooks. This is relevant in pytest, for example, which installs its *own* `sys.stdin`, which we were then clobbering. This was leading to the suppression of test failures observed in #5021 and addressed in #5027. The CI will fail for this PR because it now (correctly) exposes a failing test. Hopefully by combining this with the fixes in the works in #5027, we'll be back to a passing test suite. 😃 @Phil305, could you perhaps help validate that hypothesis? Edit: @snejus: I've now consolidated test I/O handling by removing the legacy `control_stdin`/`capture_stdout` context managers and the custom `DummyOut` stream, replacing them with a pytest-driven `io` fixture that: - provides controllable `stdin` via a lightweight `DummyIn` - captures `stdout` via `capteesys` - attaches a `DummyIO` helper to test classes as `self.io`
2 parents 8eed22c + d613981 commit 3b670cd

30 files changed

Lines changed: 282 additions & 337 deletions

beets/test/_common.py

Lines changed: 40 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,13 @@
1414

1515
"""Some common functionality for beets' test cases."""
1616

17+
from __future__ import annotations
18+
1719
import os
1820
import sys
1921
import unittest
2022
from contextlib import contextmanager
23+
from typing import TYPE_CHECKING
2124

2225
import beets
2326
import beets.library
@@ -28,6 +31,9 @@
2831
from beets.ui import commands
2932
from beets.util import syspath
3033

34+
if TYPE_CHECKING:
35+
import pytest
36+
3137
beetsplug.__path__ = [
3238
os.path.abspath(
3339
os.path.join(
@@ -118,85 +124,55 @@ def import_session(lib=None, loghandler=None, paths=[], query=[], cli=False):
118124
# Mock I/O.
119125

120126

121-
class InputError(Exception):
122-
def __init__(self, output=None):
123-
self.output = output
124-
125-
def __str__(self):
126-
msg = "Attempt to read with no input provided."
127-
if self.output is not None:
128-
msg += f" Output: {self.output!r}"
129-
return msg
130-
131-
132-
class DummyOut:
133-
encoding = "utf-8"
134-
135-
def __init__(self):
136-
self.buf = []
137-
138-
def write(self, s):
139-
self.buf.append(s)
140-
141-
def get(self):
142-
return "".join(self.buf)
143-
144-
def flush(self):
145-
self.clear()
146-
147-
def clear(self):
148-
self.buf = []
127+
class InputError(IOError):
128+
def __str__(self) -> str:
129+
return "Attempt to read with no input provided."
149130

150131

151132
class DummyIn:
152133
encoding = "utf-8"
153134

154-
def __init__(self, out=None):
155-
self.buf = []
156-
self.reads = 0
157-
self.out = out
135+
def __init__(self) -> None:
136+
self.buf: list[str] = []
158137

159-
def add(self, s):
138+
def add(self, s: str) -> None:
160139
self.buf.append(f"{s}\n")
161140

162-
def close(self):
141+
def close(self) -> None:
163142
pass
164143

165-
def readline(self):
144+
def readline(self) -> str:
166145
if not self.buf:
167-
if self.out:
168-
raise InputError(self.out.get())
169-
else:
170-
raise InputError()
171-
self.reads += 1
146+
raise InputError
147+
172148
return self.buf.pop(0)
173149

174150

175151
class DummyIO:
176-
"""Mocks input and output streams for testing UI code."""
177-
178-
def __init__(self):
179-
self.stdout = DummyOut()
180-
self.stdin = DummyIn(self.stdout)
181-
182-
def addinput(self, s):
183-
self.stdin.add(s)
184-
185-
def getoutput(self):
186-
res = self.stdout.get()
187-
self.stdout.clear()
188-
return res
189-
190-
def readcount(self):
191-
return self.stdin.reads
192-
193-
def install(self):
194-
sys.stdin = self.stdin
195-
sys.stdout = self.stdout
196-
197-
def restore(self):
198-
sys.stdin = sys.__stdin__
199-
sys.stdout = sys.__stdout__
152+
"""Test helper that manages standard input and output."""
153+
154+
def __init__(
155+
self,
156+
monkeypatch: pytest.MonkeyPatch,
157+
capteesys: pytest.CaptureFixture[str],
158+
) -> None:
159+
self._capteesys = capteesys
160+
self.stdin = DummyIn()
161+
162+
monkeypatch.setattr("sys.stdin", self.stdin)
163+
164+
def addinput(self, text: str) -> None:
165+
"""Simulate user typing into stdin."""
166+
self.stdin.add(text)
167+
168+
def getoutput(self) -> str:
169+
"""Get the standard output captured so far.
170+
171+
Note: it clears the internal buffer, so subsequent calls will only
172+
return *new* output.
173+
"""
174+
# Using capteesys allows you to see output in the console if the test fails
175+
return self._capteesys.readouterr().out
200176

201177

202178
# Utility.

beets/test/helper.py

Lines changed: 22 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,6 @@
1515
"""This module includes various helpers that provide fixtures, capture
1616
information or mock the environment.
1717
18-
- The `control_stdin` and `capture_stdout` context managers allow one to
19-
interact with the user interface.
20-
2118
- `has_program` checks the presence of a command on the system.
2219
2320
- The `ImportSessionFixture` allows one to run importer code while
@@ -38,12 +35,12 @@
3835
from dataclasses import dataclass
3936
from enum import Enum
4037
from functools import cached_property
41-
from io import StringIO
4238
from pathlib import Path
4339
from tempfile import gettempdir, mkdtemp, mkstemp
4440
from typing import Any, ClassVar
4541
from unittest.mock import patch
4642

43+
import pytest
4744
import responses
4845
from mediafile import Image, MediaFile
4946

@@ -83,41 +80,6 @@ def capture_log(logger="beets"):
8380
log.removeHandler(capture)
8481

8582

86-
@contextmanager
87-
def control_stdin(input=None):
88-
"""Sends ``input`` to stdin.
89-
90-
>>> with control_stdin('yes'):
91-
... input()
92-
'yes'
93-
"""
94-
org = sys.stdin
95-
sys.stdin = StringIO(input)
96-
try:
97-
yield sys.stdin
98-
finally:
99-
sys.stdin = org
100-
101-
102-
@contextmanager
103-
def capture_stdout():
104-
"""Save stdout in a StringIO.
105-
106-
>>> with capture_stdout() as output:
107-
... print('spam')
108-
...
109-
>>> output.getvalue()
110-
'spam'
111-
"""
112-
org = sys.stdout
113-
sys.stdout = capture = StringIO()
114-
try:
115-
yield sys.stdout
116-
finally:
117-
sys.stdout = org
118-
print(capture.getvalue())
119-
120-
12183
def has_program(cmd, args=["--version"]):
12284
"""Returns `True` if `cmd` can be executed."""
12385
full_cmd = [cmd, *args]
@@ -163,21 +125,31 @@ def config(self) -> beets.IncludeLazyConfig:
163125
)
164126

165127

166-
class IOMixin:
167-
@cached_property
168-
def io(self) -> _common.DummyIO:
169-
return _common.DummyIO()
128+
class RunMixin:
129+
def run_command(self, *args, **kwargs):
130+
"""Run a beets command with an arbitrary amount of arguments. The
131+
Library` defaults to `self.lib`, but can be overridden with
132+
the keyword argument `lib`.
133+
"""
134+
sys.argv = ["beet"] # avoid leakage from test suite args
135+
lib = None
136+
if hasattr(self, "lib"):
137+
lib = self.lib
138+
lib = kwargs.get("lib", lib)
139+
beets.ui._raw_main(list(args), lib)
170140

171-
def setUp(self):
172-
super().setUp()
173-
self.io.install()
174141

175-
def tearDown(self):
176-
super().tearDown()
177-
self.io.restore()
142+
@pytest.mark.usefixtures("io")
143+
class IOMixin(RunMixin):
144+
io: _common.DummyIO
145+
146+
def run_with_output(self, *args):
147+
self.io.getoutput()
148+
self.run_command(*args)
149+
return self.io.getoutput()
178150

179151

180-
class TestHelper(ConfigMixin):
152+
class TestHelper(RunMixin, ConfigMixin):
181153
"""Helper mixin for high-level cli and plugin tests.
182154
183155
This mixin provides methods to isolate beets' global state provide
@@ -392,25 +364,6 @@ def create_mediafile_fixture(self, ext="mp3", images=[], target_dir=None):
392364

393365
return path
394366

395-
# Running beets commands
396-
397-
def run_command(self, *args, **kwargs):
398-
"""Run a beets command with an arbitrary amount of arguments. The
399-
Library` defaults to `self.lib`, but can be overridden with
400-
the keyword argument `lib`.
401-
"""
402-
sys.argv = ["beet"] # avoid leakage from test suite args
403-
lib = None
404-
if hasattr(self, "lib"):
405-
lib = self.lib
406-
lib = kwargs.get("lib", lib)
407-
beets.ui._raw_main(list(args), lib)
408-
409-
def run_with_output(self, *args):
410-
with capture_stdout() as out:
411-
self.run_command(*args)
412-
return out.getvalue()
413-
414367
# Safe file operations
415368

416369
def create_temp_dir(self, **kwargs) -> str:
@@ -758,10 +711,7 @@ def _add_choice_input(self):
758711
class TerminalImportMixin(IOMixin, ImportHelper):
759712
"""Provides_a terminal importer for the import session."""
760713

761-
io: _common.DummyIO
762-
763714
def _get_import_session(self, import_dir: bytes) -> importer.ImportSession:
764-
self.io.install()
765715
return TerminalImportSessionFixture(
766716
self.lib,
767717
loghandler=None,

test/conftest.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from beets.autotag.distance import Distance
77
from beets.dbcore.query import Query
8+
from beets.test._common import DummyIO
89
from beets.test.helper import ConfigMixin
910
from beets.util import cached_classproperty
1011

@@ -60,3 +61,24 @@ def clear_cached_classproperty():
6061
def config():
6162
"""Provide a fresh beets configuration for a module, when requested."""
6263
return ConfigMixin().config
64+
65+
66+
@pytest.fixture
67+
def io(
68+
request: pytest.FixtureRequest,
69+
monkeypatch: pytest.MonkeyPatch,
70+
capteesys: pytest.CaptureFixture[str],
71+
) -> DummyIO:
72+
"""Fixture for tests that need controllable stdin and captured stdout.
73+
74+
This fixture builds a per-test ``DummyIO`` helper and exposes it to the
75+
test. When used on a test class, it attaches the helper as ``self.io``
76+
attribute to make it available to all test methods, including
77+
``unittest.TestCase``-based ones.
78+
"""
79+
io = DummyIO(monkeypatch, capteesys)
80+
81+
if request.instance:
82+
request.instance.io = io
83+
84+
return io

test/plugins/test_bareasc.py

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44
"""Tests for the 'bareasc' plugin."""
55

66
from beets import logging
7-
from beets.test.helper import PluginTestCase, capture_stdout
7+
from beets.test.helper import IOMixin, PluginTestCase
88

99

10-
class BareascPluginTest(PluginTestCase):
10+
class BareascPluginTest(IOMixin, PluginTestCase):
1111
"""Test bare ASCII query matching."""
1212

1313
plugin = "bareasc"
@@ -65,16 +65,12 @@ def test_bareasc_search(self):
6565

6666
def test_bareasc_list_output(self):
6767
"""Bare-ASCII version of list command - check output."""
68-
with capture_stdout() as output:
69-
self.run_command("bareasc", "with accents")
68+
self.run_command("bareasc", "with accents")
7069

71-
assert "Antonin Dvorak" in output.getvalue()
70+
assert "Antonin Dvorak" in self.io.getoutput()
7271

7372
def test_bareasc_format_output(self):
7473
"""Bare-ASCII version of list -f command - check output."""
75-
with capture_stdout() as output:
76-
self.run_command(
77-
"bareasc", "with accents", "-f", "$artist:: $title"
78-
)
74+
self.run_command("bareasc", "with accents", "-f", "$artist:: $title")
7975

80-
assert "Antonin Dvorak:: with accents\n" == output.getvalue()
76+
assert "Antonin Dvorak:: with accents\n" == self.io.getoutput()

test/plugins/test_bpd.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
from beets.test.helper import PluginTestCase
3333
from beets.util import bluelet
3434

35-
bpd = pytest.importorskip("beetsplug.bpd")
35+
bpd = pytest.importorskip("beetsplug.bpd", exc_type=ImportError)
3636

3737

3838
class CommandParseTest(unittest.TestCase):

0 commit comments

Comments
 (0)