diff --git a/beets/autotag/distance.py b/beets/autotag/distance.py index db5da585e..727439ea3 100644 --- a/beets/autotag/distance.py +++ b/beets/autotag/distance.py @@ -78,9 +78,9 @@ def string_dist(str1: str | None, str2: str | None) -> float: # example, "the something" should be considered equal to # "something, the". for word in SD_END_WORDS: - if str1.endswith(", %s" % word): + if str1.endswith(f", {word}"): str1 = f"{word} {str1[: -len(word) - 2]}" - if str2.endswith(", %s" % word): + if str2.endswith(f", {word}"): str2 = f"{word} {str2[: -len(word) - 2]}" # Perform a couple of basic normalizing substitutions. @@ -444,7 +444,7 @@ def distance( # Preferred media options. media_patterns: Sequence[str] = preferred_config["media"].as_str_seq() options = [ - re.compile(r"(\d+x)?(%s)" % pat, re.I) for pat in media_patterns + re.compile(rf"(\d+x)?({pat})", re.I) for pat in media_patterns ] if options: dist.add_priority("media", album_info.media, options) diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index 82c7217b7..5c84653d7 100755 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -1158,7 +1158,7 @@ class Database: """ # Get current schema. with self.transaction() as tx: - rows = tx.query("PRAGMA table_info(%s)" % table) + rows = tx.query(f"PRAGMA table_info({table})") current_fields = {row[1] for row in rows} field_names = set(fields.keys()) diff --git a/beets/library/models.py b/beets/library/models.py index e004fb83b..fbcfd94f1 100644 --- a/beets/library/models.py +++ b/beets/library/models.py @@ -482,7 +482,7 @@ class Album(LibModel): """ item = self.items().get() if not item: - raise ValueError("empty album for album id %d" % self.id) + raise ValueError(f"empty album for album id {self.id}") return os.path.dirname(item.path) def _albumtotal(self): diff --git a/beets/test/helper.py b/beets/test/helper.py index f1633c110..cd8f520fa 100644 --- a/beets/test/helper.py +++ b/beets/test/helper.py @@ -831,8 +831,8 @@ class AutotagStub: def _make_track_match(self, artist, album, number): return TrackInfo( - title="Applied Track %d" % number, - track_id="match %d" % number, + title=f"Applied Track {number}", + track_id=f"match {number}", artist=artist, length=1, index=0, diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 60c99c8e1..92372dea4 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -269,7 +269,7 @@ def input_options( ) ): # The first option is the default; mark it. - show_letter = "[%s]" % found_letter.upper() + show_letter = f"[{found_letter.upper()}]" is_default = True else: show_letter = found_letter.upper() @@ -308,9 +308,9 @@ def input_options( if isinstance(default, int): default_name = str(default) default_name = colorize("action_default", default_name) - tmpl = "# selection (default %s)" - prompt_parts.append(tmpl % default_name) - prompt_part_lengths.append(len(tmpl % str(default))) + tmpl = "# selection (default {})" + prompt_parts.append(tmpl.format(default_name)) + prompt_part_lengths.append(len(tmpl) - 2 + len(str(default))) else: prompt_parts.append("# selection") prompt_part_lengths.append(len(prompt_parts[-1])) @@ -349,7 +349,7 @@ def input_options( if not fallback_prompt: fallback_prompt = "Enter one of " if numrange: - fallback_prompt += "%i-%i, " % numrange + fallback_prompt += "{}-{}, ".format(*numrange) fallback_prompt += ", ".join(display_letters) + ":" resp = input_(prompt) @@ -406,7 +406,7 @@ def input_select_objects(prompt, objs, rep, prompt_all=None): objects individually. """ choice = input_options( - ("y", "n", "s"), False, "%s? (Yes/no/select)" % (prompt_all or prompt) + ("y", "n", "s"), False, f"{prompt_all or prompt}? (Yes/no/select)" ) print() # Blank line. @@ -420,7 +420,7 @@ def input_select_objects(prompt, objs, rep, prompt_all=None): answer = input_options( ("y", "n", "q"), True, - "%s? (yes/no/quit)" % prompt, + f"{prompt}? (yes/no/quit)", "Enter Y or N:", ) if answer == "y": @@ -534,7 +534,7 @@ def _colorize(color, text): # over all "ANSI codes" in `color`. escape = "" for code in color: - escape = escape + COLOR_ESCAPE + "%im" % ANSI_CODES[code] + escape = escape + COLOR_ESCAPE + f"{ANSI_CODES[code]}m" return escape + text + RESET_COLOR @@ -1475,7 +1475,7 @@ class SubcommandsOptionParser(CommonOptionsParser): for subcommand in subcommands: name = subcommand.name if subcommand.aliases: - name += " (%s)" % ", ".join(subcommand.aliases) + name += f" ({', '.join(subcommand.aliases)})" disp_names.append(name) # Set the help position based on the max width. @@ -1488,26 +1488,18 @@ class SubcommandsOptionParser(CommonOptionsParser): # Lifted directly from optparse.py. name_width = help_position - formatter.current_indent - 2 if len(name) > name_width: - name = "%*s%s\n" % (formatter.current_indent, "", name) + name = f"{' ' * formatter.current_indent}{name}\n" indent_first = help_position else: - name = "%*s%-*s " % ( - formatter.current_indent, - "", - name_width, - name, - ) + name = f"{' ' * formatter.current_indent}{name:<{name_width}}\n" indent_first = 0 result.append(name) help_width = formatter.width - help_position help_lines = textwrap.wrap(subcommand.help, help_width) help_line = help_lines[0] if help_lines else "" - result.append("%*s%s\n" % (indent_first, "", help_line)) + result.append(f"{' ' * indent_first}{help_line}\n") result.extend( - [ - "%*s%s\n" % (help_position, "", line) - for line in help_lines[1:] - ] + [f"{' ' * help_position}{line}\n" for line in help_lines[1:]] ) formatter.dedent() diff --git a/beets/ui/commands.py b/beets/ui/commands.py index d76d2b2ab..509b0e70b 100755 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -144,13 +144,13 @@ def fields_func(lib, opts, args): with lib.transaction() as tx: # The SQL uses the DISTINCT to get unique values from the query - unique_fields = "SELECT DISTINCT key FROM (%s)" + unique_fields = "SELECT DISTINCT key FROM ({})" print_("Item flexible attributes:") - _print_keys(tx.query(unique_fields % library.Item._flex_table)) + _print_keys(tx.query(unique_fields.format(library.Item._flex_table))) print_("Album flexible attributes:") - _print_keys(tx.query(unique_fields % library.Album._flex_table)) + _print_keys(tx.query(unique_fields.format(library.Album._flex_table))) fields_cmd = ui.Subcommand( @@ -1926,7 +1926,7 @@ default_commands.append(stats_cmd) def show_version(lib, opts, args): - print_("beets version %s" % beets.__version__) + print_(f"beets version {beets.__version__}") print_(f"Python version {python_version()}") # Show plugins. names = sorted(p.name for p in plugins.find_plugins()) @@ -1990,7 +1990,7 @@ def modify_items(lib, mods, dels, query, write, move, album, confirm, inherit): extra = "" changed = ui.input_select_objects( - "Really modify%s" % extra, + f"Really modify{extra}", changed, lambda o: print_and_modify(o, mods, dels), ) @@ -2168,7 +2168,7 @@ def move_items( else: if confirm: objs = ui.input_select_objects( - "Really %s" % act, + f"Really {act}", objs, lambda o: show_path_changes( [(o.path, o.destination(basedir=dest))] @@ -2461,22 +2461,18 @@ def completion_script(commands): yield "_beet() {\n" # Command names - yield " local commands='%s'\n" % " ".join(command_names) + yield f" local commands={' '.join(command_names)!r}\n" yield "\n" # Command aliases - yield " local aliases='%s'\n" % " ".join(aliases.keys()) + yield f" local aliases={' '.join(aliases.keys())!r}\n" for alias, cmd in aliases.items(): yield f" local alias__{alias.replace('-', '_')}={cmd}\n" yield "\n" # Fields - yield " fields='%s'\n" % " ".join( - set( - list(library.Item._fields.keys()) - + list(library.Album._fields.keys()) - ) - ) + fields = library.Item._fields.keys() | library.Album._fields.keys() + yield f" fields={' '.join(fields)!r}\n" # Command options for cmd, opts in options.items(): diff --git a/beets/util/bluelet.py b/beets/util/bluelet.py index b81b389e0..3f3a88b1e 100644 --- a/beets/util/bluelet.py +++ b/beets/util/bluelet.py @@ -559,7 +559,7 @@ def spawn(coro): and child coroutines run concurrently. """ if not isinstance(coro, types.GeneratorType): - raise ValueError("%s is not a coroutine" % coro) + raise ValueError(f"{coro} is not a coroutine") return SpawnEvent(coro) @@ -569,7 +569,7 @@ def call(coro): returns a value using end(), then this event returns that value. """ if not isinstance(coro, types.GeneratorType): - raise ValueError("%s is not a coroutine" % coro) + raise ValueError(f"{coro} is not a coroutine") return DelegationEvent(coro) diff --git a/beets/util/functemplate.py b/beets/util/functemplate.py index 768371b07..ed4c35596 100644 --- a/beets/util/functemplate.py +++ b/beets/util/functemplate.py @@ -136,7 +136,7 @@ class Symbol: self.original = original def __repr__(self): - return "Symbol(%s)" % repr(self.ident) + return f"Symbol({self.ident!r})" def evaluate(self, env): """Evaluate the symbol in the environment, returning a Unicode @@ -178,7 +178,7 @@ class Call: except Exception as exc: # Function raised exception! Maybe inlining the name of # the exception will help debug. - return "<%s>" % str(exc) + return f"<{exc}>" return str(out) else: return self.original @@ -224,7 +224,7 @@ class Expression: self.parts = parts def __repr__(self): - return "Expression(%s)" % (repr(self.parts)) + return f"Expression({self.parts!r})" def evaluate(self, env): """Evaluate the entire expression in the environment, returning @@ -296,9 +296,6 @@ class Parser: GROUP_CLOSE, ESCAPE_CHAR, ) - special_char_re = re.compile( - r"[%s]|\Z" % "".join(re.escape(c) for c in special_chars) - ) escapable_chars = (SYMBOL_DELIM, FUNC_DELIM, GROUP_CLOSE, ARG_SEP) terminator_chars = (GROUP_CLOSE,) @@ -310,24 +307,18 @@ class Parser: """ # Append comma (ARG_SEP) to the list of special characters only when # parsing function arguments. - extra_special_chars = () - special_char_re = self.special_char_re - if self.in_argument: - extra_special_chars = (ARG_SEP,) - special_char_re = re.compile( - r"[%s]|\Z" - % "".join( - re.escape(c) - for c in self.special_chars + extra_special_chars - ) - ) + extra_special_chars = (ARG_SEP,) if self.in_argument else () + special_chars = (*self.special_chars, *extra_special_chars) + special_char_re = re.compile( + rf"[{''.join(map(re.escape, special_chars))}]|\Z" + ) text_parts = [] while self.pos < len(self.string): char = self.string[self.pos] - if char not in self.special_chars + extra_special_chars: + if char not in special_chars: # A non-special character. Skip to the next special # character, treating the interstice as literal text. next_pos = ( diff --git a/beets/util/units.py b/beets/util/units.py index d07d42546..f5fcb743b 100644 --- a/beets/util/units.py +++ b/beets/util/units.py @@ -19,7 +19,7 @@ def human_seconds_short(interval): string. """ interval = int(interval) - return "%i:%02i" % (interval // 60, interval % 60) + return f"{interval // 60}:{interval % 60:02d}" def human_bytes(size): diff --git a/beetsplug/bpd/__init__.py b/beetsplug/bpd/__init__.py index 1da15e949..6e45d5721 100644 --- a/beetsplug/bpd/__init__.py +++ b/beetsplug/bpd/__init__.py @@ -52,7 +52,7 @@ except ImportError as e: PROTOCOL_VERSION = "0.16.0" BUFSIZE = 1024 -HELLO = "OK MPD %s" % PROTOCOL_VERSION +HELLO = f"OK MPD {PROTOCOL_VERSION}" CLIST_BEGIN = "command_list_begin" CLIST_VERBOSE_BEGIN = "command_list_ok_begin" CLIST_END = "command_list_end" @@ -1219,7 +1219,7 @@ class Server(BaseServer): if dirpath.startswith("/"): # Strip leading slash (libmpc rejects this). dirpath = dirpath[1:] - yield "directory: %s" % dirpath + yield f"directory: {dirpath}" def _listall(self, basepath, node, info=False): """Helper function for recursive listing. If info, show diff --git a/beetsplug/bucket.py b/beetsplug/bucket.py index aefeb5ce3..71d7f06db 100644 --- a/beetsplug/bucket.py +++ b/beetsplug/bucket.py @@ -41,7 +41,7 @@ def span_from_str(span_str): def normalize_year(d, yearfrom): """Convert string to a 4 digits year""" if yearfrom < 100: - raise BucketError("%d must be expressed on 4 digits" % yearfrom) + raise BucketError(f"{yearfrom} must be expressed on 4 digits") # if two digits only, pick closest year that ends by these two # digits starting from yearfrom @@ -55,14 +55,13 @@ def span_from_str(span_str): years = [int(x) for x in re.findall(r"\d+", span_str)] if not years: raise ui.UserError( - "invalid range defined for year bucket '%s': no year found" - % span_str + f"invalid range defined for year bucket {span_str!r}: no year found" ) try: years = [normalize_year(x, years[0]) for x in years] except BucketError as exc: raise ui.UserError( - "invalid range defined for year bucket '%s': %s" % (span_str, exc) + f"invalid range defined for year bucket {span_str!r}: {exc}" ) res = {"from": years[0], "str": span_str} @@ -126,18 +125,18 @@ def str2fmt(s): "tonchars": len(m.group("toyear")), } res["fmt"] = ( - f"{m['bef']}%s{m['sep']}{'%s' if res['tonchars'] else ''}{m['after']}" + f"{m['bef']}{{}}{m['sep']}{'{}' if res['tonchars'] else ''}{m['after']}" ) return res def format_span(fmt, yearfrom, yearto, fromnchars, tonchars): """Return a span string representation.""" - args = str(yearfrom)[-fromnchars:] + args = [str(yearfrom)[-fromnchars:]] if tonchars: - args = (str(yearfrom)[-fromnchars:], str(yearto)[-tonchars:]) + args.append(str(yearto)[-tonchars:]) - return fmt % args + return fmt.format(*args) def extract_modes(spans): @@ -166,7 +165,7 @@ def build_alpha_spans(alpha_spans_str, alpha_regexs): else: raise ui.UserError( "invalid range defined for alpha bucket " - "'%s': no alphanumeric character found" % elem + f"'{elem}': no alphanumeric character found" ) spans.append( re.compile( diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index e1ec5aa09..538a8e6fb 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -593,7 +593,7 @@ class CoverArtArchive(RemoteArtSource): class Amazon(RemoteArtSource): NAME = "Amazon" ID = "amazon" - URL = "https://images.amazon.com/images/P/%s.%02i.LZZZZZZZ.jpg" + URL = "https://images.amazon.com/images/P/{}.{:02d}.LZZZZZZZ.jpg" INDICES = (1, 2) def get( @@ -606,7 +606,7 @@ class Amazon(RemoteArtSource): if album.asin: for index in self.INDICES: yield self._candidate( - url=self.URL % (album.asin, index), + url=self.URL.format(album.asin, index), match=MetadataMatch.EXACT, ) diff --git a/beetsplug/inline.py b/beetsplug/inline.py index c4258fc83..3c728bf8d 100644 --- a/beetsplug/inline.py +++ b/beetsplug/inline.py @@ -28,8 +28,7 @@ class InlineError(Exception): def __init__(self, code, exc): super().__init__( - ("error in inline path field code:\n%s\n%s: %s") - % (code, type(exc).__name__, str(exc)) + f"error in inline path field code:\n{code}\n{type(exc).__name__}: {exc}" ) diff --git a/beetsplug/metasync/amarok.py b/beetsplug/metasync/amarok.py index 9afe6dbca..1e0793d25 100644 --- a/beetsplug/metasync/amarok.py +++ b/beetsplug/metasync/amarok.py @@ -46,7 +46,7 @@ class Amarok(MetaSource): query_xml = ' \ \ - \ + \ \ ' @@ -68,7 +68,7 @@ class Amarok(MetaSource): # of the result set. So query for the filename and then try to match # the correct item from the results we get back results = self.collection.Query( - self.query_xml % quoteattr(basename(path)) + self.query_xml.format(quoteattr(basename(path))) ) for result in results: if result["xesam:url"] != path: diff --git a/beetsplug/mpdupdate.py b/beetsplug/mpdupdate.py index cb53afaa5..3e950cf54 100644 --- a/beetsplug/mpdupdate.py +++ b/beetsplug/mpdupdate.py @@ -111,7 +111,7 @@ class MPDUpdatePlugin(BeetsPlugin): return if password: - s.send(b'password "%s"\n' % password.encode("utf8")) + s.send(f'password "{password}"\n'.encode()) resp = s.readline() if b"OK" not in resp: self._log.warning("Authentication failed: {0!r}", resp) diff --git a/beetsplug/rewrite.py b/beetsplug/rewrite.py index 83829d657..9489612e1 100644 --- a/beetsplug/rewrite.py +++ b/beetsplug/rewrite.py @@ -57,7 +57,7 @@ class RewritePlugin(BeetsPlugin): raise ui.UserError("invalid rewrite specification") if fieldname not in library.Item._fields: raise ui.UserError( - "invalid field name (%s) in rewriter" % fieldname + f"invalid field name ({fieldname}) in rewriter" ) self._log.debug("adding template field {0}", key) pattern = re.compile(pattern.lower()) diff --git a/beetsplug/web/__init__.py b/beetsplug/web/__init__.py index 80a95bf1d..438fd5021 100644 --- a/beetsplug/web/__init__.py +++ b/beetsplug/web/__init__.py @@ -77,7 +77,7 @@ def json_generator(items, root, expand=False): representation :returns: generator that yields strings """ - yield '{"%s":[' % root + yield f'{{"{root}":[' first = True for item in items: if first: diff --git a/docs/dev/plugins.rst b/docs/dev/plugins.rst index 620e1caec..5ee07347f 100644 --- a/docs/dev/plugins.rst +++ b/docs/dev/plugins.rst @@ -384,9 +384,9 @@ Here's an example that adds a ``$disc_and_track`` field: number. """ if item.disctotal > 1: - return u'%02i.%02i' % (item.disc, item.track) + return f"{item.disc:02d}.{item.track:02d}" else: - return u'%02i' % (item.track) + return f"{item.track:02d}" With this plugin enabled, templates can reference ``$disc_and_track`` as they can any standard metadata field. diff --git a/docs/plugins/inline.rst b/docs/plugins/inline.rst index 46ee3d634..d653b6d52 100644 --- a/docs/plugins/inline.rst +++ b/docs/plugins/inline.rst @@ -20,8 +20,7 @@ Here are a couple of examples of expressions: item_fields: initial: albumartist[0].upper() + u'.' - disc_and_track: u'%02i.%02i' % (disc, track) if - disctotal > 1 else u'%02i' % (track) + disc_and_track: f"{disc:02d}.{track:02d}" if disctotal > 1 else f"{track:02d}" Note that YAML syntax allows newlines in values if the subsequent lines are indented. diff --git a/pyproject.toml b/pyproject.toml index 35493cd01..e0393ea81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -280,6 +280,7 @@ select = [ "PT", # flake8-pytest-style # "RUF", # ruff # "UP", # pyupgrade + "UP031", # do not use percent formatting "UP032", # use f-string instead of format call "TCH", # flake8-type-checking "W", # pycodestyle diff --git a/test/plugins/test_discogs.py b/test/plugins/test_discogs.py index c31ac7511..e3e51042c 100644 --- a/test/plugins/test_discogs.py +++ b/test/plugins/test_discogs.py @@ -82,7 +82,7 @@ class DGAlbumInfoTest(BeetsTestCase): """Return a Bag that mimics a discogs_client.Release with a tracklist where tracks have the specified `positions`.""" tracks = [ - self._make_track("TITLE%s" % i, position) + self._make_track(f"TITLE{i}", position) for (i, position) in enumerate(positions, start=1) ] return self._make_release(tracks) diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py index aea05bc20..97b805924 100644 --- a/test/plugins/test_musicbrainz.py +++ b/test/plugins/test_musicbrainz.py @@ -99,7 +99,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): for recording in tracks: i += 1 track = { - "id": "RELEASE TRACK ID %d" % i, + "id": f"RELEASE TRACK ID {i}", "recording": recording, "position": i, "number": "A1", @@ -140,7 +140,7 @@ class MBAlbumInfoTest(MusicBrainzTestCase): for recording in data_tracks: i += 1 data_track = { - "id": "RELEASE TRACK ID %d" % i, + "id": f"RELEASE TRACK ID {i}", "recording": recording, "position": i, "number": "A1", diff --git a/test/test_dbcore.py b/test/test_dbcore.py index 3f9a9d45e..b2ec2e968 100644 --- a/test/test_dbcore.py +++ b/test/test_dbcore.py @@ -256,7 +256,7 @@ class TransactionTest(unittest.TestCase): def test_query_no_increase_revision(self): old_rev = self.db.revision with self.db.transaction() as tx: - tx.query("PRAGMA table_info(%s)" % ModelFixture1._table) + tx.query(f"PRAGMA table_info({ModelFixture1._table})") assert self.db.revision == old_rev diff --git a/test/test_library.py b/test/test_library.py index 35791bad7..7c0529001 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -1033,7 +1033,7 @@ class ArtDestinationTest(BeetsTestCase): def test_art_filename_respects_setting(self): art = self.ai.art_destination("something.jpg") - new_art = bytestring_path("%sartimage.jpg" % os.path.sep) + new_art = bytestring_path(f"{os.path.sep}artimage.jpg") assert new_art in art def test_art_path_in_item_dir(self): diff --git a/test/test_ui.py b/test/test_ui.py index 664323e2a..63d88f668 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -1020,7 +1020,7 @@ class ConfigTest(TestPluginTestCase): def test_cli_config_file_loads_plugin_commands(self): with open(self.cli_config_path, "w") as file: - file.write("pluginpath: %s\n" % _common.PLUGINPATH) + file.write(f"pluginpath: {_common.PLUGINPATH}\n") file.write("plugins: test") self.run_command("--config", self.cli_config_path, "plugin", lib=None)