diff --git a/picard/debug_opts.py b/picard/debug_opts.py new file mode 100644 index 00000000000..20685ebac0b --- /dev/null +++ b/picard/debug_opts.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +# +# Picard, the next-generation MusicBrainz tagger +# +# Copyright (C) 2024 Laurent Monin +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +from enum import Enum + + +class DebugOptEnum(int, Enum): + __registry__ = set() + + def __new__(cls, value: int, title: str, description: str) -> None: + value = int(value) + obj = super().__new__(cls, value) + obj._value_ = value + obj.title = title + obj.description = description + return obj + + @property + def optname(self): + return self.name.lower() + + @property + def enabled(self): + return self in self.__registry__ + + @enabled.setter + def enabled(self, enable: bool): + if enable: + self.__registry__.add(self) + else: + self.__registry__.discard(self) + + @classmethod + def opt_names(cls): + """Returns a comma-separated list of all possible debug options""" + return ','.join(sorted(o.optname for o in cls)) + + @classmethod + def from_string(cls, string: str): + """Parse command line argument, a string with comma-separated values, + and enable corresponding debug options""" + opts = {str(o).strip().lower() for o in string.split(',')} + for o in cls: + o.enabled = o.optname in opts + + @classmethod + def to_string(cls): + """Returns a comma-separated list of all enabled debug options""" + return ','.join(sorted(o.optname for o in cls.__registry__)) + + @classmethod + def set_registry(cls, registry: set): + """Defines a new set to store enabled debug options""" + cls.__registry__ = registry + + @classmethod + def get_registry(cls): + """Returns current storage for enabled debug options""" + return cls.__registry__ + + +class DebugOpt(DebugOptEnum): + WS_REPLIES = 1, N_('WS Replies'), N_('Log web service replies') diff --git a/picard/tagger.py b/picard/tagger.py index 8502c65134c..9115fbf8641 100644 --- a/picard/tagger.py +++ b/picard/tagger.py @@ -101,6 +101,7 @@ IS_WIN, ) from picard.dataobj import DataObject +from picard.debug_opts import DebugOpt from picard.disc import ( Disc, dbpoweramplog, @@ -259,6 +260,9 @@ def __init__(self, picard_args, localedir, autoupdate, pipe_handler=None): if picard_args.audit: setup_audit(picard_args.audit) + if picard_args.debug_opts: + DebugOpt.from_string(picard_args.debug_opts) + # Main thread pool used for most background tasks self.thread_pool = QtCore.QThreadPool(self) # Two threads are needed for the pipe handler and command processing. @@ -1464,6 +1468,9 @@ def process_picard_args(): help="do not load any plugins") parser.add_argument('--no-crash-dialog', action='store_true', help="disable the crash dialog") + parser.add_argument('--debug-opts', action='store', + default=None, + help="Comma-separated list of debug options to enable: %s" % DebugOpt.opt_names()) parser.add_argument('-s', '--stand-alone-instance', action='store_true', help="force Picard to create a new, stand-alone instance") parser.add_argument('-v', '--version', action='store_true', diff --git a/picard/ui/logview.py b/picard/ui/logview.py index fad3920822c..50cd7edea30 100644 --- a/picard/ui/logview.py +++ b/picard/ui/logview.py @@ -43,6 +43,7 @@ IntOption, get_config, ) +from picard.debug_opts import DebugOpt from picard.util import ( reconnect, wildcards_to_regex_pattern, @@ -159,6 +160,23 @@ def set_verbosity(self, level): self.actions[level].setChecked(True) +class DebugOptsMenu(QtWidgets.QMenu): + + def __init__(self, parent=None): + super().__init__(parent=parent) + + self.actions = {} + for debug_opt in DebugOpt: + action = QtGui.QAction(_(debug_opt.title), self, checkable=True, checked=debug_opt.enabled) + action.setToolTip(_(debug_opt.description)) + action.triggered.connect(partial(self.debug_opt_changed, debug_opt)) + self.addAction(action) + self.actions[debug_opt] = action + + def debug_opt_changed(self, debug_opt, checked): + debug_opt.enabled = checked + + class LogView(LogViewCommon): options = [ @@ -181,10 +199,18 @@ def __init__(self, parent=None): self.hbox.addWidget(self.verbosity_menu_button) self.verbosity_menu = VerbosityMenu() - self._set_verbosity(self.verbosity) self.verbosity_menu.verbosity_changed.connect(self._verbosity_changed) self.verbosity_menu_button.setMenu(self.verbosity_menu) + self.debug_opts_menu_button = QtWidgets.QPushButton(_("Debug Options")) + self.debug_opts_menu_button.setAccessibleName(_("Debug Options")) + self.hbox.addWidget(self.debug_opts_menu_button) + + self.debug_opts_menu = DebugOptsMenu() + self.debug_opts_menu_button.setMenu(self.debug_opts_menu) + + self._set_verbosity(self.verbosity) + # highlight input self.highlight_text = QtWidgets.QLineEdit() self.highlight_text.setPlaceholderText(_("String to highlight")) @@ -331,6 +357,7 @@ def _update_verbosity_label(self): feat = log.levels_features.get(self.verbosity) label = _(feat.name) if feat else _("Verbosity") self.verbosity_menu_button.setText(label) + self.debug_opts_menu_button.setEnabled(self.verbosity == logging.DEBUG) class HistoryView(LogViewCommon): diff --git a/picard/webservice/__init__.py b/picard/webservice/__init__.py index e50ad01c529..1edeca18f74 100644 --- a/picard/webservice/__init__.py +++ b/picard/webservice/__init__.py @@ -60,6 +60,7 @@ CACHE_SIZE_IN_BYTES, appdirs, ) +from picard.debug_opts import DebugOpt from picard.oauth import OAuthManager from picard.util import ( build_qurl, @@ -530,7 +531,8 @@ def _handle_reply(self, reply, request): elif request.response_parser: try: document = request.response_parser(reply) - log.debug("Response received: %s", document) + if DebugOpt.WS_REPLIES.enabled: + log.debug("Response received: %s", document) except Exception as e: log.error("Unable to parse the response for %s -> %s", display_reply_url, e) document = reply.readAll() diff --git a/test/test_debug_opt.py b/test/test_debug_opt.py new file mode 100644 index 00000000000..8e6b3c4fb45 --- /dev/null +++ b/test/test_debug_opt.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- +# +# Picard, the next-generation MusicBrainz tagger +# +# Copyright (C) 2024 Laurent Monin +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +from test.picardtestcase import PicardTestCase + +from picard.debug_opts import DebugOptEnum + + +class TestDebugOpt(DebugOptEnum): + A = 1, 'titleA', 'descriptionA' + B = 2, 'titleB', 'descriptionB' + + +class DebugOptTest(PicardTestCase): + def setUp(self): + TestDebugOpt.set_registry(set()) + + def test_enabled(self): + self.assertFalse(TestDebugOpt.A.enabled) + self.assertFalse(TestDebugOpt.B.enabled) + TestDebugOpt.A.enabled = True + self.assertTrue(TestDebugOpt.A.enabled) + + def test_optname(self): + self.assertEqual(TestDebugOpt.A.optname, 'a') + self.assertEqual(TestDebugOpt.B.optname, 'b') + + def test_title(self): + self.assertEqual(TestDebugOpt.A.title, 'titleA') + self.assertEqual(TestDebugOpt.B.title, 'titleB') + + def test_description(self): + self.assertEqual(TestDebugOpt.A.description, 'descriptionA') + self.assertEqual(TestDebugOpt.B.description, 'descriptionB') + + def test_opt_names(self): + self.assertEqual(TestDebugOpt.opt_names(), 'a,b') + + def test_from_string_simple(self): + TestDebugOpt.from_string('a') + self.assertTrue(TestDebugOpt.A.enabled) + self.assertFalse(TestDebugOpt.B.enabled) + TestDebugOpt.from_string('a,b') + self.assertTrue(TestDebugOpt.A.enabled) + self.assertTrue(TestDebugOpt.B.enabled) + + def test_from_string_complex(self): + TestDebugOpt.from_string('something, A,x,b') + self.assertTrue(TestDebugOpt.A.enabled) + self.assertTrue(TestDebugOpt.B.enabled) + + def test_from_string_remove(self): + TestDebugOpt.set_registry({TestDebugOpt.B}) + self.assertTrue(TestDebugOpt.B.enabled) + TestDebugOpt.from_string('A') + self.assertTrue(TestDebugOpt.A.enabled) + self.assertFalse(TestDebugOpt.B.enabled) + + def test_to_string(self): + self.assertEqual('', TestDebugOpt.to_string()) + TestDebugOpt.A.enabled = True + self.assertEqual('a', TestDebugOpt.to_string()) + TestDebugOpt.B.enabled = True + self.assertEqual('a,b', TestDebugOpt.to_string()) + + def test_set_get_registry(self): + old_set = TestDebugOpt.get_registry() + TestDebugOpt.A.enabled = True + self.assertTrue(TestDebugOpt.A.enabled) + new_set = set() + TestDebugOpt.set_registry(new_set) + self.assertFalse(TestDebugOpt.A.enabled) + TestDebugOpt.B.enabled = True + self.assertFalse(TestDebugOpt.A.enabled) + self.assertTrue(TestDebugOpt.B.enabled) + TestDebugOpt.set_registry(old_set) + self.assertTrue(TestDebugOpt.A.enabled) + self.assertFalse(TestDebugOpt.B.enabled)