From cc4dd688b855df2c3f2b0af7e0248727f7d76d40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Thu, 15 Jan 2026 21:02:21 +0000 Subject: [PATCH 1/5] Patch sys.stdin and sys.stdout in tests --- beets/test/_common.py | 8 -------- beets/test/helper.py | 12 ++++++------ test/ui/commands/test_completion.py | 1 - 3 files changed, 6 insertions(+), 15 deletions(-) diff --git a/beets/test/_common.py b/beets/test/_common.py index 487f7c442..10083c8cb 100644 --- a/beets/test/_common.py +++ b/beets/test/_common.py @@ -190,14 +190,6 @@ class DummyIO: def readcount(self): return self.stdin.reads - def install(self): - sys.stdin = self.stdin - sys.stdout = self.stdout - - def restore(self): - sys.stdin = sys.__stdin__ - sys.stdout = sys.__stdout__ - # Utility. diff --git a/beets/test/helper.py b/beets/test/helper.py index 207b0e491..582d7ac43 100644 --- a/beets/test/helper.py +++ b/beets/test/helper.py @@ -168,13 +168,14 @@ class IOMixin: def io(self) -> _common.DummyIO: return _common.DummyIO() - def setUp(self): + def setUp(self) -> None: super().setUp() - self.io.install() - def tearDown(self): - super().tearDown() - self.io.restore() + patcher = patch.multiple( + "sys", stdin=self.io.stdin, stdout=self.io.stdout + ) + patcher.start() + self.addCleanup(patcher.stop) class TestHelper(ConfigMixin): @@ -759,7 +760,6 @@ class TerminalImportMixin(IOMixin, ImportHelper): io: _common.DummyIO def _get_import_session(self, import_dir: bytes) -> importer.ImportSession: - self.io.install() return TerminalImportSessionFixture( self.lib, loghandler=None, diff --git a/test/ui/commands/test_completion.py b/test/ui/commands/test_completion.py index ee2881a0e..992ed58c8 100644 --- a/test/ui/commands/test_completion.py +++ b/test/ui/commands/test_completion.py @@ -49,7 +49,6 @@ class CompletionTest(IOMixin, TestPluginTestCase): # Load completion script. self.run_command("completion", lib=None) completion_script = self.io.getoutput().encode("utf-8") - self.io.restore() tester.stdin.writelines(completion_script.splitlines(True)) # Load test suite. From 125313d53946271b6a0bdda21c44f4b4f5627262 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Thu, 15 Jan 2026 21:11:16 +0000 Subject: [PATCH 2/5] Fix test failures --- test/plugins/test_ftintitle.py | 4 +++- test/ui/test_ui.py | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py index aff4dda18..560f44402 100644 --- a/test/plugins/test_ftintitle.py +++ b/test/plugins/test_ftintitle.py @@ -454,7 +454,9 @@ def test_custom_words( assert ftintitle.contains_feat(given, custom_words) is expected -def test_album_template_value(): +def test_album_template_value(config): + config["ftintitle"]["custom_words"] = [] + album = Album() album["albumartist"] = "Foo ft. Bar" assert ftintitle._album_artist_no_feat(album) == "Foo" diff --git a/test/ui/test_ui.py b/test/ui/test_ui.py index a0bf2e598..f96d2c76a 100644 --- a/test/ui/test_ui.py +++ b/test/ui/test_ui.py @@ -71,11 +71,11 @@ class TestPluginTestCase(PluginTestCase): plugin = "test" def setUp(self): + self.config["pluginpath"] = [_common.PLUGINPATH] super().setUp() - config["pluginpath"] = [_common.PLUGINPATH] -class ConfigTest(TestPluginTestCase): +class ConfigTest(IOMixin, TestPluginTestCase): def setUp(self): super().setUp() @@ -162,6 +162,7 @@ class ConfigTest(TestPluginTestCase): with self.write_config_file() as config: config.write("library: /xxx/yyy/not/a/real/path") + self.io.addinput("n") with pytest.raises(ui.UserError): self.run_command("test", lib=None) From ed43387778795e975cc82a0df1f87769010aef1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Fri, 16 Jan 2026 01:16:39 +0000 Subject: [PATCH 3/5] Replace custom stdio mocks with pytest io fixture Create a centralised pytest fixture to provide controllable stdin and captured stdout in all tests. Simplify DummyIO/DummyIn and remove the custom DummyOut implementation and make use of pytest builtin fixtures. Create a centralised pytest fixture to provide controllable stdin and captured stdout that can be applied to any tests, regardless whether they are based on pytest or unittest. * `io` fixture can be used as a fixture in pytest-based tests * `IOMixin` can be used to attach `io` attribute to any test class, including `unittest.TestCase` --- beets/test/_common.py | 88 ++++++++++++++------------------- beets/test/helper.py | 17 ++----- test/conftest.py | 22 +++++++++ test/ui/commands/test_fields.py | 2 +- 4 files changed, 62 insertions(+), 67 deletions(-) diff --git a/beets/test/_common.py b/beets/test/_common.py index 10083c8cb..4de47c337 100644 --- a/beets/test/_common.py +++ b/beets/test/_common.py @@ -14,10 +14,13 @@ """Some common functionality for beets' test cases.""" +from __future__ import annotations + import os import sys import unittest from contextlib import contextmanager +from typing import TYPE_CHECKING import beets import beets.library @@ -28,6 +31,9 @@ from beets import importer, logging, util from beets.ui import commands from beets.util import syspath +if TYPE_CHECKING: + import pytest + beetsplug.__path__ = [ os.path.abspath( os.path.join( @@ -118,77 +124,55 @@ def import_session(lib=None, loghandler=None, paths=[], query=[], cli=False): # Mock I/O. -class InputError(Exception): - def __init__(self, output=None): - self.output = output - - def __str__(self): - msg = "Attempt to read with no input provided." - if self.output is not None: - msg += f" Output: {self.output!r}" - return msg - - -class DummyOut: - encoding = "utf-8" - - def __init__(self): - self.buf = [] - - def write(self, s): - self.buf.append(s) - - def get(self): - return "".join(self.buf) - - def flush(self): - self.clear() - - def clear(self): - self.buf = [] +class InputError(IOError): + def __str__(self) -> str: + return "Attempt to read with no input provided." class DummyIn: encoding = "utf-8" - def __init__(self, out=None): - self.buf = [] - self.reads = 0 - self.out = out + def __init__(self) -> None: + self.buf: list[str] = [] - def add(self, s): + def add(self, s: str) -> None: self.buf.append(f"{s}\n") - def close(self): + def close(self) -> None: pass - def readline(self): + def readline(self) -> str: if not self.buf: - if self.out: - raise InputError(self.out.get()) - else: - raise InputError() - self.reads += 1 + raise InputError + return self.buf.pop(0) class DummyIO: - """Mocks input and output streams for testing UI code.""" + """Test helper that manages standard input and output.""" - def __init__(self): - self.stdout = DummyOut() - self.stdin = DummyIn(self.stdout) + def __init__( + self, + monkeypatch: pytest.MonkeyPatch, + capteesys: pytest.CaptureFixture[str], + ) -> None: + self._capteesys = capteesys + self.stdin = DummyIn() - def addinput(self, s): - self.stdin.add(s) + monkeypatch.setattr("sys.stdin", self.stdin) - def getoutput(self): - res = self.stdout.get() - self.stdout.clear() - return res + def addinput(self, text: str) -> None: + """Simulate user typing into stdin.""" + self.stdin.add(text) - def readcount(self): - return self.stdin.reads + def getoutput(self) -> str: + """Get the standard output captured so far. + + Note: it clears the internal buffer, so subsequent calls will only + return *new* output. + """ + # Using capteesys allows you to see output in the console if the test fails + return self._capteesys.readouterr().out # Utility. diff --git a/beets/test/helper.py b/beets/test/helper.py index 582d7ac43..809bc2a4e 100644 --- a/beets/test/helper.py +++ b/beets/test/helper.py @@ -44,6 +44,7 @@ from tempfile import gettempdir, mkdtemp, mkstemp from typing import Any, ClassVar from unittest.mock import patch +import pytest import responses from mediafile import Image, MediaFile @@ -163,19 +164,9 @@ NEEDS_REFLINK = unittest.skipUnless( ) +@pytest.mark.usefixtures("io") class IOMixin: - @cached_property - def io(self) -> _common.DummyIO: - return _common.DummyIO() - - def setUp(self) -> None: - super().setUp() - - patcher = patch.multiple( - "sys", stdin=self.io.stdin, stdout=self.io.stdout - ) - patcher.start() - self.addCleanup(patcher.stop) + io: _common.DummyIO class TestHelper(ConfigMixin): @@ -757,8 +748,6 @@ class TerminalImportSessionFixture(TerminalImportSession): class TerminalImportMixin(IOMixin, ImportHelper): """Provides_a terminal importer for the import session.""" - io: _common.DummyIO - def _get_import_session(self, import_dir: bytes) -> importer.ImportSession: return TerminalImportSessionFixture( self.lib, diff --git a/test/conftest.py b/test/conftest.py index 059526d2f..b35083641 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -5,6 +5,7 @@ import pytest from beets.autotag.distance import Distance from beets.dbcore.query import Query +from beets.test._common import DummyIO from beets.test.helper import ConfigMixin from beets.util import cached_classproperty @@ -60,3 +61,24 @@ def clear_cached_classproperty(): def config(): """Provide a fresh beets configuration for a module, when requested.""" return ConfigMixin().config + + +@pytest.fixture +def io( + request: pytest.FixtureRequest, + monkeypatch: pytest.MonkeyPatch, + capteesys: pytest.CaptureFixture[str], +) -> DummyIO: + """Fixture for tests that need controllable stdin and captured stdout. + + This fixture builds a per-test ``DummyIO`` helper and exposes it to the + test. When used on a test class, it attaches the helper as ``self.io`` + attribute to make it available to all test methods, including + ``unittest.TestCase``-based ones. + """ + io = DummyIO(monkeypatch, capteesys) + + if request.instance: + request.instance.io = io + + return io diff --git a/test/ui/commands/test_fields.py b/test/ui/commands/test_fields.py index 0eaaa9ceb..98d4809c9 100644 --- a/test/ui/commands/test_fields.py +++ b/test/ui/commands/test_fields.py @@ -16,7 +16,7 @@ class FieldsTest(IOMixin, ItemInDBTestCase): items = library.Item.all_keys() albums = library.Album.all_keys() - output = self.io.stdout.get().split() + output = self.io.getoutput().split() self.remove_keys(items, output) self.remove_keys(albums, output) From cbfec8de66997d2745f1b96e18f85cd9a8a658e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Fri, 16 Jan 2026 01:25:14 +0000 Subject: [PATCH 4/5] Replace control_stdin with io.addinput --- beets/test/helper.py | 20 +------- test/plugins/test_bpd.py | 2 +- test/plugins/test_convert.py | 76 +++++++++++++++---------------- test/plugins/test_edit.py | 21 +++++---- test/plugins/test_importsource.py | 22 ++++----- test/plugins/test_mbsubmit.py | 17 ++++--- test/plugins/test_play.py | 20 ++++---- test/plugins/test_zero.py | 47 ++++++++----------- test/test_plugins.py | 9 ++-- test/ui/commands/test_modify.py | 21 +++++---- test/ui/test_ui_init.py | 28 ++++++------ 11 files changed, 134 insertions(+), 149 deletions(-) diff --git a/beets/test/helper.py b/beets/test/helper.py index 809bc2a4e..3f6b18336 100644 --- a/beets/test/helper.py +++ b/beets/test/helper.py @@ -15,8 +15,8 @@ """This module includes various helpers that provide fixtures, capture information or mock the environment. -- The `control_stdin` and `capture_stdout` context managers allow one to - interact with the user interface. +- `capture_stdout` context managers allow one to interact with the user + interface. - `has_program` checks the presence of a command on the system. @@ -84,22 +84,6 @@ def capture_log(logger="beets"): log.removeHandler(capture) -@contextmanager -def control_stdin(input=None): - """Sends ``input`` to stdin. - - >>> with control_stdin('yes'): - ... input() - 'yes' - """ - org = sys.stdin - sys.stdin = StringIO(input) - try: - yield sys.stdin - finally: - sys.stdin = org - - @contextmanager def capture_stdout(): """Save stdout in a StringIO. diff --git a/test/plugins/test_bpd.py b/test/plugins/test_bpd.py index 157569bbe..81e088067 100644 --- a/test/plugins/test_bpd.py +++ b/test/plugins/test_bpd.py @@ -32,7 +32,7 @@ import yaml from beets.test.helper import PluginTestCase from beets.util import bluelet -bpd = pytest.importorskip("beetsplug.bpd") +bpd = pytest.importorskip("beetsplug.bpd", exc_type=ImportError) class CommandParseTest(unittest.TestCase): diff --git a/test/plugins/test_convert.py b/test/plugins/test_convert.py index 2a1a3b94d..de2650617 100644 --- a/test/plugins/test_convert.py +++ b/test/plugins/test_convert.py @@ -30,8 +30,8 @@ from beets.test.helper import ( AsIsImporterMixin, ImportHelper, PluginTestCase, + IOMixin, capture_log, - control_stdin, ) from beetsplug import convert @@ -66,7 +66,7 @@ class ConvertMixin: return path.read_bytes().endswith(tag.encode("utf-8")) -class ConvertTestCase(ConvertMixin, PluginTestCase): +class ConvertTestCase(IOMixin, ConvertMixin, PluginTestCase): db_on_disk = True plugin = "convert" @@ -157,8 +157,8 @@ class ConvertCliTest(ConvertTestCase, ConvertCommand): } def test_convert(self): - with control_stdin("y"): - self.run_convert() + self.io.addinput("y") + self.run_convert() assert self.file_endswith(self.converted_mp3, "mp3") def test_convert_with_auto_confirmation(self): @@ -166,22 +166,22 @@ class ConvertCliTest(ConvertTestCase, ConvertCommand): assert self.file_endswith(self.converted_mp3, "mp3") def test_reject_confirmation(self): - with control_stdin("n"): - self.run_convert() + self.io.addinput("n") + self.run_convert() assert not self.converted_mp3.exists() def test_convert_keep_new(self): assert os.path.splitext(self.item.path)[1] == b".ogg" - with control_stdin("y"): - self.run_convert("--keep-new") + self.io.addinput("y") + self.run_convert("--keep-new") self.item.load() assert os.path.splitext(self.item.path)[1] == b".mp3" def test_format_option(self): - with control_stdin("y"): - self.run_convert("--format", "opus") + self.io.addinput("y") + self.run_convert("--format", "opus") assert self.file_endswith(self.convert_dest / "converted.ops", "opus") def test_embed_album_art(self): @@ -192,8 +192,8 @@ class ConvertCliTest(ConvertTestCase, ConvertCommand): with open(os.path.join(image_path), "rb") as f: image_data = f.read() - with control_stdin("y"): - self.run_convert() + self.io.addinput("y") + self.run_convert() mediafile = MediaFile(self.converted_mp3) assert mediafile.images[0].data == image_data @@ -215,26 +215,26 @@ class ConvertCliTest(ConvertTestCase, ConvertCommand): def test_no_transcode_when_maxbr_set_high_and_different_formats(self): self.config["convert"]["max_bitrate"] = 5000 - with control_stdin("y"): - self.run_convert() + self.io.addinput("y") + self.run_convert() assert self.file_endswith(self.converted_mp3, "mp3") def test_transcode_when_maxbr_set_low_and_different_formats(self): self.config["convert"]["max_bitrate"] = 5 - with control_stdin("y"): - self.run_convert() + self.io.addinput("y") + self.run_convert() assert self.file_endswith(self.converted_mp3, "mp3") def test_transcode_when_maxbr_set_to_none_and_different_formats(self): - with control_stdin("y"): - self.run_convert() + self.io.addinput("y") + self.run_convert() assert self.file_endswith(self.converted_mp3, "mp3") def test_no_transcode_when_maxbr_set_high_and_same_formats(self): self.config["convert"]["max_bitrate"] = 5000 self.config["convert"]["format"] = "ogg" - with control_stdin("y"): - self.run_convert() + self.io.addinput("y") + self.run_convert() assert not self.file_endswith( self.convert_dest / "converted.ogg", "ogg" ) @@ -243,8 +243,8 @@ class ConvertCliTest(ConvertTestCase, ConvertCommand): self.config["convert"]["max_bitrate"] = 5000 self.config["convert"]["format"] = "ogg" - with control_stdin("y"): - self.run_convert("--force") + self.io.addinput("y") + self.run_convert("--force") converted = self.convert_dest / "converted.ogg" assert self.file_endswith(converted, "ogg") @@ -252,21 +252,21 @@ class ConvertCliTest(ConvertTestCase, ConvertCommand): def test_transcode_when_maxbr_set_low_and_same_formats(self): self.config["convert"]["max_bitrate"] = 5 self.config["convert"]["format"] = "ogg" - with control_stdin("y"): - self.run_convert() + self.io.addinput("y") + self.run_convert() assert self.file_endswith(self.convert_dest / "converted.ogg", "ogg") def test_transcode_when_maxbr_set_to_none_and_same_formats(self): self.config["convert"]["format"] = "ogg" - with control_stdin("y"): - self.run_convert() + self.io.addinput("y") + self.run_convert() assert not self.file_endswith( self.convert_dest / "converted.ogg", "ogg" ) def test_playlist(self): - with control_stdin("y"): - self.run_convert("--playlist", "playlist.m3u8") + self.io.addinput("y") + self.run_convert("--playlist", "playlist.m3u8") assert (self.convert_dest / "playlist.m3u8").exists() def test_playlist_pretend(self): @@ -282,8 +282,8 @@ class ConvertCliTest(ConvertTestCase, ConvertCommand): [item] = self.add_item_fixtures(ext="ogg") - with control_stdin("y"): - self.run_convert_path(item, "--format", "opus", "--force") + self.io.addinput("y") + self.run_convert_path(item, "--format", "opus", "--force") converted = self.convert_dest / "converted.ops" assert self.file_endswith(converted, "opus") @@ -309,23 +309,23 @@ class NeverConvertLossyFilesTest(ConvertTestCase, ConvertCommand): def test_transcode_from_lossless(self): [item] = self.add_item_fixtures(ext="flac") - with control_stdin("y"): - self.run_convert_path(item) + self.io.addinput("y") + self.run_convert_path(item) converted = self.convert_dest / "converted.mp3" assert self.file_endswith(converted, "mp3") def test_transcode_from_lossy(self): self.config["convert"]["never_convert_lossy_files"] = False [item] = self.add_item_fixtures(ext="ogg") - with control_stdin("y"): - self.run_convert_path(item) + self.io.addinput("y") + self.run_convert_path(item) converted = self.convert_dest / "converted.mp3" assert self.file_endswith(converted, "mp3") def test_transcode_from_lossy_prevented(self): [item] = self.add_item_fixtures(ext="ogg") - with control_stdin("y"): - self.run_convert_path(item) + self.io.addinput("y") + self.run_convert_path(item) converted = self.convert_dest / "converted.ogg" assert not self.file_endswith(converted, "mp3") @@ -336,8 +336,8 @@ class NeverConvertLossyFilesTest(ConvertTestCase, ConvertCommand): } [item] = self.add_item_fixtures(ext="ogg") - with control_stdin("y"): - self.run_convert_path(item, "--format", "opus", "--force") + self.io.addinput("y") + self.run_convert_path(item, "--format", "opus", "--force") converted = self.convert_dest / "converted.ops" assert self.file_endswith(converted, "opus") diff --git a/test/plugins/test_edit.py b/test/plugins/test_edit.py index 564b2ff1a..94ab34a03 100644 --- a/test/plugins/test_edit.py +++ b/test/plugins/test_edit.py @@ -17,15 +17,16 @@ from typing import ClassVar from unittest.mock import patch from beets.dbcore.query import TrueQuery +from beets.importer import Action from beets.library import Item from beets.test import _common from beets.test.helper import ( AutotagImportTestCase, AutotagStub, BeetsTestCase, + IOMixin, PluginMixin, TerminalImportMixin, - control_stdin, ) @@ -73,7 +74,7 @@ class ModifyFileMocker: f.write(contents) -class EditMixin(PluginMixin): +class EditMixin(IOMixin, PluginMixin): """Helper containing some common functionality used for the Edit tests.""" plugin = "edit" @@ -103,24 +104,26 @@ class EditMixin(PluginMixin): """ m = ModifyFileMocker(**modify_file_args) with patch("beetsplug.edit.edit", side_effect=m.action): - with control_stdin("\n".join(stdin)): - self.importer.run() + for char in stdin: + self.importer.add_choice(char) + self.importer.run() def run_mocked_command(self, modify_file_args={}, stdin=[], args=[]): """Run the edit command, with mocked stdin and yaml writing, and passing `args` to `run_command`.""" m = ModifyFileMocker(**modify_file_args) with patch("beetsplug.edit.edit", side_effect=m.action): - with control_stdin("\n".join(stdin)): - self.run_command("edit", *args) + for char in stdin: + self.io.addinput(char) + self.run_command("edit", *args) @_common.slow_test() @patch("beets.library.Item.write") class EditCommandTest(EditMixin, BeetsTestCase): """Black box tests for `beetsplug.edit`. Command line interaction is - simulated using `test.helper.control_stdin()`, and yaml editing via an - external editor is simulated using `ModifyFileMocker`. + simulated using mocked stdin, and yaml editing via an external editor is + simulated using `ModifyFileMocker`. """ ALBUM_COUNT = 1 @@ -412,7 +415,7 @@ class EditDuringImporterNonSingletonTest(EditDuringImporterTestCase): self.run_mocked_interpreter( {}, # 1, Apply changes. - ["1", "a"], + ["1", Action.APPLY], ) # Retag and edit track titles. On retag, the importer will reset items diff --git a/test/plugins/test_importsource.py b/test/plugins/test_importsource.py index a4f498181..7306558a1 100644 --- a/test/plugins/test_importsource.py +++ b/test/plugins/test_importsource.py @@ -19,7 +19,7 @@ import os import time from beets import importer, plugins -from beets.test.helper import AutotagImportTestCase, PluginMixin, control_stdin +from beets.test.helper import AutotagImportTestCase, IOMixin, PluginMixin from beets.util import syspath from beetsplug.importsource import ImportSourcePlugin @@ -34,7 +34,7 @@ def preserve_plugin_listeners(): ImportSourcePlugin.listeners = _listeners -class ImportSourceTest(PluginMixin, AutotagImportTestCase): +class ImportSourceTest(IOMixin, PluginMixin, AutotagImportTestCase): plugin = "importsource" preload_plugin = False @@ -50,31 +50,29 @@ class ImportSourceTest(PluginMixin, AutotagImportTestCase): self.all_items = self.lib.albums().get().items() self.item_to_remove = self.all_items[0] - def interact(self, stdin_input: str): - with control_stdin(stdin_input): - self.run_command( - "remove", - f"path:{syspath(self.item_to_remove.path)}", - ) + def interact(self, stdin: list[str]): + for char in stdin: + self.io.addinput(char) + self.run_command("remove", f"path:{syspath(self.item_to_remove.path)}") def test_do_nothing(self): - self.interact("N") + self.interact(["N"]) assert os.path.exists(self.item_to_remove.source_path) def test_remove_single(self): - self.interact("y\nD") + self.interact(["y", "D"]) assert not os.path.exists(self.item_to_remove.source_path) def test_remove_all_from_single(self): - self.interact("y\nR\ny") + self.interact(["y", "R", "y"]) for item in self.all_items: assert not os.path.exists(item.source_path) def test_stop_suggesting(self): - self.interact("y\nS") + self.interact(["y", "S"]) for item in self.all_items: assert os.path.exists(item.source_path) diff --git a/test/plugins/test_mbsubmit.py b/test/plugins/test_mbsubmit.py index 712c90866..fb275462a 100644 --- a/test/plugins/test_mbsubmit.py +++ b/test/plugins/test_mbsubmit.py @@ -18,7 +18,6 @@ from beets.test.helper import ( PluginMixin, TerminalImportMixin, capture_stdout, - control_stdin, ) @@ -35,9 +34,10 @@ class MBSubmitPluginTest( def test_print_tracks_output(self): """Test the output of the "print tracks" choice.""" with capture_stdout() as output: - with control_stdin("\n".join(["p", "s"])): - # Print tracks; Skip - self.importer.run() + self.io.addinput("p") + self.io.addinput("s") + # Print tracks; Skip + self.importer.run() # Manually build the string for comparing the output. tracklist = ( @@ -50,9 +50,12 @@ class MBSubmitPluginTest( def test_print_tracks_output_as_tracks(self): """Test the output of the "print tracks" choice, as singletons.""" with capture_stdout() as output: - with control_stdin("\n".join(["t", "s", "p", "s"])): - # as Tracks; Skip; Print tracks; Skip - self.importer.run() + self.io.addinput("t") + self.io.addinput("s") + self.io.addinput("p") + self.io.addinput("s") + # as Tracks; Skip; Print tracks; Skip + self.importer.run() # Manually build the string for comparing the output. tracklist = ( diff --git a/test/plugins/test_play.py b/test/plugins/test_play.py index b184db63f..e67c8cddf 100644 --- a/test/plugins/test_play.py +++ b/test/plugins/test_play.py @@ -21,14 +21,14 @@ from unittest.mock import ANY, patch import pytest -from beets.test.helper import CleanupModulesMixin, PluginTestCase, control_stdin +from beets.test.helper import CleanupModulesMixin, PluginTestCase, IOMixin from beets.ui import UserError from beets.util import open_anything from beetsplug.play import PlayPlugin @patch("beetsplug.play.util.interactive_open") -class PlayPluginTest(CleanupModulesMixin, PluginTestCase): +class PlayPluginTest(IOMixin, CleanupModulesMixin, PluginTestCase): modules = (PlayPlugin.__module__,) plugin = "play" @@ -127,8 +127,8 @@ class PlayPluginTest(CleanupModulesMixin, PluginTestCase): self.config["play"]["warning_threshold"] = 1 self.add_item(title="another NiceTitle") - with control_stdin("a"): - self.run_command("play", "nice") + self.io.addinput("a") + self.run_command("play", "nice") open_mock.assert_not_called() @@ -138,12 +138,12 @@ class PlayPluginTest(CleanupModulesMixin, PluginTestCase): expected_playlist = f"{self.item.filepath}\n{self.other_item.filepath}" - with control_stdin("a"): - self.run_and_assert( - open_mock, - ["-y", "NiceTitle"], - expected_playlist=expected_playlist, - ) + self.io.addinput("a") + self.run_and_assert( + open_mock, + ["-y", "NiceTitle"], + expected_playlist=expected_playlist, + ) def test_command_failed(self, open_mock): open_mock.side_effect = OSError("some reason") diff --git a/test/plugins/test_zero.py b/test/plugins/test_zero.py index b08bf0dca..23eb0e3cf 100644 --- a/test/plugins/test_zero.py +++ b/test/plugins/test_zero.py @@ -3,12 +3,12 @@ from mediafile import MediaFile from beets.library import Item -from beets.test.helper import PluginTestCase, control_stdin +from beets.test.helper import IOMixin, PluginTestCase from beets.util import syspath from beetsplug.zero import ZeroPlugin -class ZeroPluginTest(PluginTestCase): +class ZeroPluginTest(IOMixin, PluginTestCase): plugin = "zero" preload_plugin = False @@ -102,12 +102,10 @@ class ZeroPluginTest(PluginTestCase): item.write() item_id = item.id - with ( - self.configure_plugin( - {"fields": ["comments"], "update_database": True, "auto": False} - ), - control_stdin("y"), + with self.configure_plugin( + {"fields": ["comments"], "update_database": True, "auto": False} ): + self.io.addinput("y") self.run_command("zero") mf = MediaFile(syspath(item.path)) @@ -125,16 +123,14 @@ class ZeroPluginTest(PluginTestCase): item.write() item_id = item.id - with ( - self.configure_plugin( - { - "fields": ["comments"], - "update_database": False, - "auto": False, - } - ), - control_stdin("y"), + with self.configure_plugin( + { + "fields": ["comments"], + "update_database": False, + "auto": False, + } ): + self.io.addinput("y") self.run_command("zero") mf = MediaFile(syspath(item.path)) @@ -187,7 +183,8 @@ class ZeroPluginTest(PluginTestCase): item_id = item.id - with self.configure_plugin({"fields": []}), control_stdin("y"): + with self.configure_plugin({"fields": []}): + self.io.addinput("y") self.run_command("zero") item = self.lib.get_item(item_id) @@ -203,12 +200,10 @@ class ZeroPluginTest(PluginTestCase): item_id = item.id - with ( - self.configure_plugin( - {"fields": ["year"], "keep_fields": ["comments"]} - ), - control_stdin("y"), + with self.configure_plugin( + {"fields": ["year"], "keep_fields": ["comments"]} ): + self.io.addinput("y") self.run_command("zero") item = self.lib.get_item(item_id) @@ -303,12 +298,10 @@ class ZeroPluginTest(PluginTestCase): ) item.write() item_id = item.id - with ( - self.configure_plugin( - {"fields": ["comments"], "update_database": True, "auto": False} - ), - control_stdin("n"), + with self.configure_plugin( + {"fields": ["comments"], "update_database": True, "auto": False} ): + self.io.addinput("n") self.run_command("zero") mf = MediaFile(syspath(item.path)) diff --git a/test/test_plugins.py b/test/test_plugins.py index 53f24c13d..c23ea0c7a 100644 --- a/test/test_plugins.py +++ b/test/test_plugins.py @@ -429,8 +429,9 @@ class PromptChoicesTest(TerminalImportMixin, PluginImportTestCase): # DummyPlugin.foo() should be called once with patch.object(DummyPlugin, "foo", autospec=True) as mock_foo: - with helper.control_stdin("\n".join(["f", "s"])): - self.importer.run() + self.io.addinput("f") + self.io.addinput("n") + self.importer.run() assert mock_foo.call_count == 1 # input_options should be called twice, as foo() returns None @@ -471,8 +472,8 @@ class PromptChoicesTest(TerminalImportMixin, PluginImportTestCase): ) # DummyPlugin.foo() should be called once - with helper.control_stdin("f\n"): - self.importer.run() + self.io.addinput("f") + self.importer.run() # input_options should be called once, as foo() returns SKIP self.mock_input_options.assert_called_once_with( diff --git a/test/ui/commands/test_modify.py b/test/ui/commands/test_modify.py index 77d378032..3e7a63d90 100644 --- a/test/ui/commands/test_modify.py +++ b/test/ui/commands/test_modify.py @@ -2,23 +2,24 @@ import unittest from mediafile import MediaFile -from beets.test.helper import BeetsTestCase, control_stdin +from beets.test.helper import BeetsTestCase, IOMixin from beets.ui.commands.modify import modify_parse_args from beets.util import syspath -class ModifyTest(BeetsTestCase): +class ModifyTest(IOMixin, BeetsTestCase): def setUp(self): super().setUp() self.album = self.add_album_fixture() [self.item] = self.album.items() - def modify_inp(self, inp, *args): - with control_stdin(inp): - self.run_command("modify", *args) + def modify_inp(self, inp: list[str], *args): + for chat in inp: + self.io.addinput(chat) + self.run_command("modify", *args) def modify(self, *args): - self.modify_inp("y", *args) + self.modify_inp(["y"], *args) # Item tests @@ -30,14 +31,14 @@ class ModifyTest(BeetsTestCase): def test_modify_item_abort(self): item = self.lib.items().get() title = item.title - self.modify_inp("n", "title=newTitle") + self.modify_inp(["n"], "title=newTitle") item = self.lib.items().get() assert item.title == title def test_modify_item_no_change(self): title = "Tracktitle" item = self.add_item_fixture(title=title) - self.modify_inp("y", "title", f"title={title}") + self.modify_inp(["y"], "title", f"title={title}") item = self.lib.items(title).get() assert item.title == title @@ -96,7 +97,9 @@ class ModifyTest(BeetsTestCase): title=f"{title}{i}", artist=original_artist, album=album ) self.modify_inp( - "s\ny\ny\ny\nn\nn\ny\ny\ny\ny\nn", title, f"artist={new_artist}" + ["s", "y", "y", "y", "n", "n", "y", "y", "y", "y", "n"], + title, + f"artist={new_artist}", ) original_items = self.lib.items(f"artist:{original_artist}") new_items = self.lib.items(f"artist:{new_artist}") diff --git a/test/ui/test_ui_init.py b/test/ui/test_ui_init.py index f6c9fe245..00e0a6fe5 100644 --- a/test/ui/test_ui_init.py +++ b/test/ui/test_ui_init.py @@ -22,7 +22,7 @@ from random import random from beets import config, ui from beets.test import _common -from beets.test.helper import BeetsTestCase, IOMixin, control_stdin +from beets.test.helper import BeetsTestCase, IOMixin class InputMethodsTest(IOMixin, unittest.TestCase): @@ -85,7 +85,7 @@ class InputMethodsTest(IOMixin, unittest.TestCase): assert items == ["1", "3"] -class ParentalDirCreation(BeetsTestCase): +class ParentalDirCreation(IOMixin, BeetsTestCase): def test_create_yes(self): non_exist_path = _common.os.fsdecode( os.path.join(self.temp_dir, b"nonexist", str(random()).encode()) @@ -94,8 +94,8 @@ class ParentalDirCreation(BeetsTestCase): # occur; wish I can use a golang defer here. test_config = deepcopy(config) test_config["library"] = non_exist_path - with control_stdin("y"): - lib = ui._open_library(test_config) + self.io.addinput("y") + lib = ui._open_library(test_config) lib._close() def test_create_no(self): @@ -108,14 +108,14 @@ class ParentalDirCreation(BeetsTestCase): test_config = deepcopy(config) test_config["library"] = non_exist_path - with control_stdin("n"): - try: - lib = ui._open_library(test_config) - except ui.UserError: - if os.path.exists(non_exist_path_parent): - shutil.rmtree(non_exist_path_parent) - raise OSError("Parent directories should not be created.") - else: - if lib: - lib._close() + self.io.addinput("n") + try: + lib = ui._open_library(test_config) + except ui.UserError: + if os.path.exists(non_exist_path_parent): + shutil.rmtree(non_exist_path_parent) raise OSError("Parent directories should not be created.") + else: + if lib: + lib._close() + raise OSError("Parent directories should not be created.") From 9372371004526b69028a8a88d1690ab094c50449 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Fri, 16 Jan 2026 21:40:19 +0000 Subject: [PATCH 5/5] Replace capture_output with io.getoutput --- beets/test/helper.py | 65 ++++++++++-------------------- test/plugins/test_bareasc.py | 16 +++----- test/plugins/test_convert.py | 2 +- test/plugins/test_edit.py | 4 +- test/plugins/test_export.py | 4 +- test/plugins/test_fetchart.py | 4 +- test/plugins/test_info.py | 4 +- test/plugins/test_lastgenre.py | 4 +- test/plugins/test_limit.py | 4 +- test/plugins/test_mbsubmit.py | 27 ++++++------- test/plugins/test_missing.py | 19 ++++----- test/plugins/test_play.py | 2 +- test/plugins/test_smartplaylist.py | 4 +- test/plugins/test_types_plugin.py | 4 +- test/test_metasync.py | 4 +- test/test_plugins.py | 3 +- test/ui/commands/test_config.py | 4 +- test/ui/commands/test_list.py | 37 +++++++++-------- test/ui/commands/test_write.py | 4 +- test/ui/test_ui.py | 2 +- 20 files changed, 94 insertions(+), 123 deletions(-) diff --git a/beets/test/helper.py b/beets/test/helper.py index 3f6b18336..bd3dc59d6 100644 --- a/beets/test/helper.py +++ b/beets/test/helper.py @@ -15,9 +15,6 @@ """This module includes various helpers that provide fixtures, capture information or mock the environment. -- `capture_stdout` context managers allow one to interact with the user - interface. - - `has_program` checks the presence of a command on the system. - The `ImportSessionFixture` allows one to run importer code while @@ -38,7 +35,6 @@ from contextlib import contextmanager from dataclasses import dataclass from enum import Enum from functools import cached_property -from io import StringIO from pathlib import Path from tempfile import gettempdir, mkdtemp, mkstemp from typing import Any, ClassVar @@ -84,25 +80,6 @@ def capture_log(logger="beets"): log.removeHandler(capture) -@contextmanager -def capture_stdout(): - """Save stdout in a StringIO. - - >>> with capture_stdout() as output: - ... print('spam') - ... - >>> output.getvalue() - 'spam' - """ - org = sys.stdout - sys.stdout = capture = StringIO() - try: - yield sys.stdout - finally: - sys.stdout = org - print(capture.getvalue()) - - def has_program(cmd, args=["--version"]): """Returns `True` if `cmd` can be executed.""" full_cmd = [cmd, *args] @@ -148,12 +125,31 @@ NEEDS_REFLINK = unittest.skipUnless( ) +class RunMixin: + def run_command(self, *args, **kwargs): + """Run a beets command with an arbitrary amount of arguments. The + Library` defaults to `self.lib`, but can be overridden with + the keyword argument `lib`. + """ + sys.argv = ["beet"] # avoid leakage from test suite args + lib = None + if hasattr(self, "lib"): + lib = self.lib + lib = kwargs.get("lib", lib) + beets.ui._raw_main(list(args), lib) + + @pytest.mark.usefixtures("io") -class IOMixin: +class IOMixin(RunMixin): io: _common.DummyIO + def run_with_output(self, *args): + self.io.getoutput() + self.run_command(*args) + return self.io.getoutput() -class TestHelper(ConfigMixin): + +class TestHelper(RunMixin, ConfigMixin): """Helper mixin for high-level cli and plugin tests. This mixin provides methods to isolate beets' global state provide @@ -366,25 +362,6 @@ class TestHelper(ConfigMixin): return path - # Running beets commands - - def run_command(self, *args, **kwargs): - """Run a beets command with an arbitrary amount of arguments. The - Library` defaults to `self.lib`, but can be overridden with - the keyword argument `lib`. - """ - sys.argv = ["beet"] # avoid leakage from test suite args - lib = None - if hasattr(self, "lib"): - lib = self.lib - lib = kwargs.get("lib", lib) - beets.ui._raw_main(list(args), lib) - - def run_with_output(self, *args): - with capture_stdout() as out: - self.run_command(*args) - return out.getvalue() - # Safe file operations def create_temp_dir(self, **kwargs) -> str: diff --git a/test/plugins/test_bareasc.py b/test/plugins/test_bareasc.py index e699a3dcf..a661ae7aa 100644 --- a/test/plugins/test_bareasc.py +++ b/test/plugins/test_bareasc.py @@ -4,10 +4,10 @@ """Tests for the 'bareasc' plugin.""" from beets import logging -from beets.test.helper import PluginTestCase, capture_stdout +from beets.test.helper import IOMixin, PluginTestCase -class BareascPluginTest(PluginTestCase): +class BareascPluginTest(IOMixin, PluginTestCase): """Test bare ASCII query matching.""" plugin = "bareasc" @@ -65,16 +65,12 @@ class BareascPluginTest(PluginTestCase): def test_bareasc_list_output(self): """Bare-ASCII version of list command - check output.""" - with capture_stdout() as output: - self.run_command("bareasc", "with accents") + self.run_command("bareasc", "with accents") - assert "Antonin Dvorak" in output.getvalue() + assert "Antonin Dvorak" in self.io.getoutput() def test_bareasc_format_output(self): """Bare-ASCII version of list -f command - check output.""" - with capture_stdout() as output: - self.run_command( - "bareasc", "with accents", "-f", "$artist:: $title" - ) + self.run_command("bareasc", "with accents", "-f", "$artist:: $title") - assert "Antonin Dvorak:: with accents\n" == output.getvalue() + assert "Antonin Dvorak:: with accents\n" == self.io.getoutput() diff --git a/test/plugins/test_convert.py b/test/plugins/test_convert.py index de2650617..13dbea084 100644 --- a/test/plugins/test_convert.py +++ b/test/plugins/test_convert.py @@ -29,8 +29,8 @@ from beets.test import _common from beets.test.helper import ( AsIsImporterMixin, ImportHelper, - PluginTestCase, IOMixin, + PluginTestCase, capture_log, ) from beetsplug import convert diff --git a/test/plugins/test_edit.py b/test/plugins/test_edit.py index 94ab34a03..06c7cad74 100644 --- a/test/plugins/test_edit.py +++ b/test/plugins/test_edit.py @@ -74,7 +74,7 @@ class ModifyFileMocker: f.write(contents) -class EditMixin(IOMixin, PluginMixin): +class EditMixin(PluginMixin): """Helper containing some common functionality used for the Edit tests.""" plugin = "edit" @@ -120,7 +120,7 @@ class EditMixin(IOMixin, PluginMixin): @_common.slow_test() @patch("beets.library.Item.write") -class EditCommandTest(EditMixin, BeetsTestCase): +class EditCommandTest(IOMixin, EditMixin, BeetsTestCase): """Black box tests for `beetsplug.edit`. Command line interaction is simulated using mocked stdin, and yaml editing via an external editor is simulated using `ModifyFileMocker`. diff --git a/test/plugins/test_export.py b/test/plugins/test_export.py index f37a0d2a7..3c795b2dc 100644 --- a/test/plugins/test_export.py +++ b/test/plugins/test_export.py @@ -19,10 +19,10 @@ import re # used to test csv format from xml.etree import ElementTree from xml.etree.ElementTree import Element -from beets.test.helper import PluginTestCase +from beets.test.helper import IOMixin, PluginTestCase -class ExportPluginTest(PluginTestCase): +class ExportPluginTest(IOMixin, PluginTestCase): plugin = "export" def setUp(self): diff --git a/test/plugins/test_fetchart.py b/test/plugins/test_fetchart.py index 96d882e9a..f347ed66a 100644 --- a/test/plugins/test_fetchart.py +++ b/test/plugins/test_fetchart.py @@ -18,10 +18,10 @@ import os import sys from beets import util -from beets.test.helper import PluginTestCase +from beets.test.helper import IOMixin, PluginTestCase -class FetchartCliTest(PluginTestCase): +class FetchartCliTest(IOMixin, PluginTestCase): plugin = "fetchart" def setUp(self): diff --git a/test/plugins/test_info.py b/test/plugins/test_info.py index c1b3fc941..3ad4d0884 100644 --- a/test/plugins/test_info.py +++ b/test/plugins/test_info.py @@ -15,11 +15,11 @@ from mediafile import MediaFile -from beets.test.helper import PluginTestCase +from beets.test.helper import IOMixin, PluginTestCase from beets.util import displayable_path -class InfoTest(PluginTestCase): +class InfoTest(IOMixin, PluginTestCase): plugin = "info" def test_path(self): diff --git a/test/plugins/test_lastgenre.py b/test/plugins/test_lastgenre.py index 026001e38..7c9deee35 100644 --- a/test/plugins/test_lastgenre.py +++ b/test/plugins/test_lastgenre.py @@ -19,11 +19,11 @@ from unittest.mock import Mock, patch import pytest from beets.test import _common -from beets.test.helper import PluginTestCase +from beets.test.helper import IOMixin, PluginTestCase from beetsplug import lastgenre -class LastGenrePluginTest(PluginTestCase): +class LastGenrePluginTest(IOMixin, PluginTestCase): plugin = "lastgenre" def setUp(self): diff --git a/test/plugins/test_limit.py b/test/plugins/test_limit.py index d77e47ca8..8f227d41e 100644 --- a/test/plugins/test_limit.py +++ b/test/plugins/test_limit.py @@ -13,10 +13,10 @@ """Tests for the 'limit' plugin.""" -from beets.test.helper import PluginTestCase +from beets.test.helper import IOMixin, PluginTestCase -class LimitPluginTest(PluginTestCase): +class LimitPluginTest(IOMixin, PluginTestCase): """Unit tests for LimitPlugin Note: query prefix tests do not work correctly with `run_with_output`. diff --git a/test/plugins/test_mbsubmit.py b/test/plugins/test_mbsubmit.py index fb275462a..48426fd7d 100644 --- a/test/plugins/test_mbsubmit.py +++ b/test/plugins/test_mbsubmit.py @@ -17,7 +17,6 @@ from beets.test.helper import ( AutotagImportTestCase, PluginMixin, TerminalImportMixin, - capture_stdout, ) @@ -33,11 +32,10 @@ class MBSubmitPluginTest( def test_print_tracks_output(self): """Test the output of the "print tracks" choice.""" - with capture_stdout() as output: - self.io.addinput("p") - self.io.addinput("s") - # Print tracks; Skip - self.importer.run() + self.io.addinput("p") + self.io.addinput("s") + # Print tracks; Skip + self.importer.run() # Manually build the string for comparing the output. tracklist = ( @@ -45,20 +43,19 @@ class MBSubmitPluginTest( "01. Tag Track 1 - Tag Artist (0:01)\n" "02. Tag Track 2 - Tag Artist (0:01)" ) - assert tracklist in output.getvalue() + assert tracklist in self.io.getoutput() def test_print_tracks_output_as_tracks(self): """Test the output of the "print tracks" choice, as singletons.""" - with capture_stdout() as output: - self.io.addinput("t") - self.io.addinput("s") - self.io.addinput("p") - self.io.addinput("s") - # as Tracks; Skip; Print tracks; Skip - self.importer.run() + self.io.addinput("t") + self.io.addinput("s") + self.io.addinput("p") + self.io.addinput("s") + # as Tracks; Skip; Print tracks; Skip + self.importer.run() # Manually build the string for comparing the output. tracklist = ( "Open files with Picard? 02. Tag Track 2 - Tag Artist (0:01)" ) - assert tracklist in output.getvalue() + assert tracklist in self.io.getoutput() diff --git a/test/plugins/test_missing.py b/test/plugins/test_missing.py index d12f2b4cf..812ed5fa3 100644 --- a/test/plugins/test_missing.py +++ b/test/plugins/test_missing.py @@ -3,20 +3,23 @@ import uuid import pytest from beets.library import Album -from beets.test.helper import PluginMixin, TestHelper +from beets.test.helper import IOMixin, PluginMixin, TestHelper @pytest.fixture -def helper(): +def helper(request): helper = TestHelper() helper.setup_beets() - yield helper + request.instance.lib = helper.lib + + yield helper.teardown_beets() -class TestMissingAlbums(PluginMixin): +@pytest.mark.usefixtures("helper") +class TestMissingAlbums(IOMixin, PluginMixin): plugin = "missing" album_in_lib = Album( album="Album", @@ -47,15 +50,13 @@ class TestMissingAlbums(PluginMixin): ], ) def test_missing_artist_albums( - self, requests_mock, helper, release_from_mb, expected_output + self, requests_mock, release_from_mb, expected_output ): - helper.lib.add(self.album_in_lib) + self.lib.add(self.album_in_lib) requests_mock.get( f"/ws/2/release-group?artist={self.album_in_lib.mb_albumartistid}", json={"release-groups": [release_from_mb]}, ) with self.configure_plugin({}): - assert ( - helper.run_with_output("missing", "--album") == expected_output - ) + assert self.run_with_output("missing", "--album") == expected_output diff --git a/test/plugins/test_play.py b/test/plugins/test_play.py index e67c8cddf..53de21233 100644 --- a/test/plugins/test_play.py +++ b/test/plugins/test_play.py @@ -21,7 +21,7 @@ from unittest.mock import ANY, patch import pytest -from beets.test.helper import CleanupModulesMixin, PluginTestCase, IOMixin +from beets.test.helper import CleanupModulesMixin, IOMixin, PluginTestCase from beets.ui import UserError from beets.util import open_anything from beetsplug.play import PlayPlugin diff --git a/test/plugins/test_smartplaylist.py b/test/plugins/test_smartplaylist.py index 8ec2c74ce..7cc712330 100644 --- a/test/plugins/test_smartplaylist.py +++ b/test/plugins/test_smartplaylist.py @@ -24,7 +24,7 @@ import pytest from beets import config from beets.dbcore.query import FixedFieldSort, MultipleSort, NullSort from beets.library import Album, Item, parse_query_string -from beets.test.helper import BeetsTestCase, PluginTestCase +from beets.test.helper import BeetsTestCase, IOMixin, PluginTestCase from beets.ui import UserError from beets.util import CHAR_REPLACE, syspath from beetsplug.smartplaylist import SmartPlaylistPlugin @@ -458,7 +458,7 @@ class SmartPlaylistTest(BeetsTestCase): assert content.count(b"/item2.mp3") == 1 -class SmartPlaylistCLITest(PluginTestCase): +class SmartPlaylistCLITest(IOMixin, PluginTestCase): plugin = "smartplaylist" def setUp(self): diff --git a/test/plugins/test_types_plugin.py b/test/plugins/test_types_plugin.py index 41807b80d..24fb577f7 100644 --- a/test/plugins/test_types_plugin.py +++ b/test/plugins/test_types_plugin.py @@ -19,10 +19,10 @@ from datetime import datetime import pytest from confuse import ConfigValueError -from beets.test.helper import PluginTestCase +from beets.test.helper import IOMixin, PluginTestCase -class TypesPluginTest(PluginTestCase): +class TypesPluginTest(IOMixin, PluginTestCase): plugin = "types" def test_integer_modify_and_query(self): diff --git a/test/test_metasync.py b/test/test_metasync.py index 13c003a1c..aeb38545b 100644 --- a/test/test_metasync.py +++ b/test/test_metasync.py @@ -20,7 +20,7 @@ from datetime import datetime from beets.library import Item from beets.test import _common -from beets.test.helper import PluginTestCase +from beets.test.helper import IOMixin, PluginTestCase def _parsetime(s): @@ -31,7 +31,7 @@ def _is_windows(): return platform.system() == "Windows" -class MetaSyncTest(PluginTestCase): +class MetaSyncTest(IOMixin, PluginTestCase): plugin = "metasync" itunes_library_unix = os.path.join(_common.RSRC, b"itunes_library_unix.xml") itunes_library_windows = os.path.join( diff --git a/test/test_plugins.py b/test/test_plugins.py index c23ea0c7a..4786b12b4 100644 --- a/test/test_plugins.py +++ b/test/test_plugins.py @@ -38,6 +38,7 @@ from beets.test import helper from beets.test.helper import ( AutotagStub, ImportHelper, + IOMixin, PluginMixin, PluginTestCase, TerminalImportMixin, @@ -45,7 +46,7 @@ from beets.test.helper import ( from beets.util import PromptChoice, displayable_path, syspath -class TestPluginRegistration(PluginTestCase): +class TestPluginRegistration(IOMixin, PluginTestCase): class RatingPlugin(plugins.BeetsPlugin): item_types: ClassVar[dict[str, types.Type]] = { "rating": types.Float(), diff --git a/test/ui/commands/test_config.py b/test/ui/commands/test_config.py index c1215ef43..cd83b919f 100644 --- a/test/ui/commands/test_config.py +++ b/test/ui/commands/test_config.py @@ -5,10 +5,10 @@ import pytest import yaml from beets import config, ui -from beets.test.helper import BeetsTestCase +from beets.test.helper import BeetsTestCase, IOMixin -class ConfigCommandTest(BeetsTestCase): +class ConfigCommandTest(IOMixin, BeetsTestCase): def setUp(self): super().setUp() for k in ("VISUAL", "EDITOR"): diff --git a/test/ui/commands/test_list.py b/test/ui/commands/test_list.py index a63a56ad1..372d75410 100644 --- a/test/ui/commands/test_list.py +++ b/test/ui/commands/test_list.py @@ -1,9 +1,9 @@ from beets.test import _common -from beets.test.helper import BeetsTestCase, capture_stdout +from beets.test.helper import BeetsTestCase, IOMixin from beets.ui.commands.list import list_items -class ListTest(BeetsTestCase): +class ListTest(IOMixin, BeetsTestCase): def setUp(self): super().setUp() self.item = _common.item() @@ -12,13 +12,12 @@ class ListTest(BeetsTestCase): self.lib.add_album([self.item]) def _run_list(self, query="", album=False, path=False, fmt=""): - with capture_stdout() as stdout: - list_items(self.lib, query, album, fmt) - return stdout + list_items(self.lib, query, album, fmt) + return self.io.getoutput() def test_list_outputs_item(self): stdout = self._run_list() - assert "the title" in stdout.getvalue() + assert "the title" in stdout def test_list_unicode_query(self): self.item.title = "na\xefve" @@ -26,44 +25,44 @@ class ListTest(BeetsTestCase): self.lib._connection().commit() stdout = self._run_list(["na\xefve"]) - out = stdout.getvalue() + out = stdout assert "na\xefve" in out def test_list_item_path(self): stdout = self._run_list(fmt="$path") - assert stdout.getvalue().strip() == "xxx/yyy" + assert stdout.strip() == "xxx/yyy" def test_list_album_outputs_something(self): stdout = self._run_list(album=True) - assert len(stdout.getvalue()) > 0 + assert len(stdout) > 0 def test_list_album_path(self): stdout = self._run_list(album=True, fmt="$path") - assert stdout.getvalue().strip() == "xxx" + assert stdout.strip() == "xxx" def test_list_album_omits_title(self): stdout = self._run_list(album=True) - assert "the title" not in stdout.getvalue() + assert "the title" not in stdout def test_list_uses_track_artist(self): stdout = self._run_list() - assert "the artist" in stdout.getvalue() - assert "the album artist" not in stdout.getvalue() + assert "the artist" in stdout + assert "the album artist" not in stdout def test_list_album_uses_album_artist(self): stdout = self._run_list(album=True) - assert "the artist" not in stdout.getvalue() - assert "the album artist" in stdout.getvalue() + assert "the artist" not in stdout + assert "the album artist" in stdout def test_list_item_format_artist(self): stdout = self._run_list(fmt="$artist") - assert "the artist" in stdout.getvalue() + assert "the artist" in stdout def test_list_item_format_multiple(self): stdout = self._run_list(fmt="$artist - $album - $year") - assert "the artist - the album - 0001" == stdout.getvalue().strip() + assert "the artist - the album - 0001" == stdout.strip() def test_list_album_format(self): stdout = self._run_list(album=True, fmt="$genre") - assert "the genre" in stdout.getvalue() - assert "the album" not in stdout.getvalue() + assert "the genre" in stdout + assert "the album" not in stdout diff --git a/test/ui/commands/test_write.py b/test/ui/commands/test_write.py index 312b51dd2..7197cbded 100644 --- a/test/ui/commands/test_write.py +++ b/test/ui/commands/test_write.py @@ -1,7 +1,7 @@ -from beets.test.helper import BeetsTestCase +from beets.test.helper import BeetsTestCase, IOMixin -class WriteTest(BeetsTestCase): +class WriteTest(IOMixin, BeetsTestCase): def write_cmd(self, *args): return self.run_with_output("write", *args) diff --git a/test/ui/test_ui.py b/test/ui/test_ui.py index f96d2c76a..577954a85 100644 --- a/test/ui/test_ui.py +++ b/test/ui/test_ui.py @@ -398,7 +398,7 @@ class PluginTest(TestPluginTestCase): self.run_command("test", lib=None) -class CommonOptionsParserCliTest(BeetsTestCase): +class CommonOptionsParserCliTest(IOMixin, BeetsTestCase): """Test CommonOptionsParser and formatting LibModel formatting on 'list' command. """