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] 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.")