From e613a57ca4e31605536009f929be8165bf57a068 Mon Sep 17 00:00:00 2001 From: Nicholas Boyd Isacsson Date: Wed, 10 Jul 2024 23:47:56 +0200 Subject: [PATCH 01/15] Use case insensitive regex for substitute --- beetsplug/substitute.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beetsplug/substitute.py b/beetsplug/substitute.py index 94b790075c..e2ed7eca30 100644 --- a/beetsplug/substitute.py +++ b/beetsplug/substitute.py @@ -34,7 +34,7 @@ def tmpl_substitute(self, text): """Do the actual replacing.""" if text: for pattern, replacement in self.substitute_rules: - if pattern.match(text.lower()): + if pattern.match(text): return replacement return text else: @@ -52,5 +52,5 @@ def __init__(self): for key, view in self.config.items(): value = view.as_str() - pattern = re.compile(key.lower()) + pattern = re.compile(key, flags=re.IGNORECASE) self.substitute_rules.append((pattern, value)) From 7b5d818603db6fcd8a4b361ab14ce143bb5c5bae Mon Sep 17 00:00:00 2001 From: Nicholas Boyd Isacsson Date: Wed, 10 Jul 2024 23:49:15 +0200 Subject: [PATCH 02/15] Use a regex substitution in substitute --- beetsplug/substitute.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/beetsplug/substitute.py b/beetsplug/substitute.py index e2ed7eca30..4034738ac9 100644 --- a/beetsplug/substitute.py +++ b/beetsplug/substitute.py @@ -34,8 +34,9 @@ def tmpl_substitute(self, text): """Do the actual replacing.""" if text: for pattern, replacement in self.substitute_rules: - if pattern.match(text): - return replacement + new_string, number_of_subs_made = re.subn(pattern, replacement, text) + if number_of_subs_made > 0: + return new_string return text else: return "" From 9680d8f3f5556953bdee3179810227ede2abf353 Mon Sep 17 00:00:00 2001 From: Nicholas Boyd Isacsson Date: Thu, 11 Jul 2024 13:54:29 +0200 Subject: [PATCH 03/15] Rename variables --- beetsplug/substitute.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/beetsplug/substitute.py b/beetsplug/substitute.py index 4034738ac9..dfc5eca703 100644 --- a/beetsplug/substitute.py +++ b/beetsplug/substitute.py @@ -34,9 +34,9 @@ def tmpl_substitute(self, text): """Do the actual replacing.""" if text: for pattern, replacement in self.substitute_rules: - new_string, number_of_subs_made = re.subn(pattern, replacement, text) - if number_of_subs_made > 0: - return new_string + new_text, subs_made = re.subn(pattern, replacement, text) + if subs_made > 0: + return new_text return text else: return "" From 65096c425af1dc052c80aa7620c6615820a45ceb Mon Sep 17 00:00:00 2001 From: Nicholas Boyd Isacsson Date: Thu, 11 Jul 2024 14:37:29 +0200 Subject: [PATCH 04/15] Update changelog and documentation --- docs/changelog.rst | 3 +++ docs/plugins/substitute.rst | 19 +++++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index a9ed03e422..e062243cde 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -23,6 +23,9 @@ New features: * Beets now uses ``platformdirs`` to determine the default music directory. This location varies between systems -- for example, users can configure it on Unix systems via ``user-dirs.dirs(5)``. +* :doc:`/plugins/substitute`: Allow the replacement string to use capture groups + from the match. It is thus possible to create more general rules, applying to + many different artists at once. Bug fixes: diff --git a/docs/plugins/substitute.rst b/docs/plugins/substitute.rst index b443f27aca..ed8f77dddb 100644 --- a/docs/plugins/substitute.rst +++ b/docs/plugins/substitute.rst @@ -11,13 +11,28 @@ the ``rewrite`` plugin modifies the metadata, this plugin does not. Enable the ``substitute`` plugin (see :ref:`using-plugins`), then make a ``substitute:`` section in your config file to contain your rules. Each rule consists of a case-insensitive regular expression pattern, and a -replacement value. For example, you might use: +replacement string. For example, you might use: substitute: .*jimi hendrix.*: Jimi Hendrix +The replacement can be an expression utilising the matched regex, allowing us +to create more general rules. Say for example, we want to sort all albums by +multiple artists into the directory of the first artist. We can thus capture +everything before the first ``,``, `` &`` or `` and``, and use this capture +group in the output, discarding the rest of the string. + + substitute: + ^(.*?)(,| &| and).*: \1 + +This would handle all the below cases in a single rule: + + Bob Dylan and The Band -> Bob Dylan + Neil Young & Crazy Horse -> Neil Young + James Yorkston, Nina Persson & The Second Hand Orchestra -> James Yorkston + To apply the substitution, you have to call the function ``%substitute{}`` in the paths section. For example: - + paths: default: %substitute{$albumartist}/$year - $album%aunique{}/$track - $title \ No newline at end of file From 81b79a08c11a24f5d2027dc9bc2eb37539295112 Mon Sep 17 00:00:00 2001 From: Nicholas Boyd Isacsson Date: Sun, 29 Sep 2024 17:19:52 +0200 Subject: [PATCH 05/15] Add test cases for substitute plugin --- test/plugins/test_substitute.py | 99 +++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 test/plugins/test_substitute.py diff --git a/test/plugins/test_substitute.py b/test/plugins/test_substitute.py new file mode 100644 index 0000000000..cef3273f2f --- /dev/null +++ b/test/plugins/test_substitute.py @@ -0,0 +1,99 @@ +# This file is part of beets. +# Copyright 2024, Nicholas Boyd Isacsson. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +"""Test the substitute plugin regex functionality.""" + +import pytest + +from beets.test.helper import PluginTestCase +from beetsplug.substitute import Substitute + +PLUGIN_NAME = "substitute" + +class SubstitutePluginTest(PluginTestCase): + plugin = "substitute" + preload_plugin = False + + def test_simple_substitute(self): + with self.configure_plugin( + { + "a": "b", + "b": "c", + "c": "d", + } + ): + cases = [ + ("a", "b"), + ("b", "c"), + ("c", "d") + ] + for input, expected in cases: + assert Substitute().tmpl_substitute(input) == expected + + def test_case_insensitivity(self): + with self.configure_plugin( + { "a": "b" } + ): + assert Substitute().tmpl_substitute("A") == "b" + + def test_unmatched_input_preserved(self): + with self.configure_plugin( + { "a": "b" } + ): + assert Substitute().tmpl_substitute("c") == "c" + + def test_regex_to_static(self): + with self.configure_plugin( + { ".*jimi hendrix.*": "Jimi Hendrix" } + ): + assert Substitute().tmpl_substitute("The Jimi Hendrix Experience") == "Jimi Hendrix" + + def test_regex_capture_group(self): + with self.configure_plugin( + { "^(.*?)(,| &| and).*": r"\1" } + ): + cases = [ + ("King Creosote & Jon Hopkins", "King Creosote"), + ("Michael Hurley, The Holy Modal Rounders, Jeffrey Frederick & The Clamtones", "Michael Hurley"), + ("James Yorkston and the Athletes", "James Yorkston") + ] + for case in cases: + assert Substitute().tmpl_substitute(case[0]) == case[1] + + def test_partial_substitution(self): + with self.configure_plugin( + { + r"\.": "", + } + ): + cases = [ + ("U.N.P.O.C.", "UNPOC"), + ] + for input, expected in cases: + assert Substitute().tmpl_substitute(input) == expected + + + def test_break_on_first_match(self): + with self.configure_plugin( + { + "a": "b", + "[ab]": "c", + } + ): + cases = [ + ("a", "b"), + ("b", "c"), + ] + for case in cases: + assert Substitute().tmpl_substitute(case[0]) == case[1] From 16ac231f7fab8d3151bdf28144e89b299d62900d Mon Sep 17 00:00:00 2001 From: Nicholas Boyd Isacsson Date: Sun, 29 Sep 2024 17:25:52 +0200 Subject: [PATCH 06/15] Refactor substitute tests --- test/plugins/test_substitute.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/test/plugins/test_substitute.py b/test/plugins/test_substitute.py index cef3273f2f..fe1ccd8282 100644 --- a/test/plugins/test_substitute.py +++ b/test/plugins/test_substitute.py @@ -14,7 +14,6 @@ """Test the substitute plugin regex functionality.""" -import pytest from beets.test.helper import PluginTestCase from beetsplug.substitute import Substitute @@ -57,7 +56,8 @@ def test_regex_to_static(self): with self.configure_plugin( { ".*jimi hendrix.*": "Jimi Hendrix" } ): - assert Substitute().tmpl_substitute("The Jimi Hendrix Experience") == "Jimi Hendrix" + result = Substitute().tmpl_substitute("The Jimi Hendrix Experience") + assert result == "Jimi Hendrix" def test_regex_capture_group(self): with self.configure_plugin( @@ -65,11 +65,12 @@ def test_regex_capture_group(self): ): cases = [ ("King Creosote & Jon Hopkins", "King Creosote"), - ("Michael Hurley, The Holy Modal Rounders, Jeffrey Frederick & The Clamtones", "Michael Hurley"), + ("Michael Hurley, The Holy Modal Rounders," + + "Jeffrey Frederick & The Clamtones", "Michael Hurley"), ("James Yorkston and the Athletes", "James Yorkston") ] - for case in cases: - assert Substitute().tmpl_substitute(case[0]) == case[1] + for input, expected in cases: + assert Substitute().tmpl_substitute(input) == expected def test_partial_substitution(self): with self.configure_plugin( @@ -95,5 +96,5 @@ def test_break_on_first_match(self): ("a", "b"), ("b", "c"), ] - for case in cases: - assert Substitute().tmpl_substitute(case[0]) == case[1] + for input, expected in cases: + assert Substitute().tmpl_substitute(input) == expected From 876dcb9bee4059505294dead5d65b3fc46a238d2 Mon Sep 17 00:00:00 2001 From: Nicholas Boyd Isacsson Date: Sun, 29 Sep 2024 17:29:28 +0200 Subject: [PATCH 07/15] Fix substitute test formatting --- test/plugins/test_substitute.py | 34 ++++++++++++--------------------- 1 file changed, 12 insertions(+), 22 deletions(-) diff --git a/test/plugins/test_substitute.py b/test/plugins/test_substitute.py index fe1ccd8282..a24e7f2c83 100644 --- a/test/plugins/test_substitute.py +++ b/test/plugins/test_substitute.py @@ -14,12 +14,12 @@ """Test the substitute plugin regex functionality.""" - from beets.test.helper import PluginTestCase from beetsplug.substitute import Substitute PLUGIN_NAME = "substitute" + class SubstitutePluginTest(PluginTestCase): plugin = "substitute" preload_plugin = False @@ -32,42 +32,33 @@ def test_simple_substitute(self): "c": "d", } ): - cases = [ - ("a", "b"), - ("b", "c"), - ("c", "d") - ] + cases = [("a", "b"), ("b", "c"), ("c", "d")] for input, expected in cases: assert Substitute().tmpl_substitute(input) == expected def test_case_insensitivity(self): - with self.configure_plugin( - { "a": "b" } - ): + with self.configure_plugin({"a": "b"}): assert Substitute().tmpl_substitute("A") == "b" def test_unmatched_input_preserved(self): - with self.configure_plugin( - { "a": "b" } - ): + with self.configure_plugin({"a": "b"}): assert Substitute().tmpl_substitute("c") == "c" def test_regex_to_static(self): - with self.configure_plugin( - { ".*jimi hendrix.*": "Jimi Hendrix" } - ): + with self.configure_plugin({".*jimi hendrix.*": "Jimi Hendrix"}): result = Substitute().tmpl_substitute("The Jimi Hendrix Experience") assert result == "Jimi Hendrix" def test_regex_capture_group(self): - with self.configure_plugin( - { "^(.*?)(,| &| and).*": r"\1" } - ): + with self.configure_plugin({"^(.*?)(,| &| and).*": r"\1"}): cases = [ ("King Creosote & Jon Hopkins", "King Creosote"), - ("Michael Hurley, The Holy Modal Rounders," - + "Jeffrey Frederick & The Clamtones", "Michael Hurley"), - ("James Yorkston and the Athletes", "James Yorkston") + ( + "Michael Hurley, The Holy Modal Rounders, Jeffrey Frederick & " + + "The Clamtones", + "Michael Hurley", + ), + ("James Yorkston and the Athletes", "James Yorkston"), ] for input, expected in cases: assert Substitute().tmpl_substitute(input) == expected @@ -84,7 +75,6 @@ def test_partial_substitution(self): for input, expected in cases: assert Substitute().tmpl_substitute(input) == expected - def test_break_on_first_match(self): with self.configure_plugin( { From 913d51af5cce3702a5ba0e04c41069f644a9fbfb Mon Sep 17 00:00:00 2001 From: Nicholas Boyd Isacsson Date: Sat, 5 Oct 2024 15:21:16 +0200 Subject: [PATCH 08/15] Preserve rule order in substitute configuration --- beetsplug/substitute.py | 28 +++++++++++++-- docs/plugins/substitute.rst | 6 ++-- test/plugins/test_substitute.py | 64 +++++++++++++++++++++++---------- 3 files changed, 74 insertions(+), 24 deletions(-) diff --git a/beetsplug/substitute.py b/beetsplug/substitute.py index dfc5eca703..be305ab02e 100644 --- a/beetsplug/substitute.py +++ b/beetsplug/substitute.py @@ -18,6 +18,7 @@ """ import re +import textwrap from beets.plugins import BeetsPlugin @@ -51,7 +52,30 @@ def __init__(self): self.substitute_rules = [] self.template_funcs["substitute"] = self.tmpl_substitute - for key, view in self.config.items(): - value = view.as_str() + match self.config.get(): + case list(): + pairs = self.config.as_pairs() + case dict(): + pairs = [ + (key, view.as_str()) for key, view in self.config.items() + ] + self._log.warning( + "Unordered configuration is deprecated, as it leads to" + + " unpredictable behaviour on overlapping rules.\n" + + textwrap.dedent( + """ + Old syntax: + substitute: + a: b + c: d + + New syntax: + substitute: + - a: b + - c: d + """ + ) + ) + for key, value in pairs: pattern = re.compile(key, flags=re.IGNORECASE) self.substitute_rules.append((pattern, value)) diff --git a/docs/plugins/substitute.rst b/docs/plugins/substitute.rst index ed8f77dddb..acd35226f6 100644 --- a/docs/plugins/substitute.rst +++ b/docs/plugins/substitute.rst @@ -9,12 +9,12 @@ Experience to be sorted into the same folder as solo Hendrix albums. This plugin is intended as a replacement for the ``rewrite`` plugin. While the ``rewrite`` plugin modifies the metadata, this plugin does not. -Enable the ``substitute`` plugin (see :ref:`using-plugins`), then make a ``substitute:`` section in your config file to contain your rules. +Enable the ``substitute`` plugin (see :ref:`using-plugins`), then make a ``substitute:`` section in your config file to contain a list of your rules. Each rule consists of a case-insensitive regular expression pattern, and a replacement string. For example, you might use: substitute: - .*jimi hendrix.*: Jimi Hendrix + - .*jimi hendrix.*: Jimi Hendrix The replacement can be an expression utilising the matched regex, allowing us to create more general rules. Say for example, we want to sort all albums by @@ -23,7 +23,7 @@ everything before the first ``,``, `` &`` or `` and``, and use this capture group in the output, discarding the rest of the string. substitute: - ^(.*?)(,| &| and).*: \1 + - ^(.*?)(,| &| and).*: \1 This would handle all the below cases in a single rule: diff --git a/test/plugins/test_substitute.py b/test/plugins/test_substitute.py index a24e7f2c83..336edda9ea 100644 --- a/test/plugins/test_substitute.py +++ b/test/plugins/test_substitute.py @@ -14,7 +14,7 @@ """Test the substitute plugin regex functionality.""" -from beets.test.helper import PluginTestCase +from beets.test.helper import PluginTestCase, capture_log from beetsplug.substitute import Substitute PLUGIN_NAME = "substitute" @@ -26,31 +26,31 @@ class SubstitutePluginTest(PluginTestCase): def test_simple_substitute(self): with self.configure_plugin( - { - "a": "b", - "b": "c", - "c": "d", - } + [ + {"a": "b"}, + {"b": "c"}, + {"c": "d"}, + ] ): cases = [("a", "b"), ("b", "c"), ("c", "d")] for input, expected in cases: assert Substitute().tmpl_substitute(input) == expected def test_case_insensitivity(self): - with self.configure_plugin({"a": "b"}): + with self.configure_plugin([{"a": "b"}]): assert Substitute().tmpl_substitute("A") == "b" def test_unmatched_input_preserved(self): - with self.configure_plugin({"a": "b"}): + with self.configure_plugin([{"a": "b"}]): assert Substitute().tmpl_substitute("c") == "c" def test_regex_to_static(self): - with self.configure_plugin({".*jimi hendrix.*": "Jimi Hendrix"}): + with self.configure_plugin([{".*jimi hendrix.*": "Jimi Hendrix"}]): result = Substitute().tmpl_substitute("The Jimi Hendrix Experience") assert result == "Jimi Hendrix" def test_regex_capture_group(self): - with self.configure_plugin({"^(.*?)(,| &| and).*": r"\1"}): + with self.configure_plugin([{"^(.*?)(,| &| and).*": r"\1"}]): cases = [ ("King Creosote & Jon Hopkins", "King Creosote"), ( @@ -64,11 +64,7 @@ def test_regex_capture_group(self): assert Substitute().tmpl_substitute(input) == expected def test_partial_substitution(self): - with self.configure_plugin( - { - r"\.": "", - } - ): + with self.configure_plugin([{r"\.": ""}]): cases = [ ("U.N.P.O.C.", "UNPOC"), ] @@ -77,10 +73,10 @@ def test_partial_substitution(self): def test_break_on_first_match(self): with self.configure_plugin( - { - "a": "b", - "[ab]": "c", - } + [ + {"a": "b"}, + {"[ab]": "c"}, + ] ): cases = [ ("a", "b"), @@ -88,3 +84,33 @@ def test_break_on_first_match(self): ] for input, expected in cases: assert Substitute().tmpl_substitute(input) == expected + + def test_deprecated_config(self): + with self.configure_plugin( + { + "a": "b", + "b": "c", + "c": "d", + } + ): + cases = [("a", "b"), ("b", "c"), ("c", "d")] + for input, expected in cases: + assert Substitute().tmpl_substitute(input) == expected + + def test_deprecated_config_warning(self): + with capture_log() as logs: + with self.configure_plugin( + { + "a": "b", + "b": "c", + "c": "d", + } + ): + assert any( + [ + "Unordered configuration is deprecated, as it leads to" + + " unpredictable behaviour on overlapping rules" + in log + for log in logs + ] + ) From 9bc586d7ea94c6b8baceba1d1da4887991328eef Mon Sep 17 00:00:00 2001 From: Nicholas Boyd Isacsson Date: Sat, 5 Oct 2024 15:31:42 +0200 Subject: [PATCH 09/15] Replace Py3.10+ pattern matching with isinstance --- beetsplug/substitute.py | 44 ++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/beetsplug/substitute.py b/beetsplug/substitute.py index be305ab02e..84eca9de1b 100644 --- a/beetsplug/substitute.py +++ b/beetsplug/substitute.py @@ -48,34 +48,32 @@ def __init__(self): Get the configuration, register template function and create list of substitute rules. """ - super().__init__() + super(Substitute, self).__init__() self.substitute_rules = [] self.template_funcs["substitute"] = self.tmpl_substitute - match self.config.get(): - case list(): - pairs = self.config.as_pairs() - case dict(): - pairs = [ - (key, view.as_str()) for key, view in self.config.items() - ] - self._log.warning( - "Unordered configuration is deprecated, as it leads to" - + " unpredictable behaviour on overlapping rules.\n" - + textwrap.dedent( - """ - Old syntax: - substitute: - a: b - c: d + if isinstance(self.config.get(), dict): + self._log.warning( + "Unordered configuration is deprecated, as it leads to" + + " unpredictable behaviour on overlapping rules.\n" + + textwrap.dedent( + """ + Old syntax: + substitute: + a: b + c: d - New syntax: - substitute: - - a: b - - c: d - """ - ) + New syntax: + substitute: + - a: b + - c: d + """ ) + ) + pairs = [(key, view.as_str()) for key, view in self.config.items()] + else: + pairs = self.config.as_pairs() + for key, value in pairs: pattern = re.compile(key, flags=re.IGNORECASE) self.substitute_rules.append((pattern, value)) From 195644fc461a6a227e8e9f327f393fff017678a5 Mon Sep 17 00:00:00 2001 From: Nicholas Boyd Isacsson Date: Sat, 12 Oct 2024 15:34:05 +0200 Subject: [PATCH 10/15] Refactor according to review comments --- beetsplug/substitute.py | 6 +-- docs/plugins/substitute.rst | 5 ++- test/plugins/test_substitute.py | 69 ++++++++++++++------------------- 3 files changed, 37 insertions(+), 43 deletions(-) diff --git a/beetsplug/substitute.py b/beetsplug/substitute.py index 84eca9de1b..d6476ae2ff 100644 --- a/beetsplug/substitute.py +++ b/beetsplug/substitute.py @@ -35,7 +35,7 @@ def tmpl_substitute(self, text): """Do the actual replacing.""" if text: for pattern, replacement in self.substitute_rules: - new_text, subs_made = re.subn(pattern, replacement, text) + new_text, subs_made = pattern.subn(replacement, text) if subs_made > 0: return new_text return text @@ -48,7 +48,7 @@ def __init__(self): Get the configuration, register template function and create list of substitute rules. """ - super(Substitute, self).__init__() + super().__init__() self.substitute_rules = [] self.template_funcs["substitute"] = self.tmpl_substitute @@ -70,7 +70,7 @@ def __init__(self): """ ) ) - pairs = [(key, view.as_str()) for key, view in self.config.items()] + pairs = self.config.flatten().items() else: pairs = self.config.as_pairs() diff --git a/docs/plugins/substitute.rst b/docs/plugins/substitute.rst index acd35226f6..1ea4f8f559 100644 --- a/docs/plugins/substitute.rst +++ b/docs/plugins/substitute.rst @@ -13,6 +13,7 @@ Enable the ``substitute`` plugin (see :ref:`using-plugins`), then make a ``subst Each rule consists of a case-insensitive regular expression pattern, and a replacement string. For example, you might use: +.. code-block:: yaml substitute: - .*jimi hendrix.*: Jimi Hendrix @@ -22,6 +23,7 @@ multiple artists into the directory of the first artist. We can thus capture everything before the first ``,``, `` &`` or `` and``, and use this capture group in the output, discarding the rest of the string. +.. code-block:: yaml substitute: - ^(.*?)(,| &| and).*: \1 @@ -34,5 +36,6 @@ This would handle all the below cases in a single rule: To apply the substitution, you have to call the function ``%substitute{}`` in the paths section. For example: +.. code-block:: yaml paths: - default: %substitute{$albumartist}/$year - $album%aunique{}/$track - $title \ No newline at end of file + default: %substitute{$albumartist}/$year - $album%aunique{}/$track - $title diff --git a/test/plugins/test_substitute.py b/test/plugins/test_substitute.py index 336edda9ea..d193400d6a 100644 --- a/test/plugins/test_substitute.py +++ b/test/plugins/test_substitute.py @@ -17,41 +17,42 @@ from beets.test.helper import PluginTestCase, capture_log from beetsplug.substitute import Substitute -PLUGIN_NAME = "substitute" - class SubstitutePluginTest(PluginTestCase): plugin = "substitute" preload_plugin = False + def run_substitute(self, config, cases): + with self.configure_plugin(config): + for input, expected in cases: + assert Substitute().tmpl_substitute(input) == expected + def test_simple_substitute(self): - with self.configure_plugin( + self.run_substitute( [ {"a": "b"}, {"b": "c"}, {"c": "d"}, - ] - ): - cases = [("a", "b"), ("b", "c"), ("c", "d")] - for input, expected in cases: - assert Substitute().tmpl_substitute(input) == expected + ], + [("a", "b"), ("b", "c"), ("c", "d")], + ) def test_case_insensitivity(self): - with self.configure_plugin([{"a": "b"}]): - assert Substitute().tmpl_substitute("A") == "b" + self.run_substitute([{"a": "b"}], [("A", "b")]) def test_unmatched_input_preserved(self): - with self.configure_plugin([{"a": "b"}]): - assert Substitute().tmpl_substitute("c") == "c" + self.run_substitute([{"a": "b"}], [("c", "c")]) def test_regex_to_static(self): - with self.configure_plugin([{".*jimi hendrix.*": "Jimi Hendrix"}]): - result = Substitute().tmpl_substitute("The Jimi Hendrix Experience") - assert result == "Jimi Hendrix" + self.run_substitute( + [{".*jimi hendrix.*": "Jimi Hendrix"}], + [("The Jimi Hendrix Experience", "Jimi Hendrix")], + ) def test_regex_capture_group(self): - with self.configure_plugin([{"^(.*?)(,| &| and).*": r"\1"}]): - cases = [ + self.run_substitute( + [{"^(.*?)(,| &| and).*": r"\1"}], + [ ("King Creosote & Jon Hopkins", "King Creosote"), ( "Michael Hurley, The Holy Modal Rounders, Jeffrey Frederick & " @@ -59,43 +60,33 @@ def test_regex_capture_group(self): "Michael Hurley", ), ("James Yorkston and the Athletes", "James Yorkston"), - ] - for input, expected in cases: - assert Substitute().tmpl_substitute(input) == expected + ], + ) def test_partial_substitution(self): - with self.configure_plugin([{r"\.": ""}]): - cases = [ - ("U.N.P.O.C.", "UNPOC"), - ] - for input, expected in cases: - assert Substitute().tmpl_substitute(input) == expected + self.run_substitute([{r"\.": ""}], [("U.N.P.O.C.", "UNPOC")]) def test_break_on_first_match(self): - with self.configure_plugin( + self.run_substitute( [ {"a": "b"}, {"[ab]": "c"}, - ] - ): - cases = [ + ], + [ ("a", "b"), ("b", "c"), - ] - for input, expected in cases: - assert Substitute().tmpl_substitute(input) == expected + ], + ) def test_deprecated_config(self): - with self.configure_plugin( + self.run_substitute( { "a": "b", "b": "c", "c": "d", - } - ): - cases = [("a", "b"), ("b", "c"), ("c", "d")] - for input, expected in cases: - assert Substitute().tmpl_substitute(input) == expected + }, + [("a", "b"), ("b", "c"), ("c", "d")], + ) def test_deprecated_config_warning(self): with capture_log() as logs: From 48c743578041c563ef00179b6872abaad71cac0f Mon Sep 17 00:00:00 2001 From: Nicholas Boyd Isacsson Date: Sat, 12 Oct 2024 16:20:25 +0200 Subject: [PATCH 11/15] Fix code-blocks --- docs/plugins/substitute.rst | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/plugins/substitute.rst b/docs/plugins/substitute.rst index 1ea4f8f559..2f66ef75bb 100644 --- a/docs/plugins/substitute.rst +++ b/docs/plugins/substitute.rst @@ -14,6 +14,7 @@ Each rule consists of a case-insensitive regular expression pattern, and a replacement string. For example, you might use: .. code-block:: yaml + substitute: - .*jimi hendrix.*: Jimi Hendrix @@ -24,6 +25,7 @@ everything before the first ``,``, `` &`` or `` and``, and use this capture group in the output, discarding the rest of the string. .. code-block:: yaml + substitute: - ^(.*?)(,| &| and).*: \1 @@ -37,5 +39,6 @@ This would handle all the below cases in a single rule: To apply the substitution, you have to call the function ``%substitute{}`` in the paths section. For example: .. code-block:: yaml + paths: - default: %substitute{$albumartist}/$year - $album%aunique{}/$track - $title + default: \%substitute{$albumartist}/$year - $album\%aunique{}/$track - $title From ffdc3f73abb27b48e4fdcee7273f66f7f259c0d7 Mon Sep 17 00:00:00 2001 From: Nicholas Boyd Isacsson Date: Mon, 14 Oct 2024 22:43:47 +0200 Subject: [PATCH 12/15] Revert configuration format changes --- beetsplug/substitute.py | 32 +++-------------- docs/plugins/substitute.rst | 6 ++-- test/plugins/test_substitute.py | 62 +++++++++------------------------ 3 files changed, 24 insertions(+), 76 deletions(-) diff --git a/beetsplug/substitute.py b/beetsplug/substitute.py index d6476ae2ff..ebf6cacbeb 100644 --- a/beetsplug/substitute.py +++ b/beetsplug/substitute.py @@ -18,7 +18,6 @@ """ import re -import textwrap from beets.plugins import BeetsPlugin @@ -49,31 +48,8 @@ def __init__(self): substitute rules. """ super().__init__() - self.substitute_rules = [] self.template_funcs["substitute"] = self.tmpl_substitute - - if isinstance(self.config.get(), dict): - self._log.warning( - "Unordered configuration is deprecated, as it leads to" - + " unpredictable behaviour on overlapping rules.\n" - + textwrap.dedent( - """ - Old syntax: - substitute: - a: b - c: d - - New syntax: - substitute: - - a: b - - c: d - """ - ) - ) - pairs = self.config.flatten().items() - else: - pairs = self.config.as_pairs() - - for key, value in pairs: - pattern = re.compile(key, flags=re.IGNORECASE) - self.substitute_rules.append((pattern, value)) + self.substitute_rules = [ + (re.compile(key, flags=re.IGNORECASE), value) + for key, value in self.config.flatten().items() + ] diff --git a/docs/plugins/substitute.rst b/docs/plugins/substitute.rst index 2f66ef75bb..87ee2ad450 100644 --- a/docs/plugins/substitute.rst +++ b/docs/plugins/substitute.rst @@ -9,14 +9,14 @@ Experience to be sorted into the same folder as solo Hendrix albums. This plugin is intended as a replacement for the ``rewrite`` plugin. While the ``rewrite`` plugin modifies the metadata, this plugin does not. -Enable the ``substitute`` plugin (see :ref:`using-plugins`), then make a ``substitute:`` section in your config file to contain a list of your rules. +Enable the ``substitute`` plugin (see :ref:`using-plugins`), then make a ``substitute:`` section in your config file to contain your rules. Each rule consists of a case-insensitive regular expression pattern, and a replacement string. For example, you might use: .. code-block:: yaml substitute: - - .*jimi hendrix.*: Jimi Hendrix + .*jimi hendrix.*: Jimi Hendrix The replacement can be an expression utilising the matched regex, allowing us to create more general rules. Say for example, we want to sort all albums by @@ -27,7 +27,7 @@ group in the output, discarding the rest of the string. .. code-block:: yaml substitute: - - ^(.*?)(,| &| and).*: \1 + ^(.*?)(,| &| and).*: \1 This would handle all the below cases in a single rule: diff --git a/test/plugins/test_substitute.py b/test/plugins/test_substitute.py index d193400d6a..1662b88493 100644 --- a/test/plugins/test_substitute.py +++ b/test/plugins/test_substitute.py @@ -14,7 +14,7 @@ """Test the substitute plugin regex functionality.""" -from beets.test.helper import PluginTestCase, capture_log +from beets.test.helper import PluginTestCase from beetsplug.substitute import Substitute @@ -29,29 +29,29 @@ def run_substitute(self, config, cases): def test_simple_substitute(self): self.run_substitute( - [ - {"a": "b"}, - {"b": "c"}, - {"c": "d"}, - ], + { + "a": "b", + "b": "c", + "c": "d", + }, [("a", "b"), ("b", "c"), ("c", "d")], ) def test_case_insensitivity(self): - self.run_substitute([{"a": "b"}], [("A", "b")]) + self.run_substitute({"a": "b"}, [("A", "b")]) def test_unmatched_input_preserved(self): - self.run_substitute([{"a": "b"}], [("c", "c")]) + self.run_substitute({"a": "b"}, [("c", "c")]) def test_regex_to_static(self): self.run_substitute( - [{".*jimi hendrix.*": "Jimi Hendrix"}], + {".*jimi hendrix.*": "Jimi Hendrix"}, [("The Jimi Hendrix Experience", "Jimi Hendrix")], ) def test_regex_capture_group(self): self.run_substitute( - [{"^(.*?)(,| &| and).*": r"\1"}], + {"^(.*?)(,| &| and).*": r"\1"}, [ ("King Creosote & Jon Hopkins", "King Creosote"), ( @@ -64,44 +64,16 @@ def test_regex_capture_group(self): ) def test_partial_substitution(self): - self.run_substitute([{r"\.": ""}], [("U.N.P.O.C.", "UNPOC")]) + self.run_substitute({r"\.": ""}, [("U.N.P.O.C.", "UNPOC")]) def test_break_on_first_match(self): - self.run_substitute( - [ - {"a": "b"}, - {"[ab]": "c"}, - ], - [ - ("a", "b"), - ("b", "c"), - ], - ) - - def test_deprecated_config(self): self.run_substitute( { - "a": "b", - "b": "c", - "c": "d", + "a": "x", + "[ab]": "y", }, - [("a", "b"), ("b", "c"), ("c", "d")], + [ + ("a", "x"), + ("b", "y"), + ], ) - - def test_deprecated_config_warning(self): - with capture_log() as logs: - with self.configure_plugin( - { - "a": "b", - "b": "c", - "c": "d", - } - ): - assert any( - [ - "Unordered configuration is deprecated, as it leads to" - + " unpredictable behaviour on overlapping rules" - in log - for log in logs - ] - ) From 617509262e0c70bbf3f47ce284f9ee96e5a89df0 Mon Sep 17 00:00:00 2001 From: Nicholas Boyd Isacsson Date: Wed, 16 Oct 2024 16:21:32 +0200 Subject: [PATCH 13/15] Fix test helper reordering plugin configuration --- beets/test/helper.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/beets/test/helper.py b/beets/test/helper.py index 19f7299ed8..fa0076c5f5 100644 --- a/beets/test/helper.py +++ b/beets/test/helper.py @@ -513,12 +513,8 @@ def unload_plugins(self) -> None: Album._queries = getattr(Album, "_original_queries", {}) @contextmanager - def configure_plugin(self, config: list[Any] | dict[str, Any]): - if isinstance(config, list): - beets.config[self.plugin] = config - else: - for key, value in config.items(): - beets.config[self.plugin][key] = value + def configure_plugin(self, config: Any): + beets.config[self.plugin].set(config) self.load_plugins(self.plugin) yield From 19eb729db3e67d2a0569c10abab823b10b35ba6e Mon Sep 17 00:00:00 2001 From: Nicholas Boyd Isacsson Date: Wed, 16 Oct 2024 16:35:39 +0200 Subject: [PATCH 14/15] Refactor tests to make --- test/plugins/test_substitute.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/test/plugins/test_substitute.py b/test/plugins/test_substitute.py index 1662b88493..7ac74d7a0c 100644 --- a/test/plugins/test_substitute.py +++ b/test/plugins/test_substitute.py @@ -30,18 +30,18 @@ def run_substitute(self, config, cases): def test_simple_substitute(self): self.run_substitute( { - "a": "b", - "b": "c", - "c": "d", + "a": "x", + "b": "y", + "c": "z", }, - [("a", "b"), ("b", "c"), ("c", "d")], + [("a", "x"), ("b", "y"), ("c", "z")], ) def test_case_insensitivity(self): - self.run_substitute({"a": "b"}, [("A", "b")]) + self.run_substitute({"a": "x"}, [("A", "x")]) def test_unmatched_input_preserved(self): - self.run_substitute({"a": "b"}, [("c", "c")]) + self.run_substitute({"a": "x"}, [("c", "c")]) def test_regex_to_static(self): self.run_substitute( @@ -66,11 +66,12 @@ def test_regex_capture_group(self): def test_partial_substitution(self): self.run_substitute({r"\.": ""}, [("U.N.P.O.C.", "UNPOC")]) - def test_break_on_first_match(self): + def test_rules_applied_in_definition_order(self): self.run_substitute( { "a": "x", "[ab]": "y", + "b": "z", }, [ ("a", "x"), From 8e0558b80493942dfc414bd786d742bfb70c3b63 Mon Sep 17 00:00:00 2001 From: Nicholas Boyd Isacsson Date: Wed, 16 Oct 2024 16:36:36 +0200 Subject: [PATCH 15/15] Apply substitute rules in sequence --- beetsplug/substitute.py | 4 +--- test/plugins/test_substitute.py | 10 ++++++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/beetsplug/substitute.py b/beetsplug/substitute.py index ebf6cacbeb..a89d0af16f 100644 --- a/beetsplug/substitute.py +++ b/beetsplug/substitute.py @@ -34,9 +34,7 @@ def tmpl_substitute(self, text): """Do the actual replacing.""" if text: for pattern, replacement in self.substitute_rules: - new_text, subs_made = pattern.subn(replacement, text) - if subs_made > 0: - return new_text + text = pattern.sub(replacement, text) return text else: return "" diff --git a/test/plugins/test_substitute.py b/test/plugins/test_substitute.py index 7ac74d7a0c..48014e231a 100644 --- a/test/plugins/test_substitute.py +++ b/test/plugins/test_substitute.py @@ -78,3 +78,13 @@ def test_rules_applied_in_definition_order(self): ("b", "y"), ], ) + + def test_rules_applied_in_sequence(self): + self.run_substitute( + {"a": "b", "b": "c", "d": "a"}, + [ + ("a", "c"), + ("b", "c"), + ("d", "a"), + ], + )