Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Replace most str.format() uses with f-strings #5337

Open
wants to merge 10 commits into
base: master
Choose a base branch
from

Conversation

bal-e
Copy link
Member

@bal-e bal-e commented Jun 26, 2024

Fixes #5293.

Along the way, I've made some small code improvements beyond just the use of f-strings; I don't think these will conflict with others' work. In particular, I've avoided modifying the formatting of paths; there is work to greatly simplify that using 'pathlib', at which point a second commit can clean them up.

Copy link

Thank you for the PR! The changelog has not been updated, so here is a friendly reminder to check if you need to add an entry.

return f"{m.group(2)} {m.group(1)}"

str1 = re.sub(SD_END_REPLACE, replacer, str1)
str2 = re.sub(SD_END_REPLACE, replacer, str2)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neither the old option nor your version is incredibly readable. However I think I prefer the old version, because "endswith" is more readable. Could you maybe change var m to something more descriptive? And maybe change replacer to something that sounds more like a function? Like do_replace?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's fair. I think a more intuitive implementation would be based on str.rsplit() or str.rpartition() using , as a delimiter, and checking that the final component is one of the three words. If that doesn't sound good, I can improve what I have here: m -> match_info and replacer -> move_article_to_front?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I disagree with @RollingStar actually, I think your regex is clearer. Maybe I'm just more familiar/confident with regex? One thing I would note is that it might be better, if we stay with this approach, to make the regex case-insensitive.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've rewritten this as a much more explicit and intuitive function which uses str.rsplit to separate a title from an article. @RollingStar, I hope this makes it more readable! Also, @Serene-Arc, the inputs are lower-cased here, so case sensitivity is not an issue.

setup_sql += "ALTER TABLE {} ADD COLUMN {} {};\n".format(
table, name, typ.sql
setup_sql += (
f"ALTER TABLE {table} ADD COLUMN {name} {typ.sql};\n"
)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flagging here that we always want to be attentive when changing SQL commands.

@@ -151,6 +151,7 @@ def __init__(self, field_name: str, pattern: P, fast: bool = True):
self.fast = fast

def col_clause(self) -> Tuple[str, Sequence[SQLiteType]]:
# TODO: Avoid having to insert raw text into SQL clauses.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good edit

@RollingStar
Copy link
Collaborator

RollingStar commented Jun 26, 2024

Thank you for the monster commit. Hopefully you had automation to aid you. I only looked at the first few chunks (about as far as my final comment) but it passes the smell test.

I would prefer having the SQL changes in their own commit. Anything with SQL should probably have more attention on it than an f-string.

@bal-e
Copy link
Member Author

bal-e commented Jun 26, 2024

Thank you for the monster commit. Hopefully you had automation to aid you. I only looked at the first few chunks (about as far as my final comment) but it passes the smell test.

Thanks for reviewing! This is a pretty big change and I am worried I slipped up somewhere, so I'm happy to hang on to the PR until somebody can look at the full diff. PyCharm helped me find the usages of str.format() but I modified them by hand.

I would prefer having the SQL changes in their own commit. Anything with SQL should probably have more attention on it than an f-string.

That's understandable, messing with SQL can be dangerous. The tests pass, which I hope adds some degree of confidence to these changes. I haven't fundamentally changed any SQL statements, though -- they should operate exactly the same as before. I can refactor them out to a separate commit, but I think that should wait until somebody reviews the entire diff.

@Serene-Arc Serene-Arc self-requested a review June 27, 2024 03:27
@Serene-Arc
Copy link
Contributor

I'll review the entire diff and get back to you.

beets/autotag/mb.py Outdated Show resolved Hide resolved
beets/library.py Outdated Show resolved Hide resolved
beets/test/_common.py Outdated Show resolved Hide resolved
beets/test/_common.py Outdated Show resolved Hide resolved
beets/ui/commands.py Outdated Show resolved Hide resolved
beetsplug/fish.py Outdated Show resolved Hide resolved
beetsplug/plexupdate.py Outdated Show resolved Hide resolved
beetsplug/plexupdate.py Outdated Show resolved Hide resolved
Comment on lines 586 to 625
"""
if fields is None:
fields = self._fields
fields = self._fields.keys()
fields = set(fields) - {"id"}
db = self._check_db()

# Build assignments for query.
assignments = []
subvars = []
for key in fields:
if key != "id" and key in self._dirty:
self._dirty.remove(key)
assignments.append(key + "=?")
value = self._type(key).to_sql(self[key])
subvars.append(value)
dirty_fields = list(fields & self._dirty)
self._dirty -= fields
assignments = ",".join(f"{k}=?" for k in dirty_fields)
subvars = [self._type(k).to_sql(self[k]) for k in dirty_fields]

with db.transaction() as tx:
# Main table update.
if assignments:
query = "UPDATE {} SET {} WHERE id=?".format(
self._table, ",".join(assignments)
)
query = f"UPDATE {self._table} SET {assignments} WHERE id=?"
subvars.append(self.id)
tx.mutate(query, subvars)

# Modified/added flexible attributes.
for key, value in self._values_flex.items():
if key in self._dirty:
self._dirty.remove(key)
tx.mutate(
"INSERT INTO {} "
"(entity_id, key, value) "
"VALUES (?, ?, ?);".format(self._flex_table),
(self.id, key, value),
)
flex_fields = set(self._values_flex.keys())
dirty_flex_fields = list(flex_fields & self._dirty)
self._dirty -= flex_fields
for key in dirty_flex_fields:
tx.mutate(
f"INSERT INTO {self._flex_table} "
"(entity_id, key, value) "
"VALUES (?, ?, ?);",
(self.id, key, self._values_flex[key]),
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does result in the same values, but not the same ordering because of the conversion to sets. If that will cause problems should be double and triple-checks since this is ultimately being made into SQL commands.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I forgot about the order changing! I don't think it'll cause issues, but I can actually think of a nicer layout for this implementation in terms of not in that does preserve the order. I'll implement it in its own commit, we'll see what's preferable.

Copy link
Member Author

@bal-e bal-e Jun 28, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, I've rewritten the code now. I'm confident the SQL assignments do not care about the order fields are set in, so I've stuck with sets. I traced where the fields parameter can come from -- it looks like they are user-set (beet update -f FIELDS), in which case we really shouldn't be relying on the order anyway.

Copy link
Contributor

@Serene-Arc Serene-Arc Jul 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wonderful, I'll leave this issue open until I double check myself. Should always have multiple eyes on stuff like this.

return f"{m.group(2)} {m.group(1)}"

str1 = re.sub(SD_END_REPLACE, replacer, str1)
str2 = re.sub(SD_END_REPLACE, replacer, str2)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I disagree with @RollingStar actually, I think your regex is clearer. Maybe I'm just more familiar/confident with regex? One thing I would note is that it might be better, if we stay with this approach, to make the regex case-insensitive.

bal-e pushed a commit to bal-e/beets that referenced this pull request Jun 28, 2024
bal-e pushed a commit to bal-e/beets that referenced this pull request Jun 28, 2024
bal-e pushed a commit to bal-e/beets that referenced this pull request Jun 28, 2024
The logic is a bit easier to follow now.

See: <beetbox#5337 (comment)>
joined = urljoin(
"{hostname}:{port}".format(hostname=hostname, port=port), endpoint
)
joined = urljoin(f"{hostname}:{port}", endpoint)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While this is a URL we're formatting too, I think using str.format would be completely unnecessary here. The actual variable names are perfect and this is just much shorter.

bal-e pushed a commit to bal-e/beets that referenced this pull request Jul 26, 2024
The new version doesn't rely on regular expressions, provides more
intuitive names, and will probably be easier to maintain.

See: <beetbox#5337 (comment)>
bal-e pushed a commit to bal-e/beets that referenced this pull request Jul 26, 2024
In cases where the values being filled in did not intuitively describe
what they represented as URL components, it became difficult to figure
out the structure of the URL.

See: <beetbox#5337 (comment)>
@bal-e
Copy link
Member Author

bal-e commented Jul 26, 2024

@Serene-Arc, I think the primary remaining blocker is a thorough review of the SQL function change. I've fixed the URL formatting and the "title, article" implementation as well. Don't worry if you're on vacation, but I hope you're able to get to it soon.

@Serene-Arc
Copy link
Contributor

@bal-e I'll try and do it over the next day or two! The PR is looking pretty good though.

bal-e pushed a commit to bal-e/beets that referenced this pull request Sep 8, 2024
See: <beetbox#5337 (comment)>

diff --git c/beets/autotag/mb.py i/beets/autotag/mb.py
index e16643d7..f152b567 100644
--- c/beets/autotag/mb.py
+++ i/beets/autotag/mb.py
@@ -66,7 +66,9 @@ class MusicBrainzAPIError(util.HumanReadableException):
         super().__init__(reason, verb, tb)

     def get_message(self):
-        return f"{self._reasonstr()} in {self.verb} with query {self.query!r}"
+        return (
+            f"{self._reasonstr()} in {self.verb} with query {repr(self.query)}"
+        )

 log = logging.getLogger("beets")
diff --git c/beets/dbcore/db.py i/beets/dbcore/db.py
index 55ba6f11..5aa75aa5 100755
--- c/beets/dbcore/db.py
+++ i/beets/dbcore/db.py
@@ -397,7 +397,7 @@ class Model(ABC):

     def __repr__(self) -> str:
         name = type(self).__name__
-        fields = ", ".join(f"{k}={v!r}" for k, v in dict(self).items())
+        fields = ", ".join(f"{k}={repr(v)}" for k, v in dict(self).items())
         return f"{name}({fields})"

     def clear_dirty(self):
@@ -558,12 +558,12 @@ class Model(ABC):

     def __getattr__(self, key):
         if key.startswith("_"):
-            raise AttributeError(f"model has no attribute {key!r}")
+            raise AttributeError(f"model has no attribute {repr(key)}")
         else:
             try:
                 return self[key]
             except KeyError:
-                raise AttributeError(f"no such field {key!r}")
+                raise AttributeError(f"no such field {repr(key)}")

     def __setattr__(self, key, value):
         if key.startswith("_"):
diff --git c/beets/dbcore/query.py i/beets/dbcore/query.py
index 357b5685..6e94ddd5 100644
--- c/beets/dbcore/query.py
+++ i/beets/dbcore/query.py
@@ -171,7 +171,7 @@ class FieldQuery(Query, Generic[P]):

     def __repr__(self) -> str:
         return (
-            f"{self.__class__.__name__}({self.field_name!r}, {self.pattern!r}, "
+            f"{self.__class__.__name__}({repr(self.field_name)}, {repr(self.pattern)}, "
             f"fast={self.fast})"
         )

@@ -210,7 +210,9 @@ class NoneQuery(FieldQuery[None]):
         return obj.get(self.field_name) is None

     def __repr__(self) -> str:
-        return f"{self.__class__.__name__}({self.field_name!r}, {self.fast})"
+        return (
+            f"{self.__class__.__name__}({repr(self.field_name)}, {self.fast})"
+        )

 class StringFieldQuery(FieldQuery[P]):
@@ -503,7 +505,7 @@ class CollectionQuery(Query):
         return clause, subvals

     def __repr__(self) -> str:
-        return f"{self.__class__.__name__}({self.subqueries!r})"
+        return f"{self.__class__.__name__}({repr(self.subqueries)})"

     def __eq__(self, other) -> bool:
         return super().__eq__(other) and self.subqueries == other.subqueries
@@ -548,7 +550,7 @@ class AnyFieldQuery(CollectionQuery):

     def __repr__(self) -> str:
         return (
-            f"{self.__class__.__name__}({self.pattern!r}, {self.fields!r}, "
+            f"{self.__class__.__name__}({repr(self.pattern)}, {repr(self.fields)}, "
             f"{self.query_class.__name__})"
         )

@@ -619,7 +621,7 @@ class NotQuery(Query):
         return not self.subquery.match(obj)

     def __repr__(self) -> str:
-        return f"{self.__class__.__name__}({self.subquery!r})"
+        return f"{self.__class__.__name__}({repr(self.subquery)})"

     def __eq__(self, other) -> bool:
         return super().__eq__(other) and self.subquery == other.subquery
@@ -975,7 +977,7 @@ class MultipleSort(Sort):
         return items

     def __repr__(self):
-        return f"{self.__class__.__name__}({self.sorts!r})"
+        return f"{self.__class__.__name__}({repr(self.sorts)})"

     def __hash__(self):
         return hash(tuple(self.sorts))
@@ -1015,7 +1017,7 @@ class FieldSort(Sort):
     def __repr__(self) -> str:
         return (
             f"{self.__class__.__name__}"
-            f"({self.field!r}, ascending={self.ascending!r})"
+            f"({repr(self.field)}, ascending={repr(self.ascending)})"
         )

     def __hash__(self) -> int:
diff --git c/beets/library.py i/beets/library.py
index 77d24ecd..a9adc13d 100644
--- c/beets/library.py
+++ i/beets/library.py
@@ -156,7 +156,7 @@ class PathQuery(dbcore.FieldQuery[bytes]):

     def __repr__(self) -> str:
         return (
-            f"{self.__class__.__name__}({self.field!r}, {self.pattern!r}, "
+            f"{self.__class__.__name__}({repr(self.field)}, {repr(self.pattern)}, "
             f"fast={self.fast}, case_sensitive={self.case_sensitive})"
         )

@@ -735,7 +735,7 @@ class Item(LibModel):
         # can even deadlock due to the database lock.
         name = type(self).__name__
         keys = self.keys(with_album=False)
-        fields = (f"{k}={self[k]!r}" for k in keys)
+        fields = (f"{k}={repr(self[k])}" for k in keys)
         return f"{name}({', '.join(fields)})"

     def keys(self, computed=False, with_album=True):
@@ -1578,7 +1578,7 @@ def parse_query_string(s, model_cls):

     The string is split into components using shell-like syntax.
     """
-    message = f"Query is not unicode: {s!r}"
+    message = f"Query is not unicode: {repr(s)}"
     assert isinstance(s, str), message
     try:
         parts = shlex.split(s)
diff --git c/beets/test/_common.py i/beets/test/_common.py
index c12838e2..0bc1baf8 100644
--- c/beets/test/_common.py
+++ i/beets/test/_common.py
@@ -152,7 +152,7 @@ class Assertions:
     """A mixin with additional unit test assertions."""

     def assertExists(self, path):  # noqa
-        assert os.path.exists(syspath(path)), f"file does not exist: {path!r}"
+        assert os.path.exists(syspath(path)), f"file does not exist: {repr(path)}"

     def assertNotExists(self, path):  # noqa
         assert not os.path.exists(syspath(path)), f"file exists: {repr(path)}"
@@ -186,7 +186,7 @@ class InputException(Exception):
     def __str__(self):
         msg = "Attempt to read with no input provided."
         if self.output is not None:
-            msg += f" Output: {self.output!r}"
+            msg += f" Output: {repr(self.output)}"
         return msg

diff --git c/beets/ui/commands.py i/beets/ui/commands.py
index 3042ca77..a717c94c 100755
--- c/beets/ui/commands.py
+++ i/beets/ui/commands.py
@@ -213,7 +213,7 @@ def get_singleton_disambig_fields(info: hooks.TrackInfo) -> Sequence[str]:
     out = []
     chosen_fields = config["match"]["singleton_disambig_fields"].as_str_seq()
     calculated_values = {
-        "index": f"Index {info.index!s}",
+        "index": f"Index {str(info.index)}",
         "track_alt": f"Track {info.track_alt}",
         "album": (
             f"[{info.album}]"
diff --git c/beets/util/__init__.py i/beets/util/__init__.py
index aa94b6d2..a0f13fa1 100644
--- c/beets/util/__init__.py
+++ i/beets/util/__init__.py
@@ -104,7 +104,7 @@ class HumanReadableException(Exception):
         elif hasattr(self.reason, "strerror"):  # i.e., EnvironmentError
             return self.reason.strerror
         else:
-            return f'"{self.reason!s}"'
+            return f'"{str(self.reason)}"'

     def get_message(self):
         """Create the human-readable description of the error, sans
diff --git c/beets/util/functemplate.py i/beets/util/functemplate.py
index 35f60b7d..f149d370 100644
--- c/beets/util/functemplate.py
+++ i/beets/util/functemplate.py
@@ -166,7 +166,7 @@ class Call:
         self.original = original

     def __repr__(self):
-        return f"Call({self.ident!r}, {self.args!r}, {self.original!r})"
+        return f"Call({repr(self.ident)}, {repr(self.args)}, {repr(self.original)})"

     def evaluate(self, env):
         """Evaluate the function call in the environment, returning a
diff --git c/beetsplug/bpd/__init__.py i/beetsplug/bpd/__init__.py
index d6c75380..3336702c 100644
--- c/beetsplug/bpd/__init__.py
+++ i/beetsplug/bpd/__init__.py
@@ -1142,7 +1142,7 @@ class Server(BaseServer):
             pass

         for tagtype, field in self.tagtype_map.items():
-            info_lines.append(f"{tagtype}: {getattr(item, field)!s}")
+            info_lines.append(f"{tagtype}: {str(getattr(item, field))}")

         return info_lines

@@ -1301,7 +1301,7 @@ class Server(BaseServer):

             yield (
                 f"bitrate: {item.bitrate / 1000}",
-                f"audio: {item.samplerate!s}:{item.bitdepth!s}:{item.channels!s}",
+                f"audio: {str(item.samplerate)}:{str(item.bitdepth)}:{str(item.channels)}",
             )

             (pos, total) = self.player.time()
diff --git c/beetsplug/edit.py i/beetsplug/edit.py
index 61f2020a..20430255 100644
--- c/beetsplug/edit.py
+++ i/beetsplug/edit.py
@@ -47,7 +47,9 @@ def edit(filename, log):
     try:
         subprocess.call(cmd)
     except OSError as exc:
-        raise ui.UserError(f"could not run editor command {cmd[0]!r}: {exc}")
+        raise ui.UserError(
+            f"could not run editor command {repr(cmd[0])}: {exc}"
+        )

 def dump(arg):
diff --git c/beetsplug/replaygain.py i/beetsplug/replaygain.py
index 78cce1e6..1c8aaaa9 100644
--- c/beetsplug/replaygain.py
+++ i/beetsplug/replaygain.py
@@ -534,7 +534,7 @@ class FfmpegBackend(Backend):
             if output[i].startswith(search):
                 return i
         raise ReplayGainError(
-            f"ffmpeg output: missing {search!r} after line {start_line}"
+            f"ffmpeg output: missing {repr(search)} after line {start_line}"
         )

     def _parse_float(self, line: bytes) -> float:
@@ -547,7 +547,7 @@ class FfmpegBackend(Backend):
         parts = line.split(b":", 1)
         if len(parts) < 2:
             raise ReplayGainError(
-                f"ffmpeg output: expected key value pair, found {line!r}"
+                f"ffmpeg output: expected key value pair, found {repr(line)}"
             )
         value = parts[1].lstrip()
         # strip unit
@@ -557,7 +557,7 @@ class FfmpegBackend(Backend):
             return float(value)
         except ValueError:
             raise ReplayGainError(
-                f"ffmpeg output: expected float value, found {value!r}"
+                f"ffmpeg output: expected float value, found {repr(value)}"
             )

@@ -886,7 +886,7 @@ class GStreamerBackend(Backend):
         f = self._src.get_property("location")
         # A GStreamer error, either an unsupported format or a bug.
         self._error = ReplayGainError(
-            f"Error {err!r} - {debug!r} on file {f!r}"
+            f"Error {repr(err)} - {repr(debug)} on file {repr(f)}"
         )

     def _on_tag(self, bus, message):
diff --git c/beetsplug/thumbnails.py i/beetsplug/thumbnails.py
index acca413d..0cde56c7 100644
--- c/beetsplug/thumbnails.py
+++ i/beetsplug/thumbnails.py
@@ -292,4 +292,6 @@ class GioURI(URIGetter):
         try:
             return uri.decode(util._fsencoding())
         except UnicodeDecodeError:
-            raise RuntimeError(f"Could not decode filename from GIO: {uri!r}")
+            raise RuntimeError(
+                f"Could not decode filename from GIO: {repr(uri)}"
+            )
diff --git c/test/plugins/test_lyrics.py i/test/plugins/test_lyrics.py
index 7cb081fc..484d4889 100644
--- c/test/plugins/test_lyrics.py
+++ i/test/plugins/test_lyrics.py
@@ -223,9 +223,9 @@ class LyricsAssertions:

         if not keywords <= words:
             details = (
-                f"{keywords!r} is not a subset of {words!r}."
-                f" Words only in expected set {keywords - words!r},"
-                f" Words only in result set {words - keywords!r}."
+                f"{repr(keywords)} is not a subset of {repr(words)}."
+                f" Words only in expected set {repr(keywords - words)},"
+                f" Words only in result set {repr(words - keywords)}."
             )
             self.fail(f"{details} : {msg}")

diff --git c/test/plugins/test_player.py i/test/plugins/test_player.py
index bf466e1b..e23b6396 100644
--- c/test/plugins/test_player.py
+++ i/test/plugins/test_player.py
@@ -132,7 +132,7 @@ class MPCResponse:
             cmd, rest = rest[2:].split("}")
             return False, (int(code), int(pos), cmd, rest[1:])
         else:
-            raise RuntimeError(f"Unexpected status: {status!r}")
+            raise RuntimeError(f"Unexpected status: {repr(status)}")

     def _parse_body(self, body):
         """Messages are generally in the format "header: content".
@@ -145,7 +145,7 @@ class MPCResponse:
             if not line:
                 continue
             if ":" not in line:
-                raise RuntimeError(f"Unexpected line: {line!r}")
+                raise RuntimeError(f"Unexpected line: {repr(line)}")
             header, content = line.split(":", 1)
             content = content.lstrip()
             if header in repeated_headers:
@@ -191,7 +191,7 @@ class MPCClient:
                 responses.append(MPCResponse(response))
                 response = b""
             elif not line:
-                raise RuntimeError(f"Unexpected response: {line!r}")
+                raise RuntimeError(f"Unexpected response: {repr(line)}")

     def serialise_command(self, command, *args):
         cmd = [command.encode("utf-8")]
diff --git c/test/plugins/test_thumbnails.py i/test/plugins/test_thumbnails.py
index 07775995..1931061b 100644
--- c/test/plugins/test_thumbnails.py
+++ i/test/plugins/test_thumbnails.py
@@ -71,7 +71,7 @@ class ThumbnailsTest(BeetsTestCase):
                 return False
             if path == syspath(LARGE_DIR):
                 return True
-            raise ValueError(f"unexpected path {path!r}")
+            raise ValueError(f"unexpected path {repr(path)}")

         mock_os.path.exists = exists
         plugin = ThumbnailsPlugin()
bal-e pushed a commit to bal-e/beets that referenced this pull request Sep 8, 2024
bal-e pushed a commit to bal-e/beets that referenced this pull request Sep 8, 2024
The logic is a bit easier to follow now.

See: <beetbox#5337 (comment)>
bal-e pushed a commit to bal-e/beets that referenced this pull request Sep 8, 2024
The new version doesn't rely on regular expressions, provides more
intuitive names, and will probably be easier to maintain.

See: <beetbox#5337 (comment)>
bal-e pushed a commit to bal-e/beets that referenced this pull request Sep 8, 2024
In cases where the values being filled in did not intuitively describe
what they represented as URL components, it became difficult to figure
out the structure of the URL.

See: <beetbox#5337 (comment)>
bal-e pushed a commit to bal-e/beets that referenced this pull request Sep 8, 2024
See: <beetbox#5337 (comment)>

diff --git c/beets/autotag/mb.py i/beets/autotag/mb.py
index e16643d7..f152b567 100644
--- c/beets/autotag/mb.py
+++ i/beets/autotag/mb.py
@@ -66,7 +66,9 @@ class MusicBrainzAPIError(util.HumanReadableException):
         super().__init__(reason, verb, tb)

     def get_message(self):
-        return f"{self._reasonstr()} in {self.verb} with query {self.query!r}"
+        return (
+            f"{self._reasonstr()} in {self.verb} with query {repr(self.query)}"
+        )

 log = logging.getLogger("beets")
diff --git c/beets/dbcore/db.py i/beets/dbcore/db.py
index 55ba6f11..5aa75aa5 100755
--- c/beets/dbcore/db.py
+++ i/beets/dbcore/db.py
@@ -397,7 +397,7 @@ class Model(ABC):

     def __repr__(self) -> str:
         name = type(self).__name__
-        fields = ", ".join(f"{k}={v!r}" for k, v in dict(self).items())
+        fields = ", ".join(f"{k}={repr(v)}" for k, v in dict(self).items())
         return f"{name}({fields})"

     def clear_dirty(self):
@@ -558,12 +558,12 @@ class Model(ABC):

     def __getattr__(self, key):
         if key.startswith("_"):
-            raise AttributeError(f"model has no attribute {key!r}")
+            raise AttributeError(f"model has no attribute {repr(key)}")
         else:
             try:
                 return self[key]
             except KeyError:
-                raise AttributeError(f"no such field {key!r}")
+                raise AttributeError(f"no such field {repr(key)}")

     def __setattr__(self, key, value):
         if key.startswith("_"):
diff --git c/beets/dbcore/query.py i/beets/dbcore/query.py
index 357b5685..6e94ddd5 100644
--- c/beets/dbcore/query.py
+++ i/beets/dbcore/query.py
@@ -171,7 +171,7 @@ class FieldQuery(Query, Generic[P]):

     def __repr__(self) -> str:
         return (
-            f"{self.__class__.__name__}({self.field_name!r}, {self.pattern!r}, "
+            f"{self.__class__.__name__}({repr(self.field_name)}, {repr(self.pattern)}, "
             f"fast={self.fast})"
         )

@@ -210,7 +210,9 @@ class NoneQuery(FieldQuery[None]):
         return obj.get(self.field_name) is None

     def __repr__(self) -> str:
-        return f"{self.__class__.__name__}({self.field_name!r}, {self.fast})"
+        return (
+            f"{self.__class__.__name__}({repr(self.field_name)}, {self.fast})"
+        )

 class StringFieldQuery(FieldQuery[P]):
@@ -503,7 +505,7 @@ class CollectionQuery(Query):
         return clause, subvals

     def __repr__(self) -> str:
-        return f"{self.__class__.__name__}({self.subqueries!r})"
+        return f"{self.__class__.__name__}({repr(self.subqueries)})"

     def __eq__(self, other) -> bool:
         return super().__eq__(other) and self.subqueries == other.subqueries
@@ -548,7 +550,7 @@ class AnyFieldQuery(CollectionQuery):

     def __repr__(self) -> str:
         return (
-            f"{self.__class__.__name__}({self.pattern!r}, {self.fields!r}, "
+            f"{self.__class__.__name__}({repr(self.pattern)}, {repr(self.fields)}, "
             f"{self.query_class.__name__})"
         )

@@ -619,7 +621,7 @@ class NotQuery(Query):
         return not self.subquery.match(obj)

     def __repr__(self) -> str:
-        return f"{self.__class__.__name__}({self.subquery!r})"
+        return f"{self.__class__.__name__}({repr(self.subquery)})"

     def __eq__(self, other) -> bool:
         return super().__eq__(other) and self.subquery == other.subquery
@@ -975,7 +977,7 @@ class MultipleSort(Sort):
         return items

     def __repr__(self):
-        return f"{self.__class__.__name__}({self.sorts!r})"
+        return f"{self.__class__.__name__}({repr(self.sorts)})"

     def __hash__(self):
         return hash(tuple(self.sorts))
@@ -1015,7 +1017,7 @@ class FieldSort(Sort):
     def __repr__(self) -> str:
         return (
             f"{self.__class__.__name__}"
-            f"({self.field!r}, ascending={self.ascending!r})"
+            f"({repr(self.field)}, ascending={repr(self.ascending)})"
         )

     def __hash__(self) -> int:
diff --git c/beets/library.py i/beets/library.py
index 77d24ecd..a9adc13d 100644
--- c/beets/library.py
+++ i/beets/library.py
@@ -156,7 +156,7 @@ class PathQuery(dbcore.FieldQuery[bytes]):

     def __repr__(self) -> str:
         return (
-            f"{self.__class__.__name__}({self.field!r}, {self.pattern!r}, "
+            f"{self.__class__.__name__}({repr(self.field)}, {repr(self.pattern)}, "
             f"fast={self.fast}, case_sensitive={self.case_sensitive})"
         )

@@ -735,7 +735,7 @@ class Item(LibModel):
         # can even deadlock due to the database lock.
         name = type(self).__name__
         keys = self.keys(with_album=False)
-        fields = (f"{k}={self[k]!r}" for k in keys)
+        fields = (f"{k}={repr(self[k])}" for k in keys)
         return f"{name}({', '.join(fields)})"

     def keys(self, computed=False, with_album=True):
@@ -1578,7 +1578,7 @@ def parse_query_string(s, model_cls):

     The string is split into components using shell-like syntax.
     """
-    message = f"Query is not unicode: {s!r}"
+    message = f"Query is not unicode: {repr(s)}"
     assert isinstance(s, str), message
     try:
         parts = shlex.split(s)
diff --git c/beets/test/_common.py i/beets/test/_common.py
index c12838e2..0bc1baf8 100644
--- c/beets/test/_common.py
+++ i/beets/test/_common.py
@@ -152,7 +152,7 @@ class Assertions:
     """A mixin with additional unit test assertions."""

     def assertExists(self, path):  # noqa
-        assert os.path.exists(syspath(path)), f"file does not exist: {path!r}"
+        assert os.path.exists(syspath(path)), f"file does not exist: {repr(path)}"

     def assertNotExists(self, path):  # noqa
         assert not os.path.exists(syspath(path)), f"file exists: {repr(path)}"
@@ -186,7 +186,7 @@ class InputException(Exception):
     def __str__(self):
         msg = "Attempt to read with no input provided."
         if self.output is not None:
-            msg += f" Output: {self.output!r}"
+            msg += f" Output: {repr(self.output)}"
         return msg

diff --git c/beets/ui/commands.py i/beets/ui/commands.py
index 3042ca77..a717c94c 100755
--- c/beets/ui/commands.py
+++ i/beets/ui/commands.py
@@ -213,7 +213,7 @@ def get_singleton_disambig_fields(info: hooks.TrackInfo) -> Sequence[str]:
     out = []
     chosen_fields = config["match"]["singleton_disambig_fields"].as_str_seq()
     calculated_values = {
-        "index": f"Index {info.index!s}",
+        "index": f"Index {str(info.index)}",
         "track_alt": f"Track {info.track_alt}",
         "album": (
             f"[{info.album}]"
diff --git c/beets/util/__init__.py i/beets/util/__init__.py
index aa94b6d2..a0f13fa1 100644
--- c/beets/util/__init__.py
+++ i/beets/util/__init__.py
@@ -104,7 +104,7 @@ class HumanReadableException(Exception):
         elif hasattr(self.reason, "strerror"):  # i.e., EnvironmentError
             return self.reason.strerror
         else:
-            return f'"{self.reason!s}"'
+            return f'"{str(self.reason)}"'

     def get_message(self):
         """Create the human-readable description of the error, sans
diff --git c/beets/util/functemplate.py i/beets/util/functemplate.py
index 35f60b7d..f149d370 100644
--- c/beets/util/functemplate.py
+++ i/beets/util/functemplate.py
@@ -166,7 +166,7 @@ class Call:
         self.original = original

     def __repr__(self):
-        return f"Call({self.ident!r}, {self.args!r}, {self.original!r})"
+        return f"Call({repr(self.ident)}, {repr(self.args)}, {repr(self.original)})"

     def evaluate(self, env):
         """Evaluate the function call in the environment, returning a
diff --git c/beetsplug/bpd/__init__.py i/beetsplug/bpd/__init__.py
index d6c75380..3336702c 100644
--- c/beetsplug/bpd/__init__.py
+++ i/beetsplug/bpd/__init__.py
@@ -1142,7 +1142,7 @@ class Server(BaseServer):
             pass

         for tagtype, field in self.tagtype_map.items():
-            info_lines.append(f"{tagtype}: {getattr(item, field)!s}")
+            info_lines.append(f"{tagtype}: {str(getattr(item, field))}")

         return info_lines

@@ -1301,7 +1301,7 @@ class Server(BaseServer):

             yield (
                 f"bitrate: {item.bitrate / 1000}",
-                f"audio: {item.samplerate!s}:{item.bitdepth!s}:{item.channels!s}",
+                f"audio: {str(item.samplerate)}:{str(item.bitdepth)}:{str(item.channels)}",
             )

             (pos, total) = self.player.time()
diff --git c/beetsplug/edit.py i/beetsplug/edit.py
index 61f2020a..20430255 100644
--- c/beetsplug/edit.py
+++ i/beetsplug/edit.py
@@ -47,7 +47,9 @@ def edit(filename, log):
     try:
         subprocess.call(cmd)
     except OSError as exc:
-        raise ui.UserError(f"could not run editor command {cmd[0]!r}: {exc}")
+        raise ui.UserError(
+            f"could not run editor command {repr(cmd[0])}: {exc}"
+        )

 def dump(arg):
diff --git c/beetsplug/replaygain.py i/beetsplug/replaygain.py
index 78cce1e6..1c8aaaa9 100644
--- c/beetsplug/replaygain.py
+++ i/beetsplug/replaygain.py
@@ -534,7 +534,7 @@ class FfmpegBackend(Backend):
             if output[i].startswith(search):
                 return i
         raise ReplayGainError(
-            f"ffmpeg output: missing {search!r} after line {start_line}"
+            f"ffmpeg output: missing {repr(search)} after line {start_line}"
         )

     def _parse_float(self, line: bytes) -> float:
@@ -547,7 +547,7 @@ class FfmpegBackend(Backend):
         parts = line.split(b":", 1)
         if len(parts) < 2:
             raise ReplayGainError(
-                f"ffmpeg output: expected key value pair, found {line!r}"
+                f"ffmpeg output: expected key value pair, found {repr(line)}"
             )
         value = parts[1].lstrip()
         # strip unit
@@ -557,7 +557,7 @@ class FfmpegBackend(Backend):
             return float(value)
         except ValueError:
             raise ReplayGainError(
-                f"ffmpeg output: expected float value, found {value!r}"
+                f"ffmpeg output: expected float value, found {repr(value)}"
             )

@@ -886,7 +886,7 @@ class GStreamerBackend(Backend):
         f = self._src.get_property("location")
         # A GStreamer error, either an unsupported format or a bug.
         self._error = ReplayGainError(
-            f"Error {err!r} - {debug!r} on file {f!r}"
+            f"Error {repr(err)} - {repr(debug)} on file {repr(f)}"
         )

     def _on_tag(self, bus, message):
diff --git c/beetsplug/thumbnails.py i/beetsplug/thumbnails.py
index acca413d..0cde56c7 100644
--- c/beetsplug/thumbnails.py
+++ i/beetsplug/thumbnails.py
@@ -292,4 +292,6 @@ class GioURI(URIGetter):
         try:
             return uri.decode(util._fsencoding())
         except UnicodeDecodeError:
-            raise RuntimeError(f"Could not decode filename from GIO: {uri!r}")
+            raise RuntimeError(
+                f"Could not decode filename from GIO: {repr(uri)}"
+            )
diff --git c/test/plugins/test_lyrics.py i/test/plugins/test_lyrics.py
index 7cb081fc..484d4889 100644
--- c/test/plugins/test_lyrics.py
+++ i/test/plugins/test_lyrics.py
@@ -223,9 +223,9 @@ class LyricsAssertions:

         if not keywords <= words:
             details = (
-                f"{keywords!r} is not a subset of {words!r}."
-                f" Words only in expected set {keywords - words!r},"
-                f" Words only in result set {words - keywords!r}."
+                f"{repr(keywords)} is not a subset of {repr(words)}."
+                f" Words only in expected set {repr(keywords - words)},"
+                f" Words only in result set {repr(words - keywords)}."
             )
             self.fail(f"{details} : {msg}")

diff --git c/test/plugins/test_player.py i/test/plugins/test_player.py
index bf466e1b..e23b6396 100644
--- c/test/plugins/test_player.py
+++ i/test/plugins/test_player.py
@@ -132,7 +132,7 @@ class MPCResponse:
             cmd, rest = rest[2:].split("}")
             return False, (int(code), int(pos), cmd, rest[1:])
         else:
-            raise RuntimeError(f"Unexpected status: {status!r}")
+            raise RuntimeError(f"Unexpected status: {repr(status)}")

     def _parse_body(self, body):
         """Messages are generally in the format "header: content".
@@ -145,7 +145,7 @@ class MPCResponse:
             if not line:
                 continue
             if ":" not in line:
-                raise RuntimeError(f"Unexpected line: {line!r}")
+                raise RuntimeError(f"Unexpected line: {repr(line)}")
             header, content = line.split(":", 1)
             content = content.lstrip()
             if header in repeated_headers:
@@ -191,7 +191,7 @@ class MPCClient:
                 responses.append(MPCResponse(response))
                 response = b""
             elif not line:
-                raise RuntimeError(f"Unexpected response: {line!r}")
+                raise RuntimeError(f"Unexpected response: {repr(line)}")

     def serialise_command(self, command, *args):
         cmd = [command.encode("utf-8")]
diff --git c/test/plugins/test_thumbnails.py i/test/plugins/test_thumbnails.py
index 07775995..1931061b 100644
--- c/test/plugins/test_thumbnails.py
+++ i/test/plugins/test_thumbnails.py
@@ -71,7 +71,7 @@ class ThumbnailsTest(BeetsTestCase):
                 return False
             if path == syspath(LARGE_DIR):
                 return True
-            raise ValueError(f"unexpected path {path!r}")
+            raise ValueError(f"unexpected path {repr(path)}")

         mock_os.path.exists = exists
         plugin = ThumbnailsPlugin()
bal-e pushed a commit to bal-e/beets that referenced this pull request Sep 8, 2024
bal-e pushed a commit to bal-e/beets that referenced this pull request Sep 8, 2024
The logic is a bit easier to follow now.

See: <beetbox#5337 (comment)>
bal-e pushed a commit to bal-e/beets that referenced this pull request Sep 8, 2024
The new version doesn't rely on regular expressions, provides more
intuitive names, and will probably be easier to maintain.

See: <beetbox#5337 (comment)>
bal-e pushed a commit to bal-e/beets that referenced this pull request Sep 8, 2024
In cases where the values being filled in did not intuitively describe
what they represented as URL components, it became difficult to figure
out the structure of the URL.

See: <beetbox#5337 (comment)>
Copy link
Member

@snejus snejus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this! Mostly looks good except for two patterns, see the comments. Both apply to the rest of the PR

@@ -502,7 +505,7 @@ def clause_with_joiner(
return clause, subvals

def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.subqueries!r})"
return f"{self.__class__.__name__}({repr(self.subqueries)})"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why !r got replaced by repr calls? As far as I'm aware !r is the preference here: https://docs.python.org/3/reference/lexical_analysis.html#formatted-string-literals

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@snejus My reading of the PEP for f-strings led me to believe the opposite? They claim that they support the old calls, !r etc, to maintain compatibility.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good shout @Serene-Arc. What I gathered from that section is that they aren't required anymore since they can be replaced by the equivalent expressions, which was not supported previously.

Regarding the preference, from what I've seen in the wild Python community tends to use the native syntax supported by the f-string instead of an equivalent expression, if possible, for example:

  • Datetime
dt = datetime.now()
f"Datetime: {dt:%F %T}"
# vs
f'Datetime: {dt.strftime("%F %T")}'
  • Indentation
txt = "text"
f"{txt:>10}"
# vs
f'{" " * (10 - len(txt))}{txt}'

In a similar way, I prefer !r over repr call since there's no need to use an expression and it's more concise.

I asked perplexity to see what it thinks: https://www.perplexity.ai/search/should-r-or-repr-call-be-prefe-YGZ43OrrTGOYR2eNjl7QeQ

@@ -149,7 +149,7 @@ def embed_func(lib, opts, args):
with open(tempimg, "wb") as f:
f.write(response.content)
except Exception as e:
self._log.error("Unable to save image: {}".format(e))
self._log.error(f"Unable to save image: {e}")
Copy link
Member

@snejus snejus Sep 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer using lazy formatting for logging messages

Suggested change
self._log.error(f"Unable to save image: {e}")
self._log.error(f"Unable to save image: {}", e)

Context:

Edit: Replaced %s with {} to align it with the syntax that beets logger uses

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we don't have any benchmarking tests at the moment, I'm not sure how much of a difference this would be. Since this is largely a disk-bound program and we use multiple threads, I think having fstrings through the code as standard might be a better advantage than whatever mild speed gains there are from this.

The pylint page says that using f-strings is a reasonable option.

Plus we're not passing through to the logging module transparently, so the string interpolation is being done anyway from the looks of things. If we change that module, then it might make sense to switch just the logging events, but even then, we'd need benchmarks to see.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My concern has less to do with speed and more with errors.

For example, I edited FieldSort implementation to embed an error into its __repr__ method:

class FieldSort(Sort):
    ...
    def __repr__(self) -> str:
        return (
            f"{self.__class__.__name__}{1 / 0}"
            f"({repr(self.field)}, ascending={repr(self.ascending)})"
        )

I'm using beets logger. Note that the logging level is 30 (WARNING)

from beets.logging import getLogger

log = getLogger(__name__)
log.getEffectiveLevel()
# 30

s = FieldSort("field")

Using f-string

[ins] In [20]: log.debug(f"Sort {s}")
╭─────────────────────────────── Traceback (most recent call last) ────────────────────────────────╮
│ in <module>:1                                                                                    │
│                                                                                                  │
│ /home/sarunas/repo/beets/beets/dbcore/query.py:1019 in __repr__                                  │
│                                                                                                  │
│   1016 │                                                                                         │
│   1017def __repr__(self) -> str:                                                            │
│   1018 │   │   return (                                                                          │
│ ❱ 1019 │   │   │   f"{self.__class__.__name__}{1 / 0}"                                           │
│   1020 │   │   │   f"({repr(self.field)}, ascending={repr(self.ascending)})"                     │
│   1021 │   │   )                                                                                 │
│   1022                                                                                           │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
ZeroDivisionError: division by zero

Lazy logging

log.debug("Sort: {}", s)

With an f-string this issue is raised immediately in the main thread, regardless of whether the user runs beets in verbose mode or not. With lazy logging, that logic does not run.

Lazily, even when this is allowed to be logged out, there's no exception raised in the main logic:

[ins] In [30]: log.warning("Sort: {}", s)
          ...: print("\n---\nMain logic continues")
--- Logging error ---
Traceback (most recent call last):
  File "/home/sarunas/.local/share/pyenv/versions/3.8.19/lib/python3.8/logging/__init__.py", line 1085, in emit
    msg = self.format(record)
  File "/home/sarunas/.local/share/pyenv/versions/3.8.19/lib/python3.8/logging/__init__.py", line 929, in format
    return fmt.format(record)
  File "/home/sarunas/.local/share/pyenv/versions/3.8.19/lib/python3.8/logging/__init__.py", line 668, in format
    record.message = record.getMessage()
  File "/home/sarunas/.local/share/pyenv/versions/3.8.19/lib/python3.8/logging/__init__.py", line 371, in getMessage
    msg = str(self.msg)
  File "/home/sarunas/repo/beets/beets/logging.py", line 70, in __str__
    return self.msg.format(*args, **kwargs)
  File "/home/sarunas/repo/beets/beets/dbcore/query.py", line 1019, in __repr__
    f"{self.__class__.__name__}{1 / 0}"
ZeroDivisionError: division by zero
Call stack:
  File "/media/poetry/virtualenvs/beets-yAypcYUQ-py3.8/bin/ptipython", line 8, in <module>
    sys.exit(run())
  File "/media/poetry/virtualenvs/beets-yAypcYUQ-py3.8/lib/python3.8/site-packages/ptpython/entry_points/run_ptipython.py", line 72, in run
    embed(
  File "/media/poetry/virtualenvs/beets-yAypcYUQ-py3.8/lib/python3.8/site-packages/ptpython/ipython.py", line 323, in embed
    shell(header=header, stack_depth=2, compile_flags=compile_flags)
  File "/media/poetry/virtualenvs/beets-yAypcYUQ-py3.8/lib/python3.8/site-packages/IPython/terminal/embed.py", line 251, in __call__
    self.mainloop(
  File "/media/poetry/virtualenvs/beets-yAypcYUQ-py3.8/lib/python3.8/site-packages/IPython/terminal/embed.py", line 343, in mainloop
    self.interact()
  File "/media/poetry/virtualenvs/beets-yAypcYUQ-py3.8/lib/python3.8/site-packages/IPython/terminal/interactiveshell.py", line 881, in interact
    self.run_cell(code, store_history=True)
  File "/media/poetry/virtualenvs/beets-yAypcYUQ-py3.8/lib/python3.8/site-packages/IPython/core/interactiveshell.py", line 3009, in run_cell
    result = self._run_cell(
  File "/media/poetry/virtualenvs/beets-yAypcYUQ-py3.8/lib/python3.8/site-packages/IPython/core/interactiveshell.py", line 3064, in _run_cell
    result = runner(coro)
  File "/media/poetry/virtualenvs/beets-yAypcYUQ-py3.8/lib/python3.8/site-packages/IPython/core/async_helpers.py", line 129, in _pseudo_sync_runner
    coro.send(None)
  File "/media/poetry/virtualenvs/beets-yAypcYUQ-py3.8/lib/python3.8/site-packages/IPython/core/interactiveshell.py", line 3269, in run_cell_async
    has_raised = await self.run_ast_nodes(code_ast.body, cell_name,
  File "/media/poetry/virtualenvs/beets-yAypcYUQ-py3.8/lib/python3.8/site-packages/IPython/core/interactiveshell.py", line 3448, in run_ast_nodes
    if await self.run_code(code, result, async_=asy):
  File "/media/poetry/virtualenvs/beets-yAypcYUQ-py3.8/lib/python3.8/site-packages/IPython/core/interactiveshell.py", line 3508, in run_code
    exec(code_obj, self.user_global_ns, self.user_ns)
  File "<ipython-input-30-ecad3783550f>", line 1, in <module>
    log.warning("Sort: {}", s)
  File "/home/sarunas/.local/share/pyenv/versions/3.8.19/lib/python3.8/logging/__init__.py", line 1458, in warning
    self._log(WARNING, msg, args, **kwargs)
  File "/home/sarunas/repo/beets/beets/logging.py", line 88, in _log
    return super()._log(
Message: <beets.logging.StrFormatLogger._LogMessage object at 0x765899d57310>
Arguments: ()

---
Main logic continues

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I disagree with this argument. It seems impossible to me that any of the formatting logic in the codebase can fail depending on the current logging level. Any f-string passed to log should be safe to evaluate at any logging level. If you can find an actual example of this not being true in the current codebase, please show us.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I disagree with this argument. It seems impossible to me that any of the formatting logic in the codebase can fail depending on the current logging level.

Sorry, I must have miscommunicated - normally, when logging logic is clean and does not raise any exceptions, there is no difference between f-string and lazy formatting (except for a slight performance difference).

On the other hand, if logging logic happens to be broken, for example, an exception is raised in __repr__ method,

  • if the logging message is formatted with an f-string

    self._log.debug(f"Object: {repr(obj)}")

    Since obj.__repr__ runs when this line is executed, the exception is always raised immediately (regardless of the logging level) in the main logic. If this is not handled, Python exits immediately. That's because Python executes an f-string expression right when it comes across one.

  • using lazy logging

    self._log.debug("Object: {}", obj)

    obj.__repr__ is called lazily if the user's configured logging level allows the message to be logged out. If obj.__repr__ fails, the exception is only printed in the background and never raised in the main logic. That's because the broken expression is not in the source code (Python only comes across obj when it reads this line). Instead, obj.__repr__ gets executed by the logging module following a form of this (simplified) logic:

    def debug(self):
        if USER_LOGGING_LEVEL >= DEBUG:
            try:
                # note __repr__ gets called implicitly if an object does not have __str__ method
                print(self.msg.format(self.args))
            except Exception as e:
                print(e)
  • Note that in the example in my previous comment, I adjusted a __repr__ method implementation with 1 / 0 expression in order to simulate this issue.

    def __repr__(self) -> str:
        return (
            f"{self.__class__.__name__}{1 / 0}"
            f"({repr(self.field)}, ascending={repr(self.ascending)})"
        )

Ultimately, none of this is based on my subjective personal opinion - this is one of Python's logging best practices. If you're interested, have a read here

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well ... there shouldn't be any errors in the logging logic. If there are, we want to find out about them and fix them. Making them less noticeable doesn't really help that. We want users to notice that something is wrong with the program, because there otherwise we can't find out that there is something wrong.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there are, we want to find out about them and fix them.

We've got 62% coverage, so we need to test the rest of 38% of code in order to be sure about this.

Making them less noticeable doesn't really help that. We want users to notice that something is wrong with the program, because there otherwise we can't find out that there is something wrong.

I agree that visibility is important. But are we ready to accept that this crashes beets immediately, since an error in an f-string is not handled?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While coverage is not complete, I think it is highly unlikely that this can cause crashes. Crashes could occur either due to incorrect expressions within f-strings, or due to actual __repr__ methods we implement. I peeked around at the __repr__ implementations in the codebase, and I don't think any of them are problematic. Since f-strings weren't used in the codebase until this PR, a simple review of it should show that there aren't any incorrect expressions within the f-strings. Even if a crash or two does occur, I think it's worth it. I'm happy to look into increasing coverage for __repr__ in a later PR.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even if a crash or two does occur, I think it's worth it.

I don't think I am on the same page regarding this. Mistakes happen, and issues slip through the reviews (#5356). If I accidentally merge some untested code which raises an exception while formatting log.debug message, this will make beets unusable until the issue is addressed and the fix is merged.

Some proactive users will report the issue here, however others (especially new) users will stop using beets since it's not functional.

At least for me, this drawback is more important than anything we may gain from increased visibility.

Even if we are happy to accept the above, most of the code base uses lazy logging, so I think we want to be consistent with what we already have.

For example, we've got 28 log.debug statements that use non-lazy logging:

$ oneline_python **/*.py | grep -E 'log.debug\(.*(format\(|f"|" \+ )'
            log.debug("found duplicates: {}".format([o.id for o in found_duplicates]))
                self._log.debug(f"adding simple rewrite '{pattern}' → '{value}' " f"for field {fieldname}")
                    self._log.debug(f"adding advanced rewrite to '{replacement}' " f"for field {fieldname}")
        log.debug("Listening for control signals on {}:{}".format(host, ctrl_port))
        self._log.debug(f"Searching {self.data_source} for '{query}'")
                self._log.debug("{}: error receiving response".format(self.NAME))
                self._log.debug("{}: error loading response: {}".format(self.NAME, response.text))
            self._log.debug("google: error loading response: {}".format(response.text))
            self._log.debug("lastfm: error loading response: {}".format(response.text))
            self._log.debug("Error: " + str(e))
            self._log.debug("Spotify: error loading response: {}".format(response.text))
            self._log.debug(f"Cover art URL {image_url} found for {album}")
            self._log.debug(f"Cover art URL not found for {album}")
            self._log.debug(f"Cover art URL not found for {album}")
                        self._log.debug("using {0.LOC_STR} image {1}".format(source, util.displayable_path(out.path)))
            self._log.debug(f"Invalid Search Error: {e}")
        self._log.debug(f"{self.__class__.__name__}: {message}", *args)
            self._log.debug(f"loading iTunes library from {library_path}")
        self._log.debug(f"analyzing {item}")
            self._log.debug("{}: {} blocks over {} LUFS".format(item, n_blocks, gating_threshold))
        self._log.debug("{}: gain {} LU, peak {}".format(item, gain, peak))
                self._log.debug(f"{self.data_source} access token has expired. " f"Reauthenticating.")
                self._log.debug(f"Too many API requests. Retrying after " f"{seconds} seconds.")
        self._log.debug(f"Searching {self.data_source} for '{query}'")
        self._log.debug(f"using {ArtResizer.shared.method} to write metadata")
                log.debug(f"artresizer: method is {self.local_method.NAME}")
                self._log.debug("debug " + name)
            self._log.debug("debug " + name)

And 237 that use lazy logging:

$ oneline_python **/*.py | grep 'log.debug(.*{' | grep -vE 'log.debug.*(format\(|f"|" \+ )'
        log.debug("embedding {0}", displayable_path(imagepath))
    log.debug("Resizing album art to {0} pixels wide and encoding at quality \
        log.debug("Clearing art for {0}", item)
    log.debug("Searching for discovered album ID: {0}", first)
    log.debug("Candidate: {0} - {1} ({2})", info.artist, info.album, info.album_id)
            log.debug("Ignored. Missing required tag: {0}", req_tag)
            log.debug("Ignored. Penalty: {0}", penalty)
    log.debug("Success. Distance: {0}", dist)
    log.debug("Tagging {0} - {1}", cur_artist, cur_album)
            log.debug("Searching for album ID: {0}", search_id)
            log.debug("Album ID match recommendation is {0}", rec)
        log.debug("Search terms: {0} - {1}", search_artist, search_album)
            log.debug("Additional search terms: {0}", extra_tags)
        log.debug("Album might be VA: {0}", va_likely)
    log.debug("Evaluating {0} candidates.", len(candidates))
            log.debug("Searching for track ID: {0}", trackid)
    log.debug("Item search terms: {0} - {1}", search_artist, search_title)
    log.debug("Found {0} candidates.", len(candidates))
        log.debug("Album {} has too many tracks", release["id"])
            log.debug("Retrieving tracks starting at {}", i)
                log.debug("Found link to {} release via MusicBrainz", source.capitalize())
        log.debug("Searching for MusicBrainz releases with: {!r}", criteria)
    log.debug("Requesting MusicBrainz release {}", releaseid)
        log.debug("Invalid MBID ({0}).", releaseid)
        log.debug("Invalid MBID ({0}).", releaseid)
        log.debug("state file could not be read: {0}", exc)
        log.debug("removing {0} old duplicated items", len(duplicate_items))
                log.debug("deleting duplicate {0}", util.displayable_path(item.path))
            log.debug("Set field {1}={2} for {0}", displayable_path(self.paths), field, value)
                log.debug("Reimported {} {}. Not preserving flexible attributes {}. " "Path: {}", noun, new_obj.id, overwritten_fields, displayable_path(new_obj.path))
                log.debug("Reimported album {}. Preserving attribute ['added']. " "Path: {}", self.album.id, displayable_path(self.album.path))
                log.debug("Reimported album {}. Preserving flexible attributes {}. " "Path: {}", self.album.id, list(album_fields.keys()), displayable_path(self.album.path))
                    log.debug("Reimported item {}. Preserving attribute ['added']. " "Path: {}", item.id, displayable_path(item.path))
                log.debug("Reimported item {}. Preserving flexible attributes {}. " "Path: {}", item.id, list(item_fields.keys()), displayable_path(item.path))
                log.debug("Replacing item {0}: {1}", dup_item.id, displayable_path(item.path))
        log.debug("{0} of {1} items replaced", sum(bool(v) for v in self.replaced_items.values()), len(self.imported_items()))
            log.debug("Set field {1}={2} for {0}", displayable_path(self.paths), field, value)
            log.debug("Removing extracted directory: {0}", displayable_path(self.toppath))
            log.debug("Skipping previously-imported path: {0}", displayable_path(path))
            log.debug("Skipping previously-imported path: {0}", displayable_path(dirs))
        log.debug("Extracting archive: {0}", displayable_path(self.toppath))
        log.debug("Archive extracted to: {0}", self.toppath)
            log.debug("yielding album {0}: {1} - {2}", album.id, album.albumartist, album.album)
    log.debug("Looking up: {0}", displayable_path(task.paths))
            log.debug("default action for duplicates: {0}", duplicate_action)
                log.debug("moving {0} to synchronize path", util.displayable_path(self.path))
        log.debug("moving album art {0} to {1}", util.displayable_path(old_art), util.displayable_path(new_art))
    log.debug("Parsed query: {!r}", query)
    log.debug("Parsed sort: {!r}", sort)
            self._log.debug("Successfully submitted AcousticBrainz analysis " "for {}.", item)
            self._log.debug("fetching URL: {}", url)
                self._log.debug("Invalid Response: {}", res.text)
                        self._log.debug("attribute {} of {} set to {}", attr, item, val)
                        self._log.debug("skipping attribute {} of {}" " (value {}) due to config", attr, item, val)
                self._log.debug("Data {} could not be mapped to scheme {} " "because key {} was not found", subdata, v, k)
        self._log.debug("running command: {}", displayable_path(list2cmdline(cmd)))
        self._log.debug("checking path: {}", dpath)
            self._log.debug("authentication error: {0}", e)
            self._log.debug("authentication error: {0}", e)
        self._log.debug("Beatport token {0}, secret {1}", token, secret)
            self._log.debug("API Error: {0} (query: {1})", e, query)
            self._log.debug("API Error: {0} (query: {1})", e, query)
        self._log.debug("Searching for release {0}", release_id)
        self._log.debug("Searching for track {0}", track_id)
        self.server._log.debug("{}[{}]: {}", kind, self.address, message)
        self.server._log.debug("CTRL {}[{}]: {}", kind, self.address, message)
                    self._log.debug("moving album {}", album)
        log.debug("fingerprint matching {0} failed: {1}", util.displayable_path(repr(path)), exc)
    log.debug("chroma: fingerprinted {0}", util.displayable_path(repr(path)))
    log.debug("matched recordings {0} on releases {1}", recording_ids, release_ids)
        self._log.debug("acoustid album candidates: {0}", len(albums))
        self._log.debug("acoustid item candidates: {0}", len(tracks))
            self._log.debug("Command {0} exited with status {1}: {2}", args, exc.returncode, exc.output)
                    self._log.debug("embedding album art from {}", util.displayable_path(album.artpath))
            self._log.debug("image size: {}", size)
            self._log.debug("Error fetching album tracks for {}", deezer_id)
            self._log.debug("Error fetching album tracks for {}", track_data["album"]["id"])
        self._log.debug("Found {} result(s) from {} for '{}'", len(response_data), self.data_source, query)
                self._log.debug("No deezer_track_id present for: {}", item)
                self._log.debug("Deezer track: {} has {} rank", deezer_track_id, rank)
                self._log.debug("Invalid Deezer track_id: {}", e)
            self._log.debug("connection error: {0}", e)
            self._log.debug("connection error: {0}", e)
        self._log.debug("Discogs token {0}, secret {1}", token, secret)
            self._log.debug("API Error: {0} (query: {1})", e, query)
            self._log.debug("API Error: {0} (query: {1})", e, query)
            self._log.debug("searching within album {0}", album_cur.album)
        self._log.debug("Searching for release {0}", album_id)
                self._log.debug("API Error: {0} (query: {1})", e, result.data["resource_url"])
            self._log.debug("Communication error while searching for {0!r}", query, exc_info=True)
        self._log.debug("Searching for master release {0}", master_id)
                self._log.debug("API Error: {0} (query: {1})", e, result.data["resource_url"])
            self._log.debug("{}", traceback.format_exc())
            self._log.debug("Invalid position: {0}", position)
            self._log.debug("key {0} on item {1} not cached:" "computing checksum", key, displayable_path(item.path))
                self._log.debug("computed checksum for {0} using {1}", item.title, key)
                self._log.debug("failed to checksum {0}: {1}", displayable_path(item.path), e)
            self._log.debug("key {0} on item {1} cached:" "not computing checksum", key, displayable_path(item.path))
                self._log.debug("some keys {0} on item {1} are null or empty:" " skipping", keys, displayable_path(obj.path))
                self._log.debug("all keys {0} on item {1} are null or empty:" " skipping", keys, displayable_path(obj.path))
                        self._log.debug("key {0} on item {1} is null " "or empty: setting from item {2}", f, displayable_path(objs[0].path), displayable_path(o.path))
                    self._log.debug("item {0} missing from album {1}:" " merging from {2} into {3}", missing, objs[0], displayable_path(o.path), displayable_path(missing.destination()))
    log.debug("invoking editor command: {!r}", cmd)
                self._log.debug("saving changes to {}", ob)
                self._log.debug("Removing album art file for {0}", album)
        self._log.debug("image size: {}", self.size)
            self._log.debug("image too small ({} < {})", self.size[0], plugin.minwidth)
                    self._log.debug("image is not close enough to being " "square, ({} - {} > {})", long_edge, short_edge, plugin.margin_px)
                    self._log.debug("image is not close enough to being " "square, ({} - {} > {})", long_edge, short_edge, margin_px)
                self._log.debug("image is not square ({} != {})", self.size[0], self.size[1])
            self._log.debug("image needs rescaling ({} > {})", self.size[0], plugin.maxwidth)
                self._log.debug("image needs resizing ({}B > {}B)", filesize, plugin.max_filesize)
                self._log.debug("image needs reformatting: {} -> {}", fmt, plugin.cover_format)
        log.debug("{}: {}", message, prepped.url)
                    self._log.debug("not a supported image: {}", real_ct or "unknown content type")
                self._log.debug("downloaded art to: {0}", util.displayable_path(filename))
            self._log.debug("error fetching art: {}", exc)
                self._log.debug("error cleaning up tmp art: {}", exc)
            self._log.debug("scraped art URL: {0}", resp.url)
            self._log.debug("google fetchart error: {0}", reason)
            self._log.debug("fanart.tv: error loading response: {}", response.text)
                self._log.debug("fanart.tv: error on request: {}", data["error message"])
            self._log.debug("iTunes search failed: {0}", e)
            self._log.debug("Could not decode json response: {0}", e)
            self._log.debug("{} not found in json. Fields are {} ", e, list(r.json().keys()))
            self._log.debug("iTunes search for {!r} got no results", payload["term"])
                self._log.debug("Malformed itunes candidate: {} not found in {}", e, list(c.keys()))
            self._log.debug("Malformed itunes candidate: {} not found in {}", e, list(c.keys()))
            self._log.debug("wikipedia: error scraping dbpedia response: {}", dbpedia_response.text)
                    self._log.debug("using well-named art file {0}", util.displayable_path(fn))
                self._log.debug("using fallback art file {0}", util.displayable_path(remaining[0]))
                    self._log.debug("lastfm: no results for {}", album.mb_albumid)
            self._log.debug("Storing art_source for {0.albumartist} - {0.album}", album)
                self._log.debug("trying source {0} for album {1.albumartist} - {1.album}", SOURCE_NAMES[type(source)], album)
            self._log.debug('running command "{0}" for event {1}', " ".join(command_pieces), event)
        self._log.debug("Recorded mtime {0} for item '{1}' imported from " "'{2}'", mtime, util.displayable_path(destination), util.displayable_path(source))
            self._log.debug("Album '{0}' is reimported, skipping import of " "added dates for the album and its items.", util.displayable_path(album.path))
        self._log.debug("Import of album '{0}', selected album.added={1} " "from item file mtimes.", album.album, album.added)
            self._log.debug("Item '{0}' is reimported, skipping import of " "added date.", util.displayable_path(item.path))
            self._log.debug("Import of item '{0}', selected item.added={1}", util.displayable_path(item.path), item.added)
            self._log.debug("Write of item '{0}', selected item.added={1}", util.displayable_path(item.path), item.added)
            self._log.debug("adding item field {0}", key)
            self._log.debug("adding album field {0}", key)
    log.debug("Sending event: {0}", event)
        log.debug("Extracting {} ID from '{}'", url_type, id_)
                self._log.debug("{0} already added", album_dir)
            self._log.debug("Loading canonicalization tree {0}", c14n_filename)
                    self._log.debug("added last.fm item genre ({0}): {1}", src, item.genre)
            self._log.debug("added last.fm album genre ({0}): {1}", src, album.genre)
                    self._log.debug("added last.fm item genre ({0}): {1}", src, item.genre)
            self._log.debug("added last.fm item genre ({0}): {1}", src, item.genre)
            self._log.debug("last.fm error: {0}", exc)
            self._log.debug("{}", traceback.format_exc())
        log.debug("query: {0} - {1} ({2})", artist, title, album)
            log.debug("no album match, trying by album/title: {0} - {1}", album, title)
            log.debug("match: {0} - {1} ({2}) " "updating: play_count {3} => {4}", song.artist, song.title, song.album, count, new_count)
            self._log.debug("loading extension {}", ext)
            self._log.debug("applying changes to {}", album_formatted)
                        self._log.debug("moving album {0}", album_formatted)
                    self._log.debug("track {0} in album {1}", track_info.track_id, album_info.album_id)
        self._log.debug("music_directory: {0}", self.music_directory)
        self._log.debug("strip_path: {0}", self.strip_path)
        self._log.debug("returning: {0}", result)
            self._log.debug("updated: {0} = {1} [{2}]", attribute, item[attribute], displayable_path(item.path))
                    self._log.debug('unhandled status "{0}"', status)
            self._log.debug("no composer for {}; add one at " "https://musicbrainz.org/work/{}", item, work_info["work"]["id"])
                self._log.debug("error fetching work: {}", e)
                self._log.debug("Work fetched: {} - {}", parent_info["parentwork"], parent_info["parent_composer"])
                self._log.debug("Work fetched: {} - no parent composer", parent_info["parentwork"])
            self._log.debug("{}: Work present, skipping", item)
        log.debug("set permissions to {}, but permissions are now {}", permission, os.stat(syspath(path)).st_mode & 0o777)
            self._log.debug("setting file permissions on {}", displayable_path(path))
            self._log.debug("setting directory permissions on {}", displayable_path(path))
    log.debug("executing command: {} {!r}", command_str, open_args)
        self._log.debug("applied track gain {0} LU, peak {1} of FS", item.rg_track_gain, item.rg_track_peak)
        self._log.debug("applied album gain {0} LU, peak {1} of FS", item.rg_album_gain, item.rg_album_peak)
        self._log.debug("done analyzing {0}", item)
            self._log.debug("done analyzing {0}", item)
        self._log.debug("applied r128 track gain {0} LU", item.r128_track_gain)
        self._log.debug("applied r128 album gain {0} LU", item.r128_album_gain)
        self._log.debug("{}: gain {} LU, peak {}", task.album, album_gain, album_peak)
        self._log.debug("executing {0}", " ".join(map(displayable_path, cmd)))
        self._log.debug("analyzing {0} files", len(items))
        self._log.debug("executing {0}", " ".join(map(displayable_path, cmd)))
                self._log.debug("bad tool output: {0}", text)
            self._log.debug("error in rg.title_gain() call: {}", exc)
        self._log.debug("ReplayGain for track {0} - {1}: {2:.2f}, {3:.2f}", item.artist, item.title, rg_track_gain, rg_track_peak)
            self._log.debug("ReplayGain for track {0}: {1:.2f}, {2:.2f}", item, rg_track_gain, rg_track_peak)
        self._log.debug("ReplayGain for album {0}: {1:.2f}, {2:.2f}", task.items[0].album, rg_album_gain, rg_album_peak)
            self._log.debug("adding template field {0}", key)
            self._log.debug("auto-scrubbing {0}", util.displayable_path(item.path))
                self._log.debug("{0} will be updated because of {1}", n, model)
        self._log.debug("{} access token: {}", self.data_source, self.access_token)
            self._log.debug("Album removed from Spotify: {}", album_id)
            self._log.debug("Spotify API error: {}", e)
        self._log.debug("Found {} result(s) from {} for '{}'", len(response_data), self.data_source, query)
            self._log.debug("Your beets query returned no items, skipping {}.", self.data_source)
                self._log.debug("{} track(s) found, count: {}", self.data_source, len(response_data_tracks))
                self._log.debug("Most popular track chosen, count: {}", len(response_data_tracks))
        self._log.debug("Total {} tracks", len(items))
                    self._log.debug("Popularity already present for: {}", item)
                self._log.debug("No track_id present for: {}", item)
        self._log.debug("track_popularity: {} and track_isrc: {}", track_data.get("popularity"), track_data.get("external_ids").get("isrc"))
            self._log.debug("Spotify API error: {}", e)
        self._log.debug("URL is {0}", url)
        self._log.debug("auth type is {0}", config["subsonic"]["auth"])
                    self._log.debug('"{0}" -> "{1}"', text, r)
        self._log.debug("using {0.name} to compute URIs", uri_getter)
        self._log.debug("generating thumbnail for {0}", album)
                self._log.debug("found a suitable {1}x{1} thumbnail for {0}, " "forcing regeneration", album, size)
                self._log.debug("{1}x{1} thumbnail for {0} exists and is " "recent enough", album, size)
        self._log.debug("Wrote file {0}", displayable_path(outfilename))
                self._log.debug("{0}: {1} -> None", field, value)
                log.debug("skipping {0} because mtime is up to date ({1})", displayable_path(item.path), item.mtime)
                log.debug("emptied album {0}", album_id)
                log.debug("moving album {0}", album_id)
            log.debug("moving: {0}", util.displayable_path(obj.path))
            log.debug("Invalid color_name: {0}", color_name)
    log.debug("plugin paths: {0}", util.displayable_path(paths))
        log.debug("overlaying configuration: {0}", util.displayable_path(overlay_path))
        log.debug("user configuration: {0}", util.displayable_path(config_path))
        log.debug("no user configuration found at {0}", util.displayable_path(config_path))
    log.debug("data directory: {0}", util.displayable_path(config.config_dir()))
        log.debug("{}", traceback.format_exc())
    log.debug("library database: {0}\n" "library directory: {1}", util.displayable_path(lib.path), util.displayable_path(lib.directory))
        log.debug("{}", traceback.format_exc())
        log.debug("{}", traceback.format_exc())
                    log.debug("ImageMagick version check failed: {}", exc)
        log.debug("artresizer: ImageMagick resizing {0} to {1}", displayable_path(path_in), displayable_path(path_out))
            log.debug("`convert` exited with (status {}) when " "getting size with command {}:\n{}", exc.returncode, cmd, exc.output.strip())
        log.debug("comparing images with pipeline {} | {}", convert_cmd, compare_cmd)
            log.debug("ImageMagick convert failed with status {}: {!r}", convert_proc.returncode, convert_stderr)
                log.debug("ImageMagick compare failed: {0}, {1}", displayable_path(im2), displayable_path(im1))
            log.debug("IM output is not a number: {0!r}", out_str)
        log.debug("ImageMagick compare score: {0}", phash_diff)
        log.debug("artresizer: PIL resizing {0} to {1}", displayable_path(path_in), displayable_path(path_out))
                    log.debug("PIL Pass {0} : Output size: {1}B", i, filesize)

See oneline_python shell function if you want to check yourself

oneline_python() {
  # args: <python-file> ...
  # info: _unformat given .py files: remove newlines from each statement
  sed -zr '
    s/(^|\n) *# [^\n]*//g
    s/  # [^\n]*//g
    s/,?\n\s*([]}).]+)/\1/g
    s/\n\s+(\b(and|or)\b|==)/ \1/g
    s/,\s*\n\s+/, /g
    s/"\s*\n\s+(%|f['\''"])/" \1/g
    s/([[{(])\s*\n\s+/\1/g
    s/(["'\''])\n\s*(["'\''+])/\1 \2/g
    s/\n\s*( \+)/\1/g
    s/(\[[^]\n]+)\n\s*( for)/\1\2/g
  ' $@ |
    tr '\000' '\n'
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's more important to get the rest of the PR merged than to argue back and forth over this right now. Let me fix this change, and any other logging-related f-strings I added, and then we should merge.

Arav K. added 10 commits September 22, 2024 15:07
Along the way, I've made some small code improvements beyond just the
use of f-strings; I don't think these will conflict with others' work.
In particular, I've avoided modifying the formatting of paths; there is
work to greatly simplify that using 'pathlib', at which point a second
commit can clean them up.

# Conflicts:
#	beets/test/_common.py
#	test/plugins/test_player.py

diff --git c/beets/__init__.py i/beets/__init__.py
index 16f51f85..e5b19b33 100644
--- c/beets/__init__.py
+++ i/beets/__init__.py
@@ -35,7 +35,7 @@ class IncludeLazyConfig(confuse.LazyConfig):
         except confuse.NotFoundError:
             pass
         except confuse.ConfigReadError as err:
-            stderr.write("configuration `import` failed: {}".format(err.reason))
+            stderr.write(f"configuration `import` failed: {err.reason}")

 config = IncludeLazyConfig("beets", __name__)
diff --git c/beets/autotag/hooks.py i/beets/autotag/hooks.py
index 363bcaab..a711bc89 100644
--- c/beets/autotag/hooks.py
+++ i/beets/autotag/hooks.py
@@ -268,7 +268,7 @@ class TrackInfo(AttrDict):

 # Parameters for string distance function.
 # Words that can be moved to the end of a string using a comma.
-SD_END_WORDS = ["the", "a", "an"]
+SD_END_REPLACE = re.compile(r"^(.*), (the|a|an)$")
 # Reduced weights for certain portions of the string.
 SD_PATTERNS = [
     (r"^the ", 0.1),
@@ -317,11 +317,11 @@ def string_dist(str1: Optional[str], str2: Optional[str]) -> float:
     # Don't penalize strings that move certain words to the end. For
     # example, "the something" should be considered equal to
     # "something, the".
-    for word in SD_END_WORDS:
-        if str1.endswith(", %s" % word):
-            str1 = "{} {}".format(word, str1[: -len(word) - 2])
-        if str2.endswith(", %s" % word):
-            str2 = "{} {}".format(word, str2[: -len(word) - 2])
+    def replacer(m: re.Match[str]) -> str:
+        return f"{m.group(2)} {m.group(1)}"
+
+    str1 = re.sub(SD_END_REPLACE, replacer, str1)
+    str2 = re.sub(SD_END_REPLACE, replacer, str2)

     # Perform a couple of basic normalizing substitutions.
     for pat, repl in SD_REPLACE:
@@ -469,9 +469,7 @@ class Distance:
     def update(self, dist: "Distance"):
         """Adds all the distance penalties from `dist`."""
         if not isinstance(dist, Distance):
-            raise ValueError(
-                "`dist` must be a Distance object, not {}".format(type(dist))
-            )
+            raise ValueError(f"`dist` must be a Distance object, not {dist}")
         for key, penalties in dist._penalties.items():
             self._penalties.setdefault(key, []).extend(penalties)

diff --git c/beets/autotag/mb.py i/beets/autotag/mb.py
index 80ac6c8e..e16643d7 100644
--- c/beets/autotag/mb.py
+++ i/beets/autotag/mb.py
@@ -66,9 +66,7 @@ class MusicBrainzAPIError(util.HumanReadableException):
         super().__init__(reason, verb, tb)

     def get_message(self):
-        return "{} in {} with query {}".format(
-            self._reasonstr(), self.verb, repr(self.query)
-        )
+        return f"{self._reasonstr()} in {self.verb} with query {self.query!r}"

 log = logging.getLogger("beets")
diff --git c/beets/dbcore/db.py i/beets/dbcore/db.py
index 566c1163..55ba6f11 100755
--- c/beets/dbcore/db.py
+++ i/beets/dbcore/db.py
@@ -396,10 +396,9 @@ class Model(ABC):
         return obj

     def __repr__(self) -> str:
-        return "{}({})".format(
-            type(self).__name__,
-            ", ".join(f"{k}={v!r}" for k, v in dict(self).items()),
-        )
+        name = type(self).__name__
+        fields = ", ".join(f"{k}={v!r}" for k, v in dict(self).items())
+        return f"{name}({fields})"

     def clear_dirty(self):
         """Mark all fields as *clean* (i.e., not needing to be stored to
@@ -414,10 +413,11 @@ class Model(ABC):
         has a reference to a database (`_db`) and an id. A ValueError
         exception is raised otherwise.
         """
+        name = type(self).__name__
         if not self._db:
-            raise ValueError("{} has no database".format(type(self).__name__))
+            raise ValueError(f"{name} has no database")
         if need_id and not self.id:
-            raise ValueError("{} has no id".format(type(self).__name__))
+            raise ValueError(f"{name} has no id")

         return self._db

@@ -585,38 +585,34 @@ class Model(ABC):
         will be.
         """
         if fields is None:
-            fields = self._fields
+            fields = self._fields.keys()
+        fields = set(fields) - {"id"}
         db = self._check_db()

         # Build assignments for query.
-        assignments = []
-        subvars = []
-        for key in fields:
-            if key != "id" and key in self._dirty:
-                self._dirty.remove(key)
-                assignments.append(key + "=?")
-                value = self._type(key).to_sql(self[key])
-                subvars.append(value)
+        dirty_fields = list(fields & self._dirty)
+        self._dirty -= fields
+        assignments = ",".join(f"{k}=?" for k in dirty_fields)
+        subvars = [self._type(k).to_sql(self[k]) for k in dirty_fields]

         with db.transaction() as tx:
             # Main table update.
             if assignments:
-                query = "UPDATE {} SET {} WHERE id=?".format(
-                    self._table, ",".join(assignments)
-                )
+                query = f"UPDATE {self._table} SET {assignments} WHERE id=?"
                 subvars.append(self.id)
                 tx.mutate(query, subvars)

             # Modified/added flexible attributes.
-            for key, value in self._values_flex.items():
-                if key in self._dirty:
-                    self._dirty.remove(key)
-                    tx.mutate(
-                        "INSERT INTO {} "
-                        "(entity_id, key, value) "
-                        "VALUES (?, ?, ?);".format(self._flex_table),
-                        (self.id, key, value),
-                    )
+            flex_fields = set(self._values_flex.keys())
+            dirty_flex_fields = list(flex_fields & self._dirty)
+            self._dirty -= flex_fields
+            for key in dirty_flex_fields:
+                tx.mutate(
+                    f"INSERT INTO {self._flex_table} "
+                    "(entity_id, key, value) "
+                    "VALUES (?, ?, ?);",
+                    (self.id, key, self._values_flex[key]),
+                )

             # Deleted flexible attributes.
             for key in self._dirty:
@@ -1192,9 +1188,8 @@ class Database:
             columns = []
             for name, typ in fields.items():
                 columns.append(f"{name} {typ.sql}")
-            setup_sql = "CREATE TABLE {} ({});\n".format(
-                table, ", ".join(columns)
-            )
+            columns_def = ", ".join(columns)
+            setup_sql = f"CREATE TABLE {table} ({columns_def});\n"

         else:
             # Table exists does not match the field set.
@@ -1202,8 +1197,8 @@ class Database:
             for name, typ in fields.items():
                 if name in current_fields:
                     continue
-                setup_sql += "ALTER TABLE {} ADD COLUMN {} {};\n".format(
-                    table, name, typ.sql
+                setup_sql += (
+                    f"ALTER TABLE {table} ADD COLUMN {name} {typ.sql};\n"
                 )

         with self.transaction() as tx:
@@ -1215,18 +1210,16 @@ class Database:
         """
         with self.transaction() as tx:
             tx.script(
-                """
-                CREATE TABLE IF NOT EXISTS {0} (
+                f"""
+                CREATE TABLE IF NOT EXISTS {flex_table} (
                     id INTEGER PRIMARY KEY,
                     entity_id INTEGER,
                     key TEXT,
                     value TEXT,
                     UNIQUE(entity_id, key) ON CONFLICT REPLACE);
-                CREATE INDEX IF NOT EXISTS {0}_by_entity
-                    ON {0} (entity_id);
-                """.format(
-                    flex_table
-                )
+                CREATE INDEX IF NOT EXISTS {flex_table}_by_entity
+                    ON {flex_table} (entity_id);
+                """
             )

     # Querying.
diff --git c/beets/dbcore/query.py i/beets/dbcore/query.py
index f8cf7fe4..357b5685 100644
--- c/beets/dbcore/query.py
+++ i/beets/dbcore/query.py
@@ -151,6 +151,7 @@ class FieldQuery(Query, Generic[P]):
         self.fast = fast

     def col_clause(self) -> Tuple[str, Sequence[SQLiteType]]:
+        # TODO: Avoid having to insert raw text into SQL clauses.
         return self.field, ()

     def clause(self) -> Tuple[Optional[str], Sequence[SQLiteType]]:
@@ -791,9 +792,7 @@ class DateInterval:

     def __init__(self, start: Optional[datetime], end: Optional[datetime]):
         if start is not None and end is not None and not start < end:
-            raise ValueError(
-                "start date {} is not before end date {}".format(start, end)
-            )
+            raise ValueError(f"start date {start} is not before end date {end}")
         self.start = start
         self.end = end

@@ -841,8 +840,6 @@ class DateQuery(FieldQuery[str]):
         date = datetime.fromtimestamp(timestamp)
         return self.interval.contains(date)

-    _clause_tmpl = "{0} {1} ?"
-
     def col_clause(self) -> Tuple[str, Sequence[SQLiteType]]:
         clause_parts = []
         subvals = []
@@ -850,11 +847,11 @@ class DateQuery(FieldQuery[str]):
         # Convert the `datetime` objects to an integer number of seconds since
         # the (local) Unix epoch using `datetime.timestamp()`.
         if self.interval.start:
-            clause_parts.append(self._clause_tmpl.format(self.field, ">="))
+            clause_parts.append(f"{self.field} >= ?")
             subvals.append(int(self.interval.start.timestamp()))

         if self.interval.end:
-            clause_parts.append(self._clause_tmpl.format(self.field, "<"))
+            clause_parts.append(f"{self.field} < ?")
             subvals.append(int(self.interval.end.timestamp()))

         if clause_parts:
diff --git c/beets/importer.py i/beets/importer.py
index 3a290a03..9786891b 100644
--- c/beets/importer.py
+++ i/beets/importer.py
@@ -1583,9 +1583,7 @@ def resolve_duplicates(session, task):
     if task.choice_flag in (action.ASIS, action.APPLY, action.RETAG):
         found_duplicates = task.find_duplicates(session.lib)
         if found_duplicates:
-            log.debug(
-                "found duplicates: {}".format([o.id for o in found_duplicates])
-            )
+            log.debug(f"found duplicates: {[o.id for o in found_duplicates]}")

             # Get the default action to follow from config.
             duplicate_action = config["import"]["duplicate_action"].as_choice(
diff --git c/beets/library.py i/beets/library.py
index 84f6a7bf..77d24ecd 100644
--- c/beets/library.py
+++ i/beets/library.py
@@ -733,13 +733,10 @@ class Item(LibModel):
         # This must not use `with_album=True`, because that might access
         # the database. When debugging, that is not guaranteed to succeed, and
         # can even deadlock due to the database lock.
-        return "{}({})".format(
-            type(self).__name__,
-            ", ".join(
-                "{}={!r}".format(k, self[k])
-                for k in self.keys(with_album=False)
-            ),
-        )
+        name = type(self).__name__
+        keys = self.keys(with_album=False)
+        fields = (f"{k}={self[k]!r}" for k in keys)
+        return f"{name}({', '.join(fields)})"

     def keys(self, computed=False, with_album=True):
         """Get a list of available field names.
diff --git c/beets/plugins.py i/beets/plugins.py
index 35995c34..ed5e63b8 100644
--- c/beets/plugins.py
+++ i/beets/plugins.py
@@ -344,9 +344,9 @@ def types(model_cls):
         for field in plugin_types:
             if field in types and plugin_types[field] != types[field]:
                 raise PluginConflictException(
-                    "Plugin {} defines flexible field {} "
-                    "which has already been defined with "
-                    "another type.".format(plugin.name, field)
+                    f"Plugin {plugin.name} defines flexible field "
+                    f"{field} which has already been defined with "
+                    "another type."
                 )
         types.update(plugin_types)
     return types
@@ -519,9 +519,8 @@ def feat_tokens(for_artist=True):
     feat_words = ["ft", "featuring", "feat", "feat.", "ft."]
     if for_artist:
         feat_words += ["with", "vs", "and", "con", "&"]
-    return r"(?<=\s)(?:{})(?=\s)".format(
-        "|".join(re.escape(x) for x in feat_words)
-    )
+    matcher = "|".join(re.escape(x) for x in feat_words)
+    return rf"(?<=\s)(?:{matcher})(?=\s)"

 def sanitize_choices(choices, choices_all):
diff --git c/beets/test/_common.py i/beets/test/_common.py
index 50dbde43..c12838e2 100644
--- c/beets/test/_common.py
+++ i/beets/test/_common.py
@@ -155,19 +155,19 @@ class Assertions:
         assert os.path.exists(syspath(path)), f"file does not exist: {path!r}"

     def assertNotExists(self, path):  # noqa
-        assert not os.path.exists(syspath(path)), f"file exists: {path!r}"
+        assert not os.path.exists(syspath(path)), f"file exists: {repr(path)}"

     def assertIsFile(self, path):  # noqa
         self.assertExists(path)
         assert os.path.isfile(
             syspath(path)
-        ), "path exists, but is not a regular file: {!r}".format(path)
+        ), f"path exists, but is not a regular file: {repr(path)}"

     def assertIsDir(self, path):  # noqa
         self.assertExists(path)
         assert os.path.isdir(
             syspath(path)
-        ), "path exists, but is not a directory: {!r}".format(path)
+        ), f"path exists, but is not a directory: {repr(path)}"

     def assert_equal_path(self, a, b):
         """Check that two paths are equal."""
diff --git c/beets/ui/__init__.py i/beets/ui/__init__.py
index 8580bd1e..c38ad404 100644
--- c/beets/ui/__init__.py
+++ i/beets/ui/__init__.py
@@ -775,7 +775,7 @@ def get_replacements():
             replacements.append((re.compile(pattern), repl))
         except re.error:
             raise UserError(
-                "malformed regular expression in replace: {}".format(pattern)
+                f"malformed regular expression in replace: {pattern}"
             )
     return replacements

@@ -1253,22 +1253,15 @@ def show_path_changes(path_changes):
         # Print every change over two lines
         for source, dest in zip(sources, destinations):
             color_source, color_dest = colordiff(source, dest)
-            print_("{0} \n  -> {1}".format(color_source, color_dest))
+            print_(f"{color_source} \n  -> {color_dest}")
     else:
         # Print every change on a single line, and add a header
-        title_pad = max_width - len("Source ") + len(" -> ")
-
-        print_("Source {0} Destination".format(" " * title_pad))
+        source = "Source "
+        print_(f"{source:<{max_width}}     Destination")
         for source, dest in zip(sources, destinations):
-            pad = max_width - len(source)
             color_source, color_dest = colordiff(source, dest)
-            print_(
-                "{0} {1} -> {2}".format(
-                    color_source,
-                    " " * pad,
-                    color_dest,
-                )
-            )
+            width = max_width - len(source) + len(color_source)
+            print_(f"{color_source:<{width}}  -> {color_dest}")

 # Helper functions for option parsing.
@@ -1294,9 +1287,7 @@ def _store_dict(option, opt_str, value, parser):
             raise ValueError
     except ValueError:
         raise UserError(
-            "supplied argument `{}' is not of the form `key=value'".format(
-                value
-            )
+            f"supplied argument `{value}' is not of the form `key=value'"
         )

     option_values[key] = value
diff --git c/beets/ui/commands.py i/beets/ui/commands.py
index 24cae1dd..3042ca77 100755
--- c/beets/ui/commands.py
+++ i/beets/ui/commands.py
@@ -213,10 +213,10 @@ def get_singleton_disambig_fields(info: hooks.TrackInfo) -> Sequence[str]:
     out = []
     chosen_fields = config["match"]["singleton_disambig_fields"].as_str_seq()
     calculated_values = {
-        "index": "Index {}".format(str(info.index)),
-        "track_alt": "Track {}".format(info.track_alt),
+        "index": f"Index {info.index!s}",
+        "track_alt": f"Track {info.track_alt}",
         "album": (
-            "[{}]".format(info.album)
+            f"[{info.album}]"
             if (
                 config["import"]["singleton_album_disambig"].get()
                 and info.get("album")
@@ -242,7 +242,7 @@ def get_album_disambig_fields(info: hooks.AlbumInfo) -> Sequence[str]:
     chosen_fields = config["match"]["album_disambig_fields"].as_str_seq()
     calculated_values = {
         "media": (
-            "{}x{}".format(info.mediums, info.media)
+            f"{info.mediums}x{info.media}"
             if (info.mediums and info.mediums > 1)
             else info.media
         ),
@@ -490,7 +490,6 @@ class ChangeRepresentation:
         """Format colored track indices."""
         cur_track = self.format_index(item)
         new_track = self.format_index(track_info)
-        templ = "(#{})"
         changed = False
         # Choose color based on change.
         if cur_track != new_track:
@@ -502,8 +501,8 @@ class ChangeRepresentation:
         else:
             highlight_color = "text_faint"

-        cur_track = templ.format(cur_track)
-        new_track = templ.format(new_track)
+        cur_track = f"(#{cur_track})"
+        new_track = f"(#{new_track})"
         lhs_track = ui.colorize(highlight_color, cur_track)
         rhs_track = ui.colorize(highlight_color, new_track)
         return lhs_track, rhs_track, changed
@@ -711,9 +710,9 @@ class AlbumChange(ChangeRepresentation):
         if self.match.extra_items:
             print_(f"Unmatched tracks ({len(self.match.extra_items)}):")
         for item in self.match.extra_items:
-            line = " ! {} (#{})".format(item.title, self.format_index(item))
+            line = f" ! {item.title} (#{self.format_index(item)})"
             if item.length:
-                line += " ({})".format(ui.human_seconds_short(item.length))
+                line += f" ({ui.human_seconds_short(item.length)})"
             print_(ui.colorize("text_warning", line))

@@ -769,7 +768,7 @@ def summarize_items(items, singleton):
     """
     summary_parts = []
     if not singleton:
-        summary_parts.append("{} items".format(len(items)))
+        summary_parts.append(f"{len(items)} items")

     format_counts = {}
     for item in items:
@@ -885,7 +884,7 @@ def choose_candidate(
         if singleton:
             print_("No matching recordings found.")
         else:
-            print_("No matching release found for {} tracks.".format(itemcount))
+            print_(f"No matching release found for {itemcount} tracks.")
             print_(
                 "For help, see: "
                 "https://beets.readthedocs.org/en/latest/faq.html#nomatch"
@@ -920,7 +919,7 @@ def choose_candidate(
             print_(ui.indent(2) + "Candidates:")
             for i, match in enumerate(candidates):
                 # Index, metadata, and distance.
-                index0 = "{0}.".format(i + 1)
+                index0 = f"{i + 1}."
                 index = dist_colorize(index0, match.distance)
                 dist = "({:.1f}%)".format((1 - match.distance) * 100)
                 distance = dist_colorize(dist, match.distance)
@@ -1043,9 +1042,9 @@ class TerminalImportSession(importer.ImportSession):

         path_str0 = displayable_path(task.paths, "\n")
         path_str = ui.colorize("import_path", path_str0)
-        items_str0 = "({} items)".format(len(task.items))
+        items_str0 = f"({len(task.items)} items)"
         items_str = ui.colorize("import_path_items", items_str0)
-        print_(" ".join([path_str, items_str]))
+        print_(f"{path_str} {items_str}")

         # Let plugins display info or prompt the user before we go through the
         # process of selecting candidate.
diff --git c/beets/util/__init__.py i/beets/util/__init__.py
index 4f0aa283..aa94b6d2 100644
--- c/beets/util/__init__.py
+++ i/beets/util/__init__.py
@@ -104,7 +104,7 @@ class HumanReadableException(Exception):
         elif hasattr(self.reason, "strerror"):  # i.e., EnvironmentError
             return self.reason.strerror
         else:
-            return '"{}"'.format(str(self.reason))
+            return f'"{self.reason!s}"'

     def get_message(self):
         """Create the human-readable description of the error, sans
diff --git c/beets/util/artresizer.py i/beets/util/artresizer.py
index 09cc29e0..550a7c1d 100644
--- c/beets/util/artresizer.py
+++ i/beets/util/artresizer.py
@@ -44,7 +44,7 @@ def resize_url(url, maxwidth, quality=0):
     if quality > 0:
         params["q"] = quality

-    return "{}?{}".format(PROXY_URL, urlencode(params))
+    return f"{PROXY_URL}?{urlencode(params)}"

 class LocalBackendNotAvailableError(Exception):
diff --git c/beets/util/functemplate.py i/beets/util/functemplate.py
index 7d7e8f01..35f60b7d 100644
--- c/beets/util/functemplate.py
+++ i/beets/util/functemplate.py
@@ -166,9 +166,7 @@ class Call:
         self.original = original

     def __repr__(self):
-        return "Call({}, {}, {})".format(
-            repr(self.ident), repr(self.args), repr(self.original)
-        )
+        return f"Call({self.ident!r}, {self.args!r}, {self.original!r})"

     def evaluate(self, env):
         """Evaluate the function call in the environment, returning a
diff --git c/beetsplug/absubmit.py i/beetsplug/absubmit.py
index fc40b85e..b50a8e7c 100644
--- c/beetsplug/absubmit.py
+++ i/beetsplug/absubmit.py
@@ -44,9 +44,7 @@ def call(args):
     try:
         return util.command_output(args).stdout
     except subprocess.CalledProcessError as e:
-        raise ABSubmitError(
-            "{} exited with status {}".format(args[0], e.returncode)
-        )
+        raise ABSubmitError(f"{args[0]} exited with status {e.returncode}")

 class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin):
@@ -65,9 +63,7 @@ class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin):
             # Explicit path to extractor
             if not os.path.isfile(self.extractor):
                 raise ui.UserError(
-                    "Extractor command does not exist: {0}.".format(
-                        self.extractor
-                    )
+                    f"Extractor command does not exist: {self.extractor}."
                 )
         else:
             # Implicit path to extractor, search for it in path
diff --git c/beetsplug/aura.py i/beetsplug/aura.py
index 09d85920..77a006de 100644
--- c/beetsplug/aura.py
+++ i/beetsplug/aura.py
@@ -246,7 +246,7 @@ class AURADocument:
             else:
                 # Increment page token by 1
                 next_url = request.url.replace(
-                    f"page={page}", "page={}".format(page + 1)
+                    f"page={page}", f"page={page + 1}"
                 )
         # Get only the items in the page range
         data = [
@@ -431,9 +431,7 @@ class TrackDocument(AURADocument):
             return self.error(
                 "404 Not Found",
                 "No track with the requested id.",
-                "There is no track with an id of {} in the library.".format(
-                    track_id
-                ),
+                f"There is no track with an id of {track_id} in the library.",
             )
         return self.single_resource_document(
             self.get_resource_object(self.lib, track)
@@ -517,9 +515,7 @@ class AlbumDocument(AURADocument):
             return self.error(
                 "404 Not Found",
                 "No album with the requested id.",
-                "There is no album with an id of {} in the library.".format(
-                    album_id
-                ),
+                f"There is no album with an id of {album_id} in the library.",
             )
         return self.single_resource_document(
             self.get_resource_object(self.lib, album)
@@ -604,9 +600,7 @@ class ArtistDocument(AURADocument):
             return self.error(
                 "404 Not Found",
                 "No artist with the requested id.",
-                "There is no artist with an id of {} in the library.".format(
-                    artist_id
-                ),
+                f"There is no artist with an id of {artist_id} in the library.",
             )
         return self.single_resource_document(artist_resource)

@@ -731,9 +725,7 @@ class ImageDocument(AURADocument):
             return self.error(
                 "404 Not Found",
                 "No image with the requested id.",
-                "There is no image with an id of {} in the library.".format(
-                    image_id
-                ),
+                f"There is no image with an id of {image_id} in the library.",
             )
         return self.single_resource_document(image_resource)

@@ -779,9 +771,7 @@ def audio_file(track_id):
         return AURADocument.error(
             "404 Not Found",
             "No track with the requested id.",
-            "There is no track with an id of {} in the library.".format(
-                track_id
-            ),
+            f"There is no track with an id of {track_id} in the library.",
         )

     path = os.fsdecode(track.path)
@@ -789,9 +779,7 @@ def audio_file(track_id):
         return AURADocument.error(
             "404 Not Found",
             "No audio file for the requested track.",
-            (
-                "There is no audio file for track {} at the expected location"
-            ).format(track_id),
+            f"There is no audio file for track {track_id} at the expected location",
         )

     file_mimetype = guess_type(path)[0]
@@ -799,10 +787,8 @@ def audio_file(track_id):
         return AURADocument.error(
             "500 Internal Server Error",
             "Requested audio file has an unknown mimetype.",
-            (
-                "The audio file for track {} has an unknown mimetype. "
-                "Its file extension is {}."
-            ).format(track_id, path.split(".")[-1]),
+            f"The audio file for track {track_id} has an unknown mimetype. "
+            f"Its file extension is {path.split('.')[-1]}.",
         )

     # Check that the Accept header contains the file's mimetype
@@ -814,10 +800,8 @@ def audio_file(track_id):
         return AURADocument.error(
             "406 Not Acceptable",
             "Unsupported MIME type or bitrate parameter in Accept header.",
-            (
-                "The audio file for track {} is only available as {} and "
-                "bitrate parameters are not supported."
-            ).format(track_id, file_mimetype),
+            f"The audio file for track {track_id} is only available as "
+            f"{file_mimetype} and bitrate parameters are not supported.",
         )

     return send_file(
@@ -900,9 +884,7 @@ def image_file(image_id):
         return AURADocument.error(
             "404 Not Found",
             "No image with the requested id.",
-            "There is no image with an id of {} in the library".format(
-                image_id
-            ),
+            f"There is no image with an id of {image_id} in the library",
         )
     return send_file(img_path)

diff --git c/beetsplug/beatport.py i/beetsplug/beatport.py
index 6108b039..bcb010dc 100644
--- c/beetsplug/beatport.py
+++ i/beetsplug/beatport.py
@@ -201,14 +201,10 @@ class BeatportClient:
         try:
             response = self.api.get(self._make_url(endpoint), params=kwargs)
         except Exception as e:
-            raise BeatportAPIError(
-                "Error connecting to Beatport API: {}".format(e)
-            )
+            raise BeatportAPIError(f"Error connecting to Beatport API: {e}")
         if not response:
             raise BeatportAPIError(
-                "Error {0.status_code} for '{0.request.path_url}".format(
-                    response
-                )
+                f"Error {response.status_code} for '{response.request.path_url}"
             )
         return response.json()["results"]

@@ -219,11 +215,7 @@ class BeatportRelease(BeatportObject):
             artist_str = ", ".join(x[1] for x in self.artists)
         else:
             artist_str = "Various Artists"
-        return "<BeatportRelease: {} - {} ({})>".format(
-            artist_str,
-            self.name,
-            self.catalog_number,
-        )
+        return f"<BeatportRelease: {artist_str} - {self.name} ({self.catalog_number})>"

     def __repr__(self):
         return str(self).encode("utf-8")
@@ -237,8 +229,8 @@ class BeatportRelease(BeatportObject):
         if "category" in data:
             self.category = data["category"]
         if "slug" in data:
-            self.url = "https://beatport.com/release/{}/{}".format(
-                data["slug"], data["id"]
+            self.url = (
+                f"https://beatport.com/release/{data['slug']}/{data['id']}"
             )
         self.genre = data.get("genre")

@@ -246,9 +238,7 @@ class BeatportRelease(BeatportObject):
 class BeatportTrack(BeatportObject):
     def __str__(self):
         artist_str = ", ".join(x[1] for x in self.artists)
-        return "<BeatportTrack: {} - {} ({})>".format(
-            artist_str, self.name, self.mix_name
-        )
+        return f"<BeatportTrack: {artist_str} - {self.name} ({self.mix_name})>"

     def __repr__(self):
         return str(self).encode("utf-8")
@@ -267,9 +257,7 @@ class BeatportTrack(BeatportObject):
             except ValueError:
                 pass
         if "slug" in data:
-            self.url = "https://beatport.com/track/{}/{}".format(
-                data["slug"], data["id"]
-            )
+            self.url = f"https://beatport.com/track/{data['slug']}/{data['id']}"
         self.track_number = data.get("trackNumber")
         self.bpm = data.get("bpm")
         self.initial_key = str((data.get("key") or {}).get("shortName"))
diff --git c/beetsplug/bpd/__init__.py i/beetsplug/bpd/__init__.py
index a4cb4d29..d6c75380 100644
--- c/beetsplug/bpd/__init__.py
+++ i/beetsplug/bpd/__init__.py
@@ -895,9 +895,7 @@ class MPDConnection(Connection):
                     return
                 except BPDIdle as e:
                     self.idle_subscriptions = e.subsystems
-                    self.debug(
-                        "awaiting: {}".format(" ".join(e.subsystems)), kind="z"
-                    )
+                    self.debug(f"awaiting: {' '.join(e.subsystems)}", kind="z")
                 yield bluelet.call(self.server.dispatch_events())

@@ -929,7 +927,7 @@ class ControlConnection(Connection):
                 func = command.delegate("ctrl_", self)
                 yield bluelet.call(func(*command.args))
             except (AttributeError, TypeError) as e:
-                yield self.send("ERROR: {}".format(e.args[0]))
+                yield self.send(f"ERROR: {e.args[0]}")
             except Exception:
                 yield self.send(
                     ["ERROR: server error", traceback.format_exc().rstrip()]
@@ -1007,7 +1005,7 @@ class Command:
         # If the command accepts a variable number of arguments skip the check.
         if wrong_num and not argspec.varargs:
             raise TypeError(
-                'wrong number of arguments for "{}"'.format(self.name),
+                f'wrong number of arguments for "{self.name}"',
                 self.name,
             )

@@ -1114,10 +1112,8 @@ class Server(BaseServer):
         self.lib = library
         self.player = gstplayer.GstPlayer(self.play_finished)
         self.cmd_update(None)
-        log.info("Server ready and listening on {}:{}".format(host, port))
-        log.debug(
-            "Listening for control signals on {}:{}".format(host, ctrl_port)
-        )
+        log.info(f"Server ready and listening on {host}:{port}")
+        log.debug(f"Listening for control signals on {host}:{ctrl_port}")

     def run(self):
         self.player.run()
@@ -1146,9 +1142,7 @@ class Server(BaseServer):
             pass

         for tagtype, field in self.tagtype_map.items():
-            info_lines.append(
-                "{}: {}".format(tagtype, str(getattr(item, field)))
-            )
+            info_lines.append(f"{tagtype}: {getattr(item, field)!s}")

         return info_lines

@@ -1306,22 +1300,15 @@ class Server(BaseServer):
             item = self.playlist[self.current_index]

             yield (
-                "bitrate: " + str(item.bitrate / 1000),
-                "audio: {}:{}:{}".format(
-                    str(item.samplerate),
-                    str(item.bitdepth),
-                    str(item.channels),
-                ),
+                f"bitrate: {item.bitrate / 1000}",
+                f"audio: {item.samplerate!s}:{item.bitdepth!s}:{item.channels!s}",
             )

             (pos, total) = self.player.time()
             yield (
-                "time: {}:{}".format(
-                    str(int(pos)),
-                    str(int(total)),
-                ),
-                "elapsed: " + f"{pos:.3f}",
-                "duration: " + f"{total:.3f}",
+                f"time: {int(pos)}:{int(total)}",
+                f"elapsed: {pos:.3f}",
+                f"duration: {total:.3f}",
             )

         # Also missing 'updating_db'.
diff --git c/beetsplug/convert.py i/beetsplug/convert.py
index f150b7c3..03b9034a 100644
--- c/beetsplug/convert.py
+++ i/beetsplug/convert.py
@@ -63,9 +63,7 @@ def get_format(fmt=None):
         command = format_info["command"]
         extension = format_info.get("extension", fmt)
     except KeyError:
-        raise ui.UserError(
-            'convert: format {} needs the "command" field'.format(fmt)
-        )
+        raise ui.UserError(f'convert: format {fmt} needs the "command" field')
     except ConfigTypeError:
         command = config["convert"]["formats"][fmt].get(str)
         extension = fmt
diff --git c/beetsplug/deezer.py i/beetsplug/deezer.py
index a861ea0e..a5d1df09 100644
--- c/beetsplug/deezer.py
+++ i/beetsplug/deezer.py
@@ -113,7 +113,7 @@ class DeezerPlugin(MetadataSourcePlugin, BeetsPlugin):
         else:
             raise ui.UserError(
                 "Invalid `release_date` returned "
-                "by {} API: '{}'".format(self.data_source, release_date)
+                f"by {self.data_source} API: '{release_date}'"
             )
         tracks_obj = self.fetch_data(self.album_url + deezer_id + "/tracks")
         if tracks_obj is None:
diff --git c/beetsplug/discogs.py i/beetsplug/discogs.py
index 344d67a2..98e3a2e4 100644
--- c/beetsplug/discogs.py
+++ i/beetsplug/discogs.py
@@ -610,7 +610,7 @@ class DiscogsPlugin(BeetsPlugin):
             idx, medium_idx, sub_idx = self.get_track_index(
                 subtracks[0]["position"]
             )
-            position = "{}{}".format(idx or "", medium_idx or "")
+            position = f"{idx or ''}{medium_idx or ''}"

             if tracklist and not tracklist[-1]["position"]:
                 # Assume the previous index track contains the track title.
@@ -632,8 +632,8 @@ class DiscogsPlugin(BeetsPlugin):
                     # option is set
                     if self.config["index_tracks"]:
                         for subtrack in subtracks:
-                            subtrack["title"] = "{}: {}".format(
-                                index_track["title"], subtrack["title"]
+                            subtrack["title"] = (
+                                f"{index_track['title']}: {subtrack['title']}"
                             )
                     tracklist.extend(subtracks)
             else:
diff --git c/beetsplug/edit.py i/beetsplug/edit.py
index 323dd9e4..61f2020a 100644
--- c/beetsplug/edit.py
+++ i/beetsplug/edit.py
@@ -47,9 +47,7 @@ def edit(filename, log):
     try:
         subprocess.call(cmd)
     except OSError as exc:
-        raise ui.UserError(
-            "could not run editor command {!r}: {}".format(cmd[0], exc)
-        )
+        raise ui.UserError(f"could not run editor command {cmd[0]!r}: {exc}")

 def dump(arg):
@@ -72,9 +70,7 @@ def load(s):
         for d in yaml.safe_load_all(s):
             if not isinstance(d, dict):
                 raise ParseError(
-                    "each entry must be a dictionary; found {}".format(
-                        type(d).__name__
-                    )
+                    f"each entry must be a dictionary; found {type(d).__name__}"
                 )

             # Convert all keys to strings. They started out as strings,
diff --git c/beetsplug/embedart.py i/beetsplug/embedart.py
index 740863bf..b8b894ad 100644
--- c/beetsplug/embedart.py
+++ i/beetsplug/embedart.py
@@ -137,7 +137,7 @@ class EmbedCoverArtPlugin(BeetsPlugin):
                     response = requests.get(opts.url, timeout=5)
                     response.raise_for_status()
                 except requests.exceptions.RequestException as e:
-                    self._log.error("{}".format(e))
+                    self._log.error(str(e))
                     return
                 extension = guess_extension(response.headers["Content-Type"])
                 if extension is None:
@@ -149,7 +149,7 @@ class EmbedCoverArtPlugin(BeetsPlugin):
                     with open(tempimg, "wb") as f:
                         f.write(response.content)
                 except Exception as e:
-                    self._log.error("Unable to save image: {}".format(e))
+                    self._log.error(f"Unable to save image: {e}")
                     return
                 items = lib.items(decargs(args))
                 # Confirm with user.
diff --git c/beetsplug/embyupdate.py i/beetsplug/embyupdate.py
index 22c88947..8215012e 100644
--- c/beetsplug/embyupdate.py
+++ i/beetsplug/embyupdate.py
@@ -39,9 +39,7 @@ def api_url(host, port, endpoint):
         hostname_list.insert(0, "http://")
         hostname = "".join(hostname_list)

-    joined = urljoin(
-        "{hostname}:{port}".format(hostname=hostname, port=port), endpoint
-    )
+    joined = urljoin(f"{hostname}:{port}", endpoint)

     scheme, netloc, path, query_string, fragment = urlsplit(joined)
     query_params = parse_qs(query_string)
@@ -82,12 +80,12 @@ def create_headers(user_id, token=None):
     headers = {}

     authorization = (
-        'MediaBrowser UserId="{user_id}", '
+        f'MediaBrowser UserId="{user_id}", '
         'Client="other", '
         'Device="beets", '
         'DeviceId="beets", '
         'Version="0.0.0"'
-    ).format(user_id=user_id)
+    )

     headers["x-emby-authorization"] = authorization

diff --git c/beetsplug/fetchart.py i/beetsplug/fetchart.py
index 72aa3aa2..3071edaf 100644
--- c/beetsplug/fetchart.py
+++ i/beetsplug/fetchart.py
@@ -456,18 +456,14 @@ class CoverArtArchive(RemoteArtSource):
             try:
                 response = self.request(url)
             except requests.RequestException:
-                self._log.debug(
-                    "{}: error receiving response".format(self.NAME)
-                )
+                self._log.debug(f"{self.NAME}: error receiving response")
                 return

             try:
                 data = response.json()
             except ValueError:
                 self._log.debug(
-                    "{}: error loading response: {}".format(
-                        self.NAME, response.text
-                    )
+                    f"{self.NAME}: error loading response: {response.text}"
                 )
                 return

@@ -601,9 +597,7 @@ class GoogleImages(RemoteArtSource):
         try:
             data = response.json()
         except ValueError:
-            self._log.debug(
-                "google: error loading response: {}".format(response.text)
-            )
+            self._log.debug(f"google: error loading response: {response.text}")
             return

         if "error" in data:
@@ -1071,9 +1065,7 @@ class LastFM(RemoteArtSource):
                             url=images[size], size=self.SIZES[size]
                         )
         except ValueError:
-            self._log.debug(
-                "lastfm: error loading response: {}".format(response.text)
-            )
+            self._log.debug(f"lastfm: error loading response: {response.text}")
             return

@@ -1112,9 +1104,7 @@ class Spotify(RemoteArtSource):
             ]
             yield self._candidate(url=image_url, match=Candidate.MATCH_EXACT)
         except ValueError:
-            self._log.debug(
-                "Spotify: error loading response: {}".format(response.text)
-            )
+            self._log.debug(f"Spotify: error loading response: {response.text}")
             return

diff --git c/beetsplug/fish.py i/beetsplug/fish.py
index 71ac8574..d29eac1a 100644
--- c/beetsplug/fish.py
+++ i/beetsplug/fish.py
@@ -127,18 +127,10 @@ class FishPlugin(BeetsPlugin):
         totstring += get_cmds_list([name[0] for name in cmd_names_help])
         totstring += "" if nobasicfields else get_standard_fields(fields)
         totstring += get_extravalues(lib, extravalues) if extravalues else ""
-        totstring += (
-            "\n"
-            + "# ====== {} =====".format("setup basic beet completion")
-            + "\n" * 2
-        )
+        totstring += "\n" "# ====== setup basic beet completion =====" "\n\n"
         totstring += get_basic_beet_options()
         totstring += (
-            "\n"
-            + "# ====== {} =====".format(
-                "setup field completion for subcommands"
-            )
-            + "\n"
+            "\n" "# ====== setup field completion for subcommands =====" "\n"
         )
         totstring += get_subcommands(cmd_names_help, nobasicfields, extravalues)
         # Set up completion for all the command options
@@ -227,11 +219,7 @@ def get_subcommands(cmd_name_and_help, nobasicfields, extravalues):
     for cmdname, cmdhelp in cmd_name_and_help:
         cmdname = _escape(cmdname)

-        word += (
-            "\n"
-            + "# ------ {} -------".format("fieldsetups for  " + cmdname)
-            + "\n"
-        )
+        word += "\n" f"# ------ fieldsetups for {cmdname} -------" "\n"
         word += BL_NEED2.format(
             ("-a " + cmdname), ("-f " + "-d " + wrap(clean_whitespace(cmdhelp)))
         )
@@ -269,11 +257,7 @@ def get_all_commands(beetcmds):
             name = _escape(name)

             word += "\n"
-            word += (
-                ("\n" * 2)
-                + "# ====== {} =====".format("completions for  " + name)
-                + "\n"
-            )
+            word += "\n\n" f"# ====== completions for {name} =====" "\n"

             for option in cmd.parser._get_all_options()[1:]:
                 cmd_l = (
diff --git c/beetsplug/fromfilename.py i/beetsplug/fromfilename.py
index 103e8290..4c643106 100644
--- c/beetsplug/fromfilename.py
+++ i/beetsplug/fromfilename.py
@@ -112,7 +112,7 @@ def apply_matches(d, log):
         for item in d:
             if not item.artist:
                 item.artist = artist
-                log.info("Artist replaced with: {}".format(item.artist))
+                log.info(f"Artist replaced with: {item.artist}")

     # No artist field: remaining field is the title.
     else:
@@ -122,11 +122,11 @@ def apply_matches(d, log):
     for item in d:
         if bad_title(item.title):
             item.title = str(d[item][title_field])
-            log.info("Title replaced with: {}".format(item.title))
+            log.info(f"Title replaced with: {item.title}")

         if "track" in d[item] and item.track == 0:
             item.track = int(d[item]["track"])
-            log.info("Track replaced with: {}".format(item.track))
+            log.info(f"Track replaced with: {item.track}")

 # Plugin structure and hook into import process.
diff --git c/beetsplug/info.py i/beetsplug/info.py
index 1c3b6f54..d122fc3f 100644
--- c/beetsplug/info.py
+++ i/beetsplug/info.py
@@ -119,7 +119,6 @@ def print_data(data, item=None, fmt=None):
         return

     maxwidth = max(len(key) for key in formatted)
-    lineformat = f"{{0:>{maxwidth}}}: {{1}}"

     if path:
         ui.print_(displayable_path(path))
@@ -128,7 +127,7 @@ def print_data(data, item=None, fmt=None):
         value = formatted[field]
         if isinstance(value, list):
             value = "; ".join(value)
-        ui.print_(lineformat.format(field, value))
+        ui.print_(f"{field:>{maxwidth}}: {value}")

 def print_data_keys(data, item=None):
@@ -141,12 +140,11 @@ def print_data_keys(data, item=None):
     if len(formatted) == 0:
         return

-    line_format = "{0}{{0}}".format(" " * 4)
     if path:
         ui.print_(displayable_path(path))

     for field in sorted(formatted):
-        ui.print_(line_format.format(field))
+        ui.print_(f"    {field}")

 class InfoPlugin(BeetsPlugin):
diff --git c/beetsplug/inline.py i/beetsplug/inline.py
index 4ca676e5..74416244 100644
--- c/beetsplug/inline.py
+++ i/beetsplug/inline.py
@@ -38,7 +38,8 @@ def _compile_func(body):
     """Given Python code for a function body, return a compiled
     callable that invokes that code.
     """
-    body = "def {}():\n    {}".format(FUNC_NAME, body.replace("\n", "\n    "))
+    body = body.replace("\n", "\n    ")
+    body = f"def {FUNC_NAME}():\n    {body}"
     code = compile(body, "inline", "exec")
     env = {}
     eval(code, env)
diff --git c/beetsplug/lyrics.py i/beetsplug/lyrics.py
index db29c9c6..19fe7f45 100644
--- c/beetsplug/lyrics.py
+++ i/beetsplug/lyrics.py
@@ -182,7 +182,7 @@ def search_pairs(item):
         # examples include (live), (remix), and (acoustic).
         r"(.+?)\s+[(].*[)]$",
         # Remove any featuring artists from the title
-        r"(.*?) {}".format(plugins.feat_tokens(for_artist=False)),
+        rf"(.*?) {plugins.feat_tokens(for_artist=False)}",
         # Remove part of title after colon ':' for songs with subtitles
         r"(.+?)\s*:.*",
     ]
@@ -997,12 +997,10 @@ class LyricsPlugin(plugins.BeetsPlugin):
             tmpalbum = self.album = item.album.strip()
             if self.album == "":
                 tmpalbum = "Unknown album"
-            self.rest += "{}\n{}\n\n".format(tmpalbum, "-" * len(tmpalbum))
+            self.rest += f"{tmpalbum}\n{'-'*len(tmpalbum)}\n\n"
         title_str = ":index:`%s`" % item.title.strip()
         block = "| " + item.lyrics.replace("\n", "\n| ")
-        self.rest += "{}\n{}\n\n{}\n\n".format(
-            title_str, "~" * len(title_str), block
-        )
+        self.rest += f"{title_str}\n{'~'*len(title_str)}\n\n{block}\n\n"

     def writerest(self, directory):
         """Write self.rest to a ReST file"""
@@ -1132,5 +1130,5 @@ class LyricsPlugin(plugins.BeetsPlugin):
             translations = dict(zip(text_lines, lines_translated.split("|")))
             result = ""
             for line in text.split("\n"):
-                result += "{} / {}\n".format(line, translations[line])
+                result += f"{line} / {translations[line]}\n"
             return result
diff --git c/beetsplug/mbcollection.py i/beetsplug/mbcollection.py
index 1c010bf5..d0512cd6 100644
--- c/beetsplug/mbcollection.py
+++ i/beetsplug/mbcollection.py
@@ -79,9 +79,7 @@ class MusicBrainzCollectionPlugin(BeetsPlugin):
         collection = self.config["collection"].as_str()
         if collection:
             if collection not in collection_ids:
-                raise ui.UserError(
-                    "invalid collection ID: {}".format(collection)
-                )
+                raise ui.UserError(f"invalid collection ID: {collection}")
             return collection

         # No specified collection. Just return the first collection ID
diff --git c/beetsplug/metasync/__init__.py i/beetsplug/metasync/__init__.py
index d17071b5..89e20812 100644
--- c/beetsplug/metasync/__init__.py
+++ i/beetsplug/metasync/__init__.py
@@ -120,14 +120,13 @@ class MetaSyncPlugin(BeetsPlugin):
             try:
                 cls = META_SOURCES[player]
             except KeyError:
-                self._log.error("Unknown metadata source '{}'".format(player))
+                self._log.error(f"Unknown metadata source '{player}'")

             try:
                 meta_source_instances[player] = cls(self.config, self._log)
             except (ImportError, ConfigValueError) as e:
                 self._log.error(
-                    "Failed to instantiate metadata source "
-                    "'{}': {}".format(player, e)
+                    "Failed to instantiate metadata source " f"'{player}': {e}"
                 )

         # Avoid needlessly iterating over items
diff --git c/beetsplug/missing.py i/beetsplug/missing.py
index 2e37fde7..4f958ef9 100644
--- c/beetsplug/missing.py
+++ i/beetsplug/missing.py
@@ -222,7 +222,7 @@ class MissingPlugin(BeetsPlugin):
             missing_titles = {rg["title"] for rg in missing}

             for release_title in missing_titles:
-                print_("{} - {}".format(artist[0], release_title))
+                print_(f"{artist[0]} - {release_title}")

         if total:
             print(total_missing)
diff --git c/beetsplug/play.py i/beetsplug/play.py
index 3476e582..9169bcd2 100644
--- c/beetsplug/play.py
+++ i/beetsplug/play.py
@@ -44,7 +44,7 @@ def play(
     """
     # Print number of tracks or albums to be played, log command to be run.
     item_type += "s" if len(selection) > 1 else ""
-    ui.print_("Playing {} {}.".format(len(selection), item_type))
+    ui.print_(f"Playing {len(selection)} {item_type}.")
     log.debug("executing command: {} {!r}", command_str, open_args)

     try:
@@ -180,9 +180,7 @@ class PlayPlugin(BeetsPlugin):
             ui.print_(
                 ui.colorize(
                     "text_warning",
-                    "You are about to queue {} {}.".format(
-                        len(selection), item_type
-                    ),
+                    f"You are about to queue {len(selection)} {item_type}.",
                 )
             )

diff --git c/beetsplug/playlist.py i/beetsplug/playlist.py
index 83f95796..9a83aa4c 100644
--- c/beetsplug/playlist.py
+++ i/beetsplug/playlist.py
@@ -192,9 +192,7 @@ class PlaylistPlugin(beets.plugins.BeetsPlugin):

         if changes or deletions:
             self._log.info(
-                "Updated playlist {} ({} changes, {} deletions)".format(
-                    filename, changes, deletions
-                )
+                f"Updated playlist {filename} ({changes} changes, {deletions} deletions)"
             )
             beets.util.copy(new_playlist, filename, replace=True)
         beets.util.remove(new_playlist)
diff --git c/beetsplug/plexupdate.py i/beetsplug/plexupdate.py
index 9b4419c7..c0ea0f4e 100644
--- c/beetsplug/plexupdate.py
+++ i/beetsplug/plexupdate.py
@@ -22,9 +22,7 @@ def get_music_section(
 ):
     """Getting the section key for the music library in Plex."""
     api_endpoint = append_token("library/sections", token)
-    url = urljoin(
-        "{}://{}:{}".format(get_protocol(secure), host, port), api_endpoint
-    )
+    url = urljoin(f"{get_protocol(secure)}://{host}:{port}", api_endpoint)

     # Sends request.
     r = requests.get(
@@ -54,9 +52,7 @@ def update_plex(host, port, token, library_name, secure, ignore_cert_errors):
     )
     api_endpoint = f"library/sections/{section_key}/refresh"
     api_endpoint = append_token(api_endpoint, token)
-    url = urljoin(
-        "{}://{}:{}".format(get_protocol(secure), host, port), api_endpoint
-    )
+    url = urljoin(f"{get_protocol(secure)}://{host}:{port}", api_endpoint)

     # Sends request and returns requests object.
     r = requests.get(
diff --git c/beetsplug/replaygain.py i/beetsplug/replaygain.py
index a2753f96..78cce1e6 100644
--- c/beetsplug/replaygain.py
+++ i/beetsplug/replaygain.py
@@ -77,9 +77,7 @@ def call(args: List[Any], log: Logger, **kwargs: Any):
         return command_output(args, **kwargs)
     except subprocess.CalledProcessError as e:
         log.debug(e.output.decode("utf8", "ignore"))
-        raise ReplayGainError(
-            "{} exited with status {}".format(args[0], e.returncode)
-        )
+        raise ReplayGainError(f"{args[0]} exited with status {e.returncode}")
     except UnicodeEncodeError:
         # Due to a bug in Python 2's subprocess on Windows, Unicode
         # filenames can fail to encode on that platform. See:
@@ -182,9 +180,7 @@ class RgTask:
             # `track_gains` without throwing FatalReplayGainError
             #  => raise non-fatal exception & continue
             raise ReplayGainError(
-                "ReplayGain backend `{}` failed for track {}".format(
-                    self.backend_name, item
-                )
+                f"ReplayGain backend `{self.backend_name}` failed for track {item}"
             )

         self._store_track_gain(item, self.track_gains[0])
@@ -203,10 +199,8 @@ class RgTask:
             # `album_gain` without throwing FatalReplayGainError
             #  => raise non-fatal exception & continue
             raise ReplayGainError(
-                "ReplayGain backend `{}` failed "
-                "for some tracks in album {}".format(
-                    self.backend_name, self.album
-                )
+                f"ReplayGain backend `{self.backend_name}` failed "
+                f"for some tracks in album {self.album}"
             )
         for item, track_gain in zip(self.items, self.track_gains):
             self._store_track_gain(item, track_gain)
@@ -517,12 +511,10 @@ class FfmpegBackend(Backend):
                 if self._parse_float(b"M: " + line[1]) >= gating_threshold:
                     n_blocks += 1
             self._log.debug(
-                "{}: {} blocks over {} LUFS".format(
-                    item, n_blocks, gating_threshold
-                )
+                f"{item}: {n_blocks} blocks over {gating_threshold} LUFS"
             )

-        self._log.debug("{}: gain {} LU, peak {}".format(item, gain, peak))
+        self._log.debug(f"{item}: gain {gain} LU, peak {peak}")

         return Gain(gain, peak), n_blocks

@@ -542,9 +534,7 @@ class FfmpegBackend(Backend):
             if output[i].startswith(search):
                 return i
         raise ReplayGainError(
-            "ffmpeg output: missing {} after line {}".format(
-                repr(search), start_line
-            )
+            f"ffmpeg output: missing {search!r} after line {start_line}"
         )

     def _parse_float(self, line: bytes) -> float:
@@ -591,7 +581,7 @@ class CommandBackend(Backend):
             # Explicit executable path.
             if not os.path.isfile(self.command):
                 raise FatalReplayGainError(
-                    "replaygain command does not exist: {}".format(self.command)
+                    f"replaygain command does not exist: {self.command}"
                 )
         else:
             # Check whether the program is in $PATH.
@@ -1241,10 +1231,8 @@ class ReplayGainPlugin(BeetsPlugin):

         if self.backend_name not in BACKENDS:
             raise ui.UserError(
-                "Selected ReplayGain backend {} is not supported. "
-                "Please select one of: {}".format(
-                    self.backend_name, ", ".join(BACKENDS.keys())
-                )
+                f"Selected ReplayGain backend {self.backend_name} is not supported. "
+                f"Please select one of: {', '.join(BACKENDS.keys())}"
             )

         # FIXME: Consider renaming the configuration option to 'peak_method'
@@ -1252,10 +1240,8 @@ class ReplayGainPlugin(BeetsPlugin):
         peak_method = self.config["peak"].as_str()
         if peak_method not in PeakMethod.__members__:
             raise ui.UserError(
-                "Selected ReplayGain peak method {} is not supported. "
-                "Please select one of: {}".format(
-                    peak_method, ", ".join(PeakMethod.__members__)
-                )
+                f"Selected ReplayGain peak method {peak_method} is not supported. "
+                f"Please select one of: {', '.join(PeakMethod.__members__)}"
             )
         # This only applies to plain old rg tags, r128 doesn't store peak
         # values.
@@ -1543,18 +1529,14 @@ class ReplayGainPlugin(BeetsPlugin):
             if opts.album:
                 albums = lib.albums(ui.decargs(args))
                 self._log.info(
-                    "Analyzing {} albums ~ {} backend...".format(
-                        len(albums), self.backend_name
-                    )
+                    f"Analyzing {len(albums)} albums ~ {self.backend_name} backend..."
                 )
                 for album in albums:
                     self.handle_album(album, write, force)
             else:
                 items = lib.items(ui.decargs(args))
                 self._log.info(
-                    "Analyzing {} tracks ~ {} backend...".format(
-                        len(items), self.backend_name
-                    )
+                    f"Analyzing {len(items)} tracks ~ {self.backend_name} backend..."
                 )
                 for item in items:
                     self.handle_track(item, write, force)
diff --git c/beetsplug/smartplaylist.py i/beetsplug/smartplaylist.py
index 9df2cca6..35419f98 100644
--- c/beetsplug/smartplaylist.py
+++ i/beetsplug/smartplaylist.py
@@ -313,10 +313,11 @@ class SmartPlaylistPlugin(BeetsPlugin):
                 )
                 mkdirall(m3u_path)
                 pl_format = self.config["output"].get()
-                if pl_format != "m3u" and pl_format != "extm3u":
-                    msg = "Unsupported output format '{}' provided! "
-                    msg += "Supported: m3u, extm3u"
-                    raise Exception(msg.format(pl_format))
+                if pl_format not in ["m3u", "extm3u"]:
+                    raise Exception(
+                        f"Unsupported output format '{pl_format}' provided! "
+                        "Supported: m3u, extm3u"
+                    )
                 extm3u = pl_format == "extm3u"
                 with open(syspath(m3u_path), "wb") as f:
                     keys = []
@@ -332,9 +333,7 @@ class SmartPlaylistPlugin(BeetsPlugin):
                                 f" {a[0]}={json.dumps(str(a[1]))}" for a in attr
                             ]
                             attrs = "".join(al)
-                            comment = "#EXTINF:{}{},{} - {}\n".format(
-                                int(item.length), attrs, item.artist, item.title
-                            )
+                            comment = f"#EXTINF:{int(item.length)}{attrs},{item.artist} - {item.title}\n"
                         f.write(comment.encode("utf-8") + entry.uri + b"\n")
             # Send an event when playlists were updated.
             send_event("smartplaylist_update")
diff --git c/beetsplug/spotify.py i/beetsplug/spotify.py
index 55a77a8a..ec30363a 100644
--- c/beetsplug/spotify.py
+++ i/beetsplug/spotify.py
@@ -146,7 +146,7 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin):
             response.raise_for_status()
         except requests.exceptions.HTTPError as e:
             raise ui.UserError(
-                "Spotify authorization failed: {}\n{}".format(e, response.text)
+                f"Spotify authorization failed: {e}\n{response.text}"
             )
         self.access_token = response.json()["access_token"]

@@ -271,9 +271,7 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin):
         else:
             raise ui.UserError(
                 "Invalid `release_date_precision` returned "
-                "by {} API: '{}'".format(
-                    self.data_source, release_date_precision
-                )
+                f"by {self.data_source} API: '{release_date_precision}'"
             )

         tracks_data = album_data["tracks"]
@@ -457,17 +455,15 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin):
             "-m",
             "--mode",
             action="store",
-            help='"open" to open {} with playlist, '
-            '"list" to print (default)'.format(self.data_source),
+            help=f'"open" to open {self.data_source} with '
+            'playlist, "list" to print (default)',
         )
         spotify_cmd.parser.add_option(
             "-f",
             "--show-failures",
             action="store_true",
             dest="show_failures",
-            help="list tracks that did not match a {} ID".format(
-                self.data_source
-            ),
+            help=f"list tracks that did not match a {self.data_source} ID",
         )
         spotify_cmd.func = queries

@@ -628,9 +624,7 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin):
             spotify_ids = [track_data["id"] for track_data in results]
             if self.config["mode"].get() == "open":
                 self._log.info(
-                    "Attempting to open {} with playlist".format(
-                        self.data_source
-                    )
+                    f"Attempting to open {self.data_source} with playlist"
                 )
                 spotify_url = "spotify:trackset:Playlist:" + ",".join(
                     spotify_ids
diff --git c/beetsplug/thumbnails.py i/beetsplug/thumbnails.py
index 19c19f06..acca413d 100644
--- c/beetsplug/thumbnails.py
+++ i/beetsplug/thumbnails.py
@@ -204,7 +204,7 @@ class ThumbnailsPlugin(BeetsPlugin):
         artfile = os.path.split(album.artpath)[1]
         with open(syspath(outfilename), "w") as f:
             f.write("[Desktop Entry]\n")
-            f.write("Icon=./{}".format(artfile.decode("utf-8")))
+            f.write(f"Icon=./{artfile.decode('utf-8')}")
             f.close()
         self._log.debug("Wrote file {0}", displayable_path(outfilename))

diff --git c/beetsplug/types.py i/beetsplug/types.py
index 9ba3aac6..451d9e98 100644
--- c/beetsplug/types.py
+++ i/beetsplug/types.py
@@ -45,6 +45,6 @@ class TypesPlugin(BeetsPlugin):
                 mytypes[key] = library.DateType()
             else:
                 raise ConfigValueError(
-                    "unknown type '{}' for the '{}' field".format(value, key)
+                    f"unknown type '{value}' for the '{key}' field"
                 )
         return mytypes
diff --git c/test/plugins/test_art.py i/test/plugins/test_art.py
index 20bbcdce..a5244c20 100644
--- c/test/plugins/test_art.py
+++ i/test/plugins/test_art.py
@@ -64,11 +64,11 @@ class FetchImageTestCase(FetchImageHelper, UseThePlugin):
 class CAAHelper:
     """Helper mixin for mocking requests to the Cover Art Archive."""

-    MBID_RELASE = "rid"
+    MBID_RELEASE = "rid"
     MBID_GROUP = "rgid"

-    RELEASE_URL = "coverartarchive.org/release/{}".format(MBID_RELASE)
-    GROUP_URL = "coverartarchive.org/release-group/{}".format(MBID_GROUP)
+    RELEASE_URL = f"coverartarchive.org/release/{MBID_RELEASE}"
+    GROUP_URL = f"coverartarchive.org/release-group/{MBID_GROUP}"

     RELEASE_URL = "https://" + RELEASE_URL
     GROUP_URL = "https://" + GROUP_URL
@@ -281,10 +281,8 @@ class FSArtTest(UseThePlugin):
 class CombinedTest(FetchImageTestCase, CAAHelper):
     ASIN = "xxxx"
     MBID = "releaseid"
-    AMAZON_URL = "https://images.amazon.com/images/P/{}.01.LZZZZZZZ.jpg".format(
-        ASIN
-    )
-    AAO_URL = "https://www.albumart.org/index_detail.php?asin={}".format(ASIN)
+    AMAZON_URL = f"https://images.amazon.com/images/P/{ASIN}.01.LZZZZZZZ.jpg"
+    AAO_URL = f"https://www.albumart.org/index_detail.php?asin={ASIN}"

     def setUp(self):
         super().setUp()
@@ -342,7 +340,7 @@ class CombinedTest(FetchImageTestCase, CAAHelper):
             content_type="image/jpeg",
         )
         album = _common.Bag(
-            mb_albumid=self.MBID_RELASE,
+            mb_albumid=self.MBID_RELEASE,
             mb_releasegroupid=self.MBID_GROUP,
             asin=self.ASIN,
         )
@@ -562,7 +560,7 @@ class CoverArtArchiveTest(UseThePlugin, CAAHelper):

     def test_caa_finds_image(self):
         album = _common.Bag(
-            mb_albumid=self.MBID_RELASE, mb_releasegroupid=self.MBID_GROUP
+            mb_albumid=self.MBID_RELEASE, mb_releasegroupid=self.MBID_GROUP
         )
         self.mock_caa_response(self.RELEASE_URL, self.RESPONSE_RELEASE)
         self.mock_caa_response(self.GROUP_URL, self.RESPONSE_GROUP)
@@ -578,7 +576,7 @@ class CoverArtArchiveTest(UseThePlugin, CAAHelper):
         self.settings = Settings(maxwidth=maxwidth)

         album = _common.Bag(
-            mb_albumid=self.MBID_RELASE, mb_releasegroupid=self.MBID_GROUP
+            mb_albumid=self.MBID_RELEASE, mb_releasegroupid=self.MBID_GROUP
         )
         self.mock_caa_response(self.RELEASE_URL, self.RESPONSE_RELEA…
See: <beetbox#5337 (comment)>

diff --git c/beets/autotag/mb.py i/beets/autotag/mb.py
index e16643d7..f152b567 100644
--- c/beets/autotag/mb.py
+++ i/beets/autotag/mb.py
@@ -66,7 +66,9 @@ class MusicBrainzAPIError(util.HumanReadableException):
         super().__init__(reason, verb, tb)

     def get_message(self):
-        return f"{self._reasonstr()} in {self.verb} with query {self.query!r}"
+        return (
+            f"{self._reasonstr()} in {self.verb} with query {repr(self.query)}"
+        )

 log = logging.getLogger("beets")
diff --git c/beets/dbcore/db.py i/beets/dbcore/db.py
index 55ba6f11..5aa75aa5 100755
--- c/beets/dbcore/db.py
+++ i/beets/dbcore/db.py
@@ -397,7 +397,7 @@ class Model(ABC):

     def __repr__(self) -> str:
         name = type(self).__name__
-        fields = ", ".join(f"{k}={v!r}" for k, v in dict(self).items())
+        fields = ", ".join(f"{k}={repr(v)}" for k, v in dict(self).items())
         return f"{name}({fields})"

     def clear_dirty(self):
@@ -558,12 +558,12 @@ class Model(ABC):

     def __getattr__(self, key):
         if key.startswith("_"):
-            raise AttributeError(f"model has no attribute {key!r}")
+            raise AttributeError(f"model has no attribute {repr(key)}")
         else:
             try:
                 return self[key]
             except KeyError:
-                raise AttributeError(f"no such field {key!r}")
+                raise AttributeError(f"no such field {repr(key)}")

     def __setattr__(self, key, value):
         if key.startswith("_"):
diff --git c/beets/dbcore/query.py i/beets/dbcore/query.py
index 357b5685..6e94ddd5 100644
--- c/beets/dbcore/query.py
+++ i/beets/dbcore/query.py
@@ -171,7 +171,7 @@ class FieldQuery(Query, Generic[P]):

     def __repr__(self) -> str:
         return (
-            f"{self.__class__.__name__}({self.field_name!r}, {self.pattern!r}, "
+            f"{self.__class__.__name__}({repr(self.field_name)}, {repr(self.pattern)}, "
             f"fast={self.fast})"
         )

@@ -210,7 +210,9 @@ class NoneQuery(FieldQuery[None]):
         return obj.get(self.field_name) is None

     def __repr__(self) -> str:
-        return f"{self.__class__.__name__}({self.field_name!r}, {self.fast})"
+        return (
+            f"{self.__class__.__name__}({repr(self.field_name)}, {self.fast})"
+        )

 class StringFieldQuery(FieldQuery[P]):
@@ -503,7 +505,7 @@ class CollectionQuery(Query):
         return clause, subvals

     def __repr__(self) -> str:
-        return f"{self.__class__.__name__}({self.subqueries!r})"
+        return f"{self.__class__.__name__}({repr(self.subqueries)})"

     def __eq__(self, other) -> bool:
         return super().__eq__(other) and self.subqueries == other.subqueries
@@ -548,7 +550,7 @@ class AnyFieldQuery(CollectionQuery):

     def __repr__(self) -> str:
         return (
-            f"{self.__class__.__name__}({self.pattern!r}, {self.fields!r}, "
+            f"{self.__class__.__name__}({repr(self.pattern)}, {repr(self.fields)}, "
             f"{self.query_class.__name__})"
         )

@@ -619,7 +621,7 @@ class NotQuery(Query):
         return not self.subquery.match(obj)

     def __repr__(self) -> str:
-        return f"{self.__class__.__name__}({self.subquery!r})"
+        return f"{self.__class__.__name__}({repr(self.subquery)})"

     def __eq__(self, other) -> bool:
         return super().__eq__(other) and self.subquery == other.subquery
@@ -975,7 +977,7 @@ class MultipleSort(Sort):
         return items

     def __repr__(self):
-        return f"{self.__class__.__name__}({self.sorts!r})"
+        return f"{self.__class__.__name__}({repr(self.sorts)})"

     def __hash__(self):
         return hash(tuple(self.sorts))
@@ -1015,7 +1017,7 @@ class FieldSort(Sort):
     def __repr__(self) -> str:
         return (
             f"{self.__class__.__name__}"
-            f"({self.field!r}, ascending={self.ascending!r})"
+            f"({repr(self.field)}, ascending={repr(self.ascending)})"
         )

     def __hash__(self) -> int:
diff --git c/beets/library.py i/beets/library.py
index 77d24ecd..a9adc13d 100644
--- c/beets/library.py
+++ i/beets/library.py
@@ -156,7 +156,7 @@ class PathQuery(dbcore.FieldQuery[bytes]):

     def __repr__(self) -> str:
         return (
-            f"{self.__class__.__name__}({self.field!r}, {self.pattern!r}, "
+            f"{self.__class__.__name__}({repr(self.field)}, {repr(self.pattern)}, "
             f"fast={self.fast}, case_sensitive={self.case_sensitive})"
         )

@@ -735,7 +735,7 @@ class Item(LibModel):
         # can even deadlock due to the database lock.
         name = type(self).__name__
         keys = self.keys(with_album=False)
-        fields = (f"{k}={self[k]!r}" for k in keys)
+        fields = (f"{k}={repr(self[k])}" for k in keys)
         return f"{name}({', '.join(fields)})"

     def keys(self, computed=False, with_album=True):
@@ -1578,7 +1578,7 @@ def parse_query_string(s, model_cls):

     The string is split into components using shell-like syntax.
     """
-    message = f"Query is not unicode: {s!r}"
+    message = f"Query is not unicode: {repr(s)}"
     assert isinstance(s, str), message
     try:
         parts = shlex.split(s)
diff --git c/beets/test/_common.py i/beets/test/_common.py
index c12838e2..0bc1baf8 100644
--- c/beets/test/_common.py
+++ i/beets/test/_common.py
@@ -152,7 +152,7 @@ class Assertions:
     """A mixin with additional unit test assertions."""

     def assertExists(self, path):  # noqa
-        assert os.path.exists(syspath(path)), f"file does not exist: {path!r}"
+        assert os.path.exists(syspath(path)), f"file does not exist: {repr(path)}"

     def assertNotExists(self, path):  # noqa
         assert not os.path.exists(syspath(path)), f"file exists: {repr(path)}"
@@ -186,7 +186,7 @@ class InputException(Exception):
     def __str__(self):
         msg = "Attempt to read with no input provided."
         if self.output is not None:
-            msg += f" Output: {self.output!r}"
+            msg += f" Output: {repr(self.output)}"
         return msg

diff --git c/beets/ui/commands.py i/beets/ui/commands.py
index 3042ca77..a717c94c 100755
--- c/beets/ui/commands.py
+++ i/beets/ui/commands.py
@@ -213,7 +213,7 @@ def get_singleton_disambig_fields(info: hooks.TrackInfo) -> Sequence[str]:
     out = []
     chosen_fields = config["match"]["singleton_disambig_fields"].as_str_seq()
     calculated_values = {
-        "index": f"Index {info.index!s}",
+        "index": f"Index {str(info.index)}",
         "track_alt": f"Track {info.track_alt}",
         "album": (
             f"[{info.album}]"
diff --git c/beets/util/__init__.py i/beets/util/__init__.py
index aa94b6d2..a0f13fa1 100644
--- c/beets/util/__init__.py
+++ i/beets/util/__init__.py
@@ -104,7 +104,7 @@ class HumanReadableException(Exception):
         elif hasattr(self.reason, "strerror"):  # i.e., EnvironmentError
             return self.reason.strerror
         else:
-            return f'"{self.reason!s}"'
+            return f'"{str(self.reason)}"'

     def get_message(self):
         """Create the human-readable description of the error, sans
diff --git c/beets/util/functemplate.py i/beets/util/functemplate.py
index 35f60b7d..f149d370 100644
--- c/beets/util/functemplate.py
+++ i/beets/util/functemplate.py
@@ -166,7 +166,7 @@ class Call:
         self.original = original

     def __repr__(self):
-        return f"Call({self.ident!r}, {self.args!r}, {self.original!r})"
+        return f"Call({repr(self.ident)}, {repr(self.args)}, {repr(self.original)})"

     def evaluate(self, env):
         """Evaluate the function call in the environment, returning a
diff --git c/beetsplug/bpd/__init__.py i/beetsplug/bpd/__init__.py
index d6c75380..3336702c 100644
--- c/beetsplug/bpd/__init__.py
+++ i/beetsplug/bpd/__init__.py
@@ -1142,7 +1142,7 @@ class Server(BaseServer):
             pass

         for tagtype, field in self.tagtype_map.items():
-            info_lines.append(f"{tagtype}: {getattr(item, field)!s}")
+            info_lines.append(f"{tagtype}: {str(getattr(item, field))}")

         return info_lines

@@ -1301,7 +1301,7 @@ class Server(BaseServer):

             yield (
                 f"bitrate: {item.bitrate / 1000}",
-                f"audio: {item.samplerate!s}:{item.bitdepth!s}:{item.channels!s}",
+                f"audio: {str(item.samplerate)}:{str(item.bitdepth)}:{str(item.channels)}",
             )

             (pos, total) = self.player.time()
diff --git c/beetsplug/edit.py i/beetsplug/edit.py
index 61f2020a..20430255 100644
--- c/beetsplug/edit.py
+++ i/beetsplug/edit.py
@@ -47,7 +47,9 @@ def edit(filename, log):
     try:
         subprocess.call(cmd)
     except OSError as exc:
-        raise ui.UserError(f"could not run editor command {cmd[0]!r}: {exc}")
+        raise ui.UserError(
+            f"could not run editor command {repr(cmd[0])}: {exc}"
+        )

 def dump(arg):
diff --git c/beetsplug/replaygain.py i/beetsplug/replaygain.py
index 78cce1e6..1c8aaaa9 100644
--- c/beetsplug/replaygain.py
+++ i/beetsplug/replaygain.py
@@ -534,7 +534,7 @@ class FfmpegBackend(Backend):
             if output[i].startswith(search):
                 return i
         raise ReplayGainError(
-            f"ffmpeg output: missing {search!r} after line {start_line}"
+            f"ffmpeg output: missing {repr(search)} after line {start_line}"
         )

     def _parse_float(self, line: bytes) -> float:
@@ -547,7 +547,7 @@ class FfmpegBackend(Backend):
         parts = line.split(b":", 1)
         if len(parts) < 2:
             raise ReplayGainError(
-                f"ffmpeg output: expected key value pair, found {line!r}"
+                f"ffmpeg output: expected key value pair, found {repr(line)}"
             )
         value = parts[1].lstrip()
         # strip unit
@@ -557,7 +557,7 @@ class FfmpegBackend(Backend):
             return float(value)
         except ValueError:
             raise ReplayGainError(
-                f"ffmpeg output: expected float value, found {value!r}"
+                f"ffmpeg output: expected float value, found {repr(value)}"
             )

@@ -886,7 +886,7 @@ class GStreamerBackend(Backend):
         f = self._src.get_property("location")
         # A GStreamer error, either an unsupported format or a bug.
         self._error = ReplayGainError(
-            f"Error {err!r} - {debug!r} on file {f!r}"
+            f"Error {repr(err)} - {repr(debug)} on file {repr(f)}"
         )

     def _on_tag(self, bus, message):
diff --git c/beetsplug/thumbnails.py i/beetsplug/thumbnails.py
index acca413d..0cde56c7 100644
--- c/beetsplug/thumbnails.py
+++ i/beetsplug/thumbnails.py
@@ -292,4 +292,6 @@ class GioURI(URIGetter):
         try:
             return uri.decode(util._fsencoding())
         except UnicodeDecodeError:
-            raise RuntimeError(f"Could not decode filename from GIO: {uri!r}")
+            raise RuntimeError(
+                f"Could not decode filename from GIO: {repr(uri)}"
+            )
diff --git c/test/plugins/test_lyrics.py i/test/plugins/test_lyrics.py
index 7cb081fc..484d4889 100644
--- c/test/plugins/test_lyrics.py
+++ i/test/plugins/test_lyrics.py
@@ -223,9 +223,9 @@ class LyricsAssertions:

         if not keywords <= words:
             details = (
-                f"{keywords!r} is not a subset of {words!r}."
-                f" Words only in expected set {keywords - words!r},"
-                f" Words only in result set {words - keywords!r}."
+                f"{repr(keywords)} is not a subset of {repr(words)}."
+                f" Words only in expected set {repr(keywords - words)},"
+                f" Words only in result set {repr(words - keywords)}."
             )
             self.fail(f"{details} : {msg}")

diff --git c/test/plugins/test_player.py i/test/plugins/test_player.py
index bf466e1b..e23b6396 100644
--- c/test/plugins/test_player.py
+++ i/test/plugins/test_player.py
@@ -132,7 +132,7 @@ class MPCResponse:
             cmd, rest = rest[2:].split("}")
             return False, (int(code), int(pos), cmd, rest[1:])
         else:
-            raise RuntimeError(f"Unexpected status: {status!r}")
+            raise RuntimeError(f"Unexpected status: {repr(status)}")

     def _parse_body(self, body):
         """Messages are generally in the format "header: content".
@@ -145,7 +145,7 @@ class MPCResponse:
             if not line:
                 continue
             if ":" not in line:
-                raise RuntimeError(f"Unexpected line: {line!r}")
+                raise RuntimeError(f"Unexpected line: {repr(line)}")
             header, content = line.split(":", 1)
             content = content.lstrip()
             if header in repeated_headers:
@@ -191,7 +191,7 @@ class MPCClient:
                 responses.append(MPCResponse(response))
                 response = b""
             elif not line:
-                raise RuntimeError(f"Unexpected response: {line!r}")
+                raise RuntimeError(f"Unexpected response: {repr(line)}")

     def serialise_command(self, command, *args):
         cmd = [command.encode("utf-8")]
diff --git c/test/plugins/test_thumbnails.py i/test/plugins/test_thumbnails.py
index 07775995..1931061b 100644
--- c/test/plugins/test_thumbnails.py
+++ i/test/plugins/test_thumbnails.py
@@ -71,7 +71,7 @@ class ThumbnailsTest(BeetsTestCase):
                 return False
             if path == syspath(LARGE_DIR):
                 return True
-            raise ValueError(f"unexpected path {path!r}")
+            raise ValueError(f"unexpected path {repr(path)}")

         mock_os.path.exists = exists
         plugin = ThumbnailsPlugin()

diff --git c/beets/autotag/mb.py i/beets/autotag/mb.py
index 537123a77..1402c9420 100644
--- c/beets/autotag/mb.py
+++ i/beets/autotag/mb.py
@@ -66,7 +66,9 @@ class MusicBrainzAPIError(util.HumanReadableError):
         super().__init__(reason, verb, tb)

     def get_message(self):
-        return f"{self._reasonstr()} in {self.verb} with query {self.query!r}"
+        return (
+            f"{self._reasonstr()} in {self.verb} with query {repr(self.query)}"
+        )

 log = logging.getLogger("beets")
diff --git c/beets/dbcore/db.py i/beets/dbcore/db.py
index 55ba6f110..5aa75aa59 100755
--- c/beets/dbcore/db.py
+++ i/beets/dbcore/db.py
@@ -397,7 +397,7 @@ class Model(ABC):

     def __repr__(self) -> str:
         name = type(self).__name__
-        fields = ", ".join(f"{k}={v!r}" for k, v in dict(self).items())
+        fields = ", ".join(f"{k}={repr(v)}" for k, v in dict(self).items())
         return f"{name}({fields})"

     def clear_dirty(self):
@@ -558,12 +558,12 @@ class Model(ABC):

     def __getattr__(self, key):
         if key.startswith("_"):
-            raise AttributeError(f"model has no attribute {key!r}")
+            raise AttributeError(f"model has no attribute {repr(key)}")
         else:
             try:
                 return self[key]
             except KeyError:
-                raise AttributeError(f"no such field {key!r}")
+                raise AttributeError(f"no such field {repr(key)}")

     def __setattr__(self, key, value):
         if key.startswith("_"):
diff --git c/beets/dbcore/query.py i/beets/dbcore/query.py
index 357b56857..6e94ddd51 100644
--- c/beets/dbcore/query.py
+++ i/beets/dbcore/query.py
@@ -171,7 +171,7 @@ class FieldQuery(Query, Generic[P]):

     def __repr__(self) -> str:
         return (
-            f"{self.__class__.__name__}({self.field_name!r}, {self.pattern!r}, "
+            f"{self.__class__.__name__}({repr(self.field_name)}, {repr(self.pattern)}, "
             f"fast={self.fast})"
         )

@@ -210,7 +210,9 @@ class NoneQuery(FieldQuery[None]):
         return obj.get(self.field_name) is None

     def __repr__(self) -> str:
-        return f"{self.__class__.__name__}({self.field_name!r}, {self.fast})"
+        return (
+            f"{self.__class__.__name__}({repr(self.field_name)}, {self.fast})"
+        )

 class StringFieldQuery(FieldQuery[P]):
@@ -503,7 +505,7 @@ class CollectionQuery(Query):
         return clause, subvals

     def __repr__(self) -> str:
-        return f"{self.__class__.__name__}({self.subqueries!r})"
+        return f"{self.__class__.__name__}({repr(self.subqueries)})"

     def __eq__(self, other) -> bool:
         return super().__eq__(other) and self.subqueries == other.subqueries
@@ -548,7 +550,7 @@ class AnyFieldQuery(CollectionQuery):

     def __repr__(self) -> str:
         return (
-            f"{self.__class__.__name__}({self.pattern!r}, {self.fields!r}, "
+            f"{self.__class__.__name__}({repr(self.pattern)}, {repr(self.fields)}, "
             f"{self.query_class.__name__})"
         )

@@ -619,7 +621,7 @@ class NotQuery(Query):
         return not self.subquery.match(obj)

     def __repr__(self) -> str:
-        return f"{self.__class__.__name__}({self.subquery!r})"
+        return f"{self.__class__.__name__}({repr(self.subquery)})"

     def __eq__(self, other) -> bool:
         return super().__eq__(other) and self.subquery == other.subquery
@@ -975,7 +977,7 @@ class MultipleSort(Sort):
         return items

     def __repr__(self):
-        return f"{self.__class__.__name__}({self.sorts!r})"
+        return f"{self.__class__.__name__}({repr(self.sorts)})"

     def __hash__(self):
         return hash(tuple(self.sorts))
@@ -1015,7 +1017,7 @@ class FieldSort(Sort):
     def __repr__(self) -> str:
         return (
             f"{self.__class__.__name__}"
-            f"({self.field!r}, ascending={self.ascending!r})"
+            f"({repr(self.field)}, ascending={repr(self.ascending)})"
         )

     def __hash__(self) -> int:
diff --git c/beets/library.py i/beets/library.py
index 9a9dedf38..89420cfe1 100644
--- c/beets/library.py
+++ i/beets/library.py
@@ -157,7 +157,7 @@ class PathQuery(dbcore.FieldQuery[bytes]):

     def __repr__(self) -> str:
         return (
-            f"{self.__class__.__name__}({self.field!r}, {self.pattern!r}, "
+            f"{self.__class__.__name__}({repr(self.field)}, {repr(self.pattern)}, "
             f"fast={self.fast}, case_sensitive={self.case_sensitive})"
         )

@@ -736,7 +736,7 @@ class Item(LibModel):
         # can even deadlock due to the database lock.
         name = type(self).__name__
         keys = self.keys(with_album=False)
-        fields = (f"{k}={self[k]!r}" for k in keys)
+        fields = (f"{k}={repr(self[k])}" for k in keys)
         return f"{name}({', '.join(fields)})"

     def keys(self, computed=False, with_album=True):
@@ -1579,7 +1579,7 @@ def parse_query_string(s, model_cls):

     The string is split into components using shell-like syntax.
     """
-    message = f"Query is not unicode: {s!r}"
+    message = f"Query is not unicode: {repr(s)}"
     assert isinstance(s, str), message
     try:
         parts = shlex.split(s)
diff --git c/beets/test/_common.py i/beets/test/_common.py
index abb2e6ae9..2fda9760d 100644
--- c/beets/test/_common.py
+++ i/beets/test/_common.py
@@ -178,7 +178,7 @@ class InputError(Exception):
     def __str__(self):
         msg = "Attempt to read with no input provided."
         if self.output is not None:
-            msg += f" Output: {self.output!r}"
+            msg += f" Output: {repr(self.output)}"
         return msg

diff --git c/beets/ui/commands.py i/beets/ui/commands.py
index 4988027b9..78267a329 100755
--- c/beets/ui/commands.py
+++ i/beets/ui/commands.py
@@ -212,7 +212,7 @@ def get_singleton_disambig_fields(info: hooks.TrackInfo) -> Sequence[str]:
     out = []
     chosen_fields = config["match"]["singleton_disambig_fields"].as_str_seq()
     calculated_values = {
-        "index": f"Index {info.index!s}",
+        "index": f"Index {str(info.index)}",
         "track_alt": f"Track {info.track_alt}",
         "album": (
             f"[{info.album}]"
diff --git c/beets/util/__init__.py i/beets/util/__init__.py
index 437bb57d1..251f1eaee 100644
--- c/beets/util/__init__.py
+++ i/beets/util/__init__.py
@@ -106,7 +106,7 @@ class HumanReadableError(Exception):
         elif hasattr(self.reason, "strerror"):  # i.e., EnvironmentError
             return self.reason.strerror
         else:
-            return f'"{self.reason!s}"'
+            return f'"{str(self.reason)}"'

     def get_message(self):
         """Create the human-readable description of the error, sans
diff --git c/beets/util/functemplate.py i/beets/util/functemplate.py
index 768371b07..cae646ab0 100644
--- c/beets/util/functemplate.py
+++ i/beets/util/functemplate.py
@@ -165,7 +165,7 @@ class Call:
         self.original = original

     def __repr__(self):
-        return f"Call({self.ident!r}, {self.args!r}, {self.original!r})"
+        return f"Call({repr(self.ident)}, {repr(self.args)}, {repr(self.original)})"

     def evaluate(self, env):
         """Evaluate the function call in the environment, returning a
diff --git c/beetsplug/bpd/__init__.py i/beetsplug/bpd/__init__.py
index f9fdab7b7..10e1b0828 100644
--- c/beetsplug/bpd/__init__.py
+++ i/beetsplug/bpd/__init__.py
@@ -1141,7 +1141,7 @@ class Server(BaseServer):
             pass

         for tagtype, field in self.tagtype_map.items():
-            info_lines.append(f"{tagtype}: {getattr(item, field)!s}")
+            info_lines.append(f"{tagtype}: {str(getattr(item, field))}")

         return info_lines

@@ -1300,7 +1300,7 @@ class Server(BaseServer):

             yield (
                 f"bitrate: {item.bitrate / 1000}",
-                f"audio: {item.samplerate!s}:{item.bitdepth!s}:{item.channels!s}",
+                f"audio: {str(item.samplerate)}:{str(item.bitdepth)}:{str(item.channels)}",
             )

             (pos, total) = self.player.time()
diff --git c/beetsplug/edit.py i/beetsplug/edit.py
index d53b5942f..28821e97c 100644
--- c/beetsplug/edit.py
+++ i/beetsplug/edit.py
@@ -46,7 +46,9 @@ def edit(filename, log):
     try:
         subprocess.call(cmd)
     except OSError as exc:
-        raise ui.UserError(f"could not run editor command {cmd[0]!r}: {exc}")
+        raise ui.UserError(
+            f"could not run editor command {repr(cmd[0])}: {exc}"
+        )

 def dump(arg):
diff --git c/beetsplug/replaygain.py i/beetsplug/replaygain.py
index dac1018bb..54441341e 100644
--- c/beetsplug/replaygain.py
+++ i/beetsplug/replaygain.py
@@ -532,7 +532,7 @@ class FfmpegBackend(Backend):
             if output[i].startswith(search):
                 return i
         raise ReplayGainError(
-            f"ffmpeg output: missing {search!r} after line {start_line}"
+            f"ffmpeg output: missing {repr(search)} after line {start_line}"
         )

     def _parse_float(self, line: bytes) -> float:
@@ -545,7 +545,7 @@ class FfmpegBackend(Backend):
         parts = line.split(b":", 1)
         if len(parts) < 2:
             raise ReplayGainError(
-                f"ffmpeg output: expected key value pair, found {line!r}"
+                f"ffmpeg output: expected key value pair, found {repr(line)}"
             )
         value = parts[1].lstrip()
         # strip unit
@@ -555,7 +555,7 @@ class FfmpegBackend(Backend):
             return float(value)
         except ValueError:
             raise ReplayGainError(
-                f"ffmpeg output: expected float value, found {value!r}"
+                f"ffmpeg output: expected float value, found {repr(value)}"
             )

@@ -884,7 +884,7 @@ class GStreamerBackend(Backend):
         f = self._src.get_property("location")
         # A GStreamer error, either an unsupported format or a bug.
         self._error = ReplayGainError(
-            f"Error {err!r} - {debug!r} on file {f!r}"
+            f"Error {repr(err)} - {repr(debug)} on file {repr(f)}"
         )

     def _on_tag(self, bus, message):
diff --git c/beetsplug/thumbnails.py i/beetsplug/thumbnails.py
index bd377d7f9..873f32445 100644
--- c/beetsplug/thumbnails.py
+++ i/beetsplug/thumbnails.py
@@ -290,4 +290,6 @@ class GioURI(URIGetter):
         try:
             return uri.decode(util._fsencoding())
         except UnicodeDecodeError:
-            raise RuntimeError(f"Could not decode filename from GIO: {uri!r}")
+            raise RuntimeError(
+                f"Could not decode filename from GIO: {repr(uri)}"
+            )
diff --git c/test/plugins/test_lyrics.py i/test/plugins/test_lyrics.py
index 937e0a3cb..104b847c2 100644
--- c/test/plugins/test_lyrics.py
+++ i/test/plugins/test_lyrics.py
@@ -223,9 +223,9 @@ class LyricsAssertions:

         if not keywords <= words:
             details = (
-                f"{keywords!r} is not a subset of {words!r}."
-                f" Words only in expected set {keywords - words!r},"
-                f" Words only in result set {words - keywords!r}."
+                f"{repr(keywords)} is not a subset of {repr(words)}."
+                f" Words only in expected set {repr(keywords - words)},"
+                f" Words only in result set {repr(words - keywords)}."
             )
             self.fail(f"{details} : {msg}")

diff --git c/test/plugins/test_player.py i/test/plugins/test_player.py
index b17a78c17..4e59cda06 100644
--- c/test/plugins/test_player.py
+++ i/test/plugins/test_player.py
@@ -132,7 +132,7 @@ class MPCResponse:
             cmd, rest = rest[2:].split("}")
             return False, (int(code), int(pos), cmd, rest[1:])
         else:
-            raise RuntimeError(f"Unexpected status: {status!r}")
+            raise RuntimeError(f"Unexpected status: {repr(status)}")

     def _parse_body(self, body):
         """Messages are generally in the format "header: content".
@@ -145,7 +145,7 @@ class MPCResponse:
             if not line:
                 continue
             if ":" not in line:
-                raise RuntimeError(f"Unexpected line: {line!r}")
+                raise RuntimeError(f"Unexpected line: {repr(line)}")
             header, content = line.split(":", 1)
             content = content.lstrip()
             if header in repeated_headers:
@@ -191,7 +191,7 @@ class MPCClient:
                 responses.append(MPCResponse(response))
                 response = b""
             elif not line:
-                raise RuntimeError(f"Unexpected response: {line!r}")
+                raise RuntimeError(f"Unexpected response: {repr(line)}")

     def serialise_command(self, command, *args):
         cmd = [command.encode("utf-8")]
diff --git c/test/plugins/test_thumbnails.py i/test/plugins/test_thumbnails.py
index 3eb36cd25..c9e1c7743 100644
--- c/test/plugins/test_thumbnails.py
+++ i/test/plugins/test_thumbnails.py
@@ -71,7 +71,7 @@ class ThumbnailsTest(BeetsTestCase):
                 return False
             if path == syspath(LARGE_DIR):
                 return True
-            raise ValueError(f"unexpected path {path!r}")
+            raise ValueError(f"unexpected path {repr(path)}")

         mock_os.path.exists = exists
         plugin = ThumbnailsPlugin()
The logic is a bit easier to follow now.

See: <beetbox#5337 (comment)>
The new version doesn't rely on regular expressions, provides more
intuitive names, and will probably be easier to maintain.

See: <beetbox#5337 (comment)>
In cases where the values being filled in did not intuitively describe
what they represented as URL components, it became difficult to figure
out the structure of the URL.

See: <beetbox#5337 (comment)>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Update to use f-strings instead of .format now that Python 3.8 is supported
4 participants