diff --git a/beets/library.py b/beets/library.py index 2890ee901..b76c8998f 100644 --- a/beets/library.py +++ b/beets/library.py @@ -229,6 +229,8 @@ class WriteError(FileOperationError): class LibModel(dbcore.Model): """Shared concrete functionality for Items and Albums. """ + _format_config_key = None + """Config key that specifies how an instance should be formatted""" def _template_funcs(self): funcs = DefaultTemplateFunctions(self, self._db).functions() @@ -247,6 +249,22 @@ class LibModel(dbcore.Model): super(LibModel, self).add(lib) plugins.send('database_change', lib=self._db) + def __format__(self, spec): + if not spec: + spec = beets.config[self._format_config_key].get(unicode) + result = self.evaluate_template(spec) + if isinstance(spec, bytes): + # if spec is a byte string then we must return a one as well + return result.encode('utf8') + else: + return result + + def __str__(self): + return format(self).encode('utf8') + + def __unicode__(self): + return format(self) + class FormattedItemMapping(dbcore.db.FormattedMapping): """Add lookup for album-level fields. @@ -383,6 +401,8 @@ class Item(LibModel): _sorts = {'artist': SmartArtistSort} + _format_config_key = 'list_format_item' + @classmethod def _getters(cls): getters = plugins.item_field_getters() @@ -789,6 +809,8 @@ class Album(LibModel): """List of keys that are set on an album's items. """ + _format_config_key = 'list_format_album' + @classmethod def _getters(cls): # In addition to plugin-provided computed fields, also expose diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index c541ba2de..a6b7e5518 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -97,6 +97,9 @@ def print_(*strings): """Like print, but rather than raising an error when a character is not in the terminal's encoding's character set, just silently replaces it. + + If the arguments are strings then they're expected to share the same type: + either bytes or unicode. """ if strings: if isinstance(strings[0], unicode): @@ -471,31 +474,6 @@ def get_replacements(): return replacements -def _pick_format(album, fmt=None): - """Pick a format string for printing Album or Item objects, - falling back to config options and defaults. - """ - if fmt: - return fmt - if album: - return config['list_format_album'].get(unicode) - else: - return config['list_format_item'].get(unicode) - - -def print_obj(obj, lib, fmt=None): - """Print an Album or Item object. If `fmt` is specified, use that - format string. Otherwise, use the configured template. - """ - album = isinstance(obj, library.Album) - fmt = _pick_format(album, fmt) - if isinstance(fmt, Template): - template = fmt - else: - template = Template(fmt) - print_(obj.evaluate_template(template)) - - def term_width(): """Get the width (columns) of the terminal.""" fallback = config['ui']['terminal_width'].get(int) @@ -587,7 +565,7 @@ def show_model_changes(new, old=None, fields=None, always=False): # Print changes. if changes or always: - print_obj(old, old._db) + print_(format(old)) if changes: print_(u'\n'.join(changes)) diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 839263fc5..34e8b7518 100644 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -32,7 +32,6 @@ from beets import plugins from beets import importer from beets import util from beets.util import syspath, normpath, ancestry, displayable_path -from beets.util.functemplate import Template from beets import library from beets import config from beets import logging @@ -951,20 +950,16 @@ def list_items(lib, query, album, fmt): """Print out items in lib matching query. If album, then search for albums instead of single items. """ - tmpl = Template(ui._pick_format(album, fmt)) if album: for album in lib.albums(query): - ui.print_obj(album, lib, tmpl) + ui.print_(format(album, fmt)) else: for item in lib.items(query): - ui.print_obj(item, lib, tmpl) + ui.print_(format(item, fmt)) def list_func(lib, opts, args): - if opts.path: - fmt = '$path' - else: - fmt = opts.format + fmt = '$path' if opts.path else opts.format list_items(lib, decargs(args), opts.album, fmt) @@ -979,7 +974,7 @@ list_cmd.parser.add_option( ) list_cmd.parser.add_option( '-f', '--format', action='store', - help='print with custom format', default=None + help='print with custom format', default='' ) list_cmd.func = list_func default_commands.append(list_cmd) @@ -999,7 +994,7 @@ def update_items(lib, query, album, move, pretend): for item in items: # Item deleted? if not os.path.exists(syspath(item.path)): - ui.print_obj(item, lib) + ui.print_(format(item)) ui.print_(ui.colorize('red', u' deleted')) if not pretend: item.remove(True) @@ -1095,7 +1090,7 @@ update_cmd.parser.add_option( ) update_cmd.parser.add_option( '-f', '--format', action='store', - help='print with custom format', default=None + help='print with custom format', default='' ) update_cmd.func = update_func default_commands.append(update_cmd) @@ -1116,13 +1111,13 @@ def remove_items(lib, query, album, delete): fmt = u'$path - $title' prompt = 'Really DELETE %i files (y/n)?' % len(items) else: - fmt = None + fmt = '' prompt = 'Really remove %i items from the library (y/n)?' % \ len(items) # Show all the items. for item in items: - ui.print_obj(item, lib, fmt) + ui.print_(format(item, fmt)) # Confirm with user. if not ui.input_yn(prompt, True): @@ -1352,7 +1347,7 @@ modify_cmd.parser.add_option( ) modify_cmd.parser.add_option( '-f', '--format', action='store', - help='print with custom format', default=None + help='print with custom format', default='' ) modify_cmd.func = modify_func default_commands.append(modify_cmd) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index baf084423..4274448ad 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -356,7 +356,7 @@ class ConvertPlugin(BeetsPlugin): self.config['pretend'].get(bool) if not pretend: - ui.commands.list_items(lib, ui.decargs(args), opts.album, None) + ui.commands.list_items(lib, ui.decargs(args), opts.album, '') if not (opts.yes or ui.input_yn("Convert? (Y/n)")): return diff --git a/beetsplug/duplicates.py b/beetsplug/duplicates.py index 60a49d091..ac5b19c17 100644 --- a/beetsplug/duplicates.py +++ b/beetsplug/duplicates.py @@ -17,14 +17,14 @@ import shlex from beets.plugins import BeetsPlugin -from beets.ui import decargs, print_obj, vararg_callback, Subcommand, UserError +from beets.ui import decargs, print_, vararg_callback, Subcommand, UserError from beets.util import command_output, displayable_path, subprocess PLUGIN = 'duplicates' def _process_item(item, lib, copy=False, move=False, delete=False, - tag=False, format=None): + tag=False, format=''): """Process Item `item` in `lib`. """ if copy: @@ -42,7 +42,7 @@ def _process_item(item, lib, copy=False, move=False, delete=False, raise UserError('%s: can\'t parse k=v tag: %s' % (PLUGIN, tag)) setattr(k, v) item.store() - print_obj(item, lib, fmt=format) + print_(format(item, format)) def _checksum(item, prog, log): @@ -126,7 +126,7 @@ class DuplicatesPlugin(BeetsPlugin): self._command.parser.add_option('-f', '--format', dest='format', action='store', type='string', help='print with custom format', - metavar='FMT') + metavar='FMT', default='') self._command.parser.add_option('-a', '--album', dest='album', action='store_true', diff --git a/beetsplug/echonest.py b/beetsplug/echonest.py index a2b24bf20..fc66ad775 100644 --- a/beetsplug/echonest.py +++ b/beetsplug/echonest.py @@ -115,7 +115,7 @@ def similar(lib, src_item, threshold=0.15, fmt='${difference}: ${path}'): d = diff(item, src_item) if d < threshold: s = fmt.replace('${difference}', '{:2.2f}'.format(d)) - ui.print_obj(item, lib, s) + ui.print_(format(item, s)) class EchonestMetadataPlugin(plugins.BeetsPlugin): @@ -401,10 +401,9 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin): for method in methods: song = method(item) if song: - self._log.debug(u'got song through {0}: {1} - {2} [{3}]', + self._log.debug(u'got song through {0}: {1} [{2}]', method.__name__, - item.artist, - item.title, + item, song.get('duration'), ) return song @@ -471,7 +470,7 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin): self.config.set_args(opts) write = config['import']['write'].get(bool) for item in lib.items(ui.decargs(args)): - self._log.info(u'{0} - {1}', item.artist, item.title) + self._log.info(u'{0}', item) if self.config['force'] or self.requires_update(item): song = self.fetch_song(item) if song: diff --git a/beetsplug/embedart.py b/beetsplug/embedart.py index efb32e0fa..27f45243c 100644 --- a/beetsplug/embedart.py +++ b/beetsplug/embedart.py @@ -26,12 +26,6 @@ from beets.ui import decargs from beets.util import syspath, normpath, displayable_path from beets.util.artresizer import ArtResizer from beets import config -from beets.util.functemplate import Template - -__item_template = Template(ui._pick_format(False)) -fmt_item = lambda item: item.evaluate_template(__item_template) -__album_template = Template(ui._pick_format(True)) -fmt_album = lambda item: item.evaluate_template(__album_template) class EmbedCoverArtPlugin(BeetsPlugin): @@ -146,16 +140,16 @@ class EmbedCoverArtPlugin(BeetsPlugin): """ imagepath = album.artpath if not imagepath: - self._log.info(u'No album art present for {0}', fmt_album(album)) + self._log.info(u'No album art present for {0}', album) return if not os.path.isfile(syspath(imagepath)): self._log.info(u'Album art not found at {0} for {1}', - displayable_path(imagepath), fmt_album(album)) + displayable_path(imagepath), album) return if maxwidth: imagepath = self.resize_image(imagepath, maxwidth) - self._log.info(u'Embedding album art into {0}', fmt_album(album)) + self._log.info(u'Embedding album art into {0}', album) for item in album.items(): thresh = self.config['compare_threshold'].get(int) @@ -244,8 +238,7 @@ class EmbedCoverArtPlugin(BeetsPlugin): art = self.get_art(item) if not art: - self._log.info(u'No album art present in {0}, skipping.', - fmt_item(item)) + self._log.info(u'No album art present in {0}, skipping.', item) return # Add an extension to the filename. @@ -257,7 +250,7 @@ class EmbedCoverArtPlugin(BeetsPlugin): outpath += '.' + ext self._log.info(u'Extracting album art from: {0} to: {1}', - fmt_item(item), displayable_path(outpath)) + item, displayable_path(outpath)) with open(syspath(outpath), 'wb') as f: f.write(art) return outpath @@ -269,7 +262,7 @@ class EmbedCoverArtPlugin(BeetsPlugin): items = lib.items(query) self._log.info(u'Clearing album art from {0} items', len(items)) for item in items: - self._log.debug(u'Clearing art for {0}', fmt_item(item)) + self._log.debug(u'Clearing art for {0}', item) try: mf = mediafile.MediaFile(syspath(item.path), id3v23) except mediafile.UnreadableFileError as exc: diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index 196be4b2d..c5b2d8737 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -448,7 +448,7 @@ class FetchArtPlugin(plugins.BeetsPlugin): else: message = ui.colorize('red', 'no art found') - self._log.info(u'{0.albumartist} - {0.album}: {1}', album, message) + self._log.info(u'{0}: {1}', album, message) def _source_urls(self, album): """Generate possible source URLs for an album's art. The URLs are diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 54604ece6..727ab08fe 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -337,8 +337,8 @@ class LastGenrePlugin(plugins.BeetsPlugin): for album in lib.albums(ui.decargs(args)): album.genre, src = self._get_genre(album) - self._log.info(u'genre for album {0.albumartist} - {0.album} ' - u'({1}): {0.genre}', album, src) + self._log.info(u'genre for album {0} ({1}): {0.genre}', + album, src) album.store() for item in album.items(): @@ -347,8 +347,8 @@ class LastGenrePlugin(plugins.BeetsPlugin): if 'track' in self.sources: item.genre, src = self._get_genre(item) item.store() - self._log.info(u'genre for track {0.artist} - {0.tit' - u'le} ({1}): {0.genre}', item, src) + self._log.info(u'genre for track {0} ({1}): {0.genre}', + item, src) if write: item.try_write() diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py index 5e51b2b7c..9dd1fce34 100644 --- a/beetsplug/lyrics.py +++ b/beetsplug/lyrics.py @@ -508,8 +508,7 @@ class LyricsPlugin(plugins.BeetsPlugin): lyrics will also be written to the file itself.""" # Skip if the item already has lyrics. if not force and item.lyrics: - self._log.info(u'lyrics already present: {0.artist} - {0.title}', - item) + self._log.info(u'lyrics already present: {0}', item) return lyrics = None @@ -521,9 +520,9 @@ class LyricsPlugin(plugins.BeetsPlugin): lyrics = u"\n\n---\n\n".join([l for l in lyrics if l]) if lyrics: - self._log.info(u'fetched lyrics: {0.artist} - {0.title}', item) + self._log.info(u'fetched lyrics: {0}', item) else: - self._log.info(u'lyrics not found: {0.artist} - {0.title}', item) + self._log.info(u'lyrics not found: {0}', item) fallback = self.config['fallback'].get() if fallback: lyrics = fallback diff --git a/beetsplug/mbsync.py b/beetsplug/mbsync.py index a4e242026..aea50fd15 100644 --- a/beetsplug/mbsync.py +++ b/beetsplug/mbsync.py @@ -18,7 +18,6 @@ from beets.plugins import BeetsPlugin from beets import autotag, library, ui, util from beets.autotag import hooks from beets import config -from beets.util.functemplate import Template from collections import defaultdict @@ -50,7 +49,7 @@ class MBSyncPlugin(BeetsPlugin): cmd.parser.add_option('-W', '--nowrite', action='store_false', default=config['import']['write'], dest='write', help="don't write updated metadata to files") - cmd.parser.add_option('-f', '--format', action='store', default=None, + cmd.parser.add_option('-f', '--format', action='store', default='', help='print with custom format') cmd.func = self.func return [cmd] @@ -71,10 +70,8 @@ class MBSyncPlugin(BeetsPlugin): """Retrieve and apply info from the autotagger for items matched by query. """ - template = Template(ui._pick_format(False, fmt)) - for item in lib.items(query + ['singleton:true']): - item_formatted = item.evaluate_template(template) + item_formatted = format(item, fmt) if not item.mb_trackid: self._log.info(u'Skipping singleton with no mb_trackid: {0}', item_formatted) @@ -97,11 +94,9 @@ class MBSyncPlugin(BeetsPlugin): """Retrieve and apply info from the autotagger for albums matched by query and their items. """ - template = Template(ui._pick_format(True, fmt)) - # Process matching albums. for a in lib.albums(query): - album_formatted = a.evaluate_template(template) + album_formatted = format(a, fmt) if not a.mb_albumid: self._log.info(u'Skipping album with no mb_albumid: {0}', album_formatted) diff --git a/beetsplug/missing.py b/beetsplug/missing.py index cfcb3a958..1748c731d 100644 --- a/beetsplug/missing.py +++ b/beetsplug/missing.py @@ -17,7 +17,7 @@ from beets.autotag import hooks from beets.library import Item from beets.plugins import BeetsPlugin -from beets.ui import decargs, print_obj, Subcommand +from beets.ui import decargs, print_, Subcommand def _missing_count(album): @@ -95,7 +95,7 @@ class MissingPlugin(BeetsPlugin): self._command.parser.add_option('-f', '--format', dest='format', action='store', type='string', help='print with custom FORMAT', - metavar='FORMAT') + metavar='FORMAT', default='') self._command.parser.add_option('-c', '--count', dest='count', action='store_true', @@ -123,13 +123,12 @@ class MissingPlugin(BeetsPlugin): for album in albums: if count: - missing = _missing_count(album) - if missing: - print_obj(album, lib, fmt=fmt) + if _missing_count(album): + print_(format(album, fmt)) else: for item in self._missing(album): - print_obj(item, lib, fmt=fmt) + print_(format(item, fmt)) self._command.func = _miss return [self._command] diff --git a/beetsplug/random.py b/beetsplug/random.py index 2c4d0c000..fefe46aaf 100644 --- a/beetsplug/random.py +++ b/beetsplug/random.py @@ -16,8 +16,7 @@ """ from __future__ import absolute_import from beets.plugins import BeetsPlugin -from beets.ui import Subcommand, decargs, print_obj -from beets.util.functemplate import Template +from beets.ui import Subcommand, decargs, print_ import random from operator import attrgetter from itertools import groupby @@ -25,11 +24,7 @@ from itertools import groupby def random_item(lib, opts, args): query = decargs(args) - if opts.path: - fmt = '$path' - else: - fmt = opts.format - template = Template(fmt) if fmt else None + fmt = '$path' if opts.path else opts.format if opts.album: objs = list(lib.albums(query)) @@ -66,7 +61,7 @@ def random_item(lib, opts, args): objs = random.sample(objs, number) for item in objs: - print_obj(item, lib, template) + print_(format(item, fmt)) random_cmd = Subcommand('random', help='chose a random track or album') @@ -75,7 +70,7 @@ random_cmd.parser.add_option('-a', '--album', action='store_true', random_cmd.parser.add_option('-p', '--path', action='store_true', help='print the path of the matched item') random_cmd.parser.add_option('-f', '--format', action='store', - help='print with custom format', default=None) + help='print with custom format', default='') random_cmd.parser.add_option('-n', '--number', action='store', type="int", help='number of objects to choose', default=1) random_cmd.parser.add_option('-e', '--equal-chance', action='store_true', diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index c2e21cd92..ec3941d12 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -558,7 +558,7 @@ class AudioToolsBackend(Backend): :rtype: :class:`AlbumGain` """ - self._log.debug(u'Analysing album {0.albumartist} - {0.album}', album) + self._log.debug(u'Analysing album {0}', album) # The first item is taken and opened to get the sample rate to # initialize the replaygain object. The object is used for all the @@ -574,15 +574,13 @@ class AudioToolsBackend(Backend): track_gains.append( Gain(gain=rg_track_gain, peak=rg_track_peak) ) - self._log.debug(u'ReplayGain for track {0.artist} - {0.title}: ' - u'{1:.2f}, {2:.2f}', + self._log.debug(u'ReplayGain for track {0}: {1:.2f}, {2:.2f}', item, rg_track_gain, rg_track_peak) # After getting the values for all tracks, it's possible to get the # album values. rg_album_gain, rg_album_peak = rg.album_gain() - self._log.debug(u'ReplayGain for album {0.albumartist} - {0.album}: ' - u'{1:.2f}, {2:.2f}', + self._log.debug(u'ReplayGain for album {0}: {1:.2f}, {2:.2f}', album, rg_album_gain, rg_album_peak) return AlbumGain( @@ -674,20 +672,17 @@ class ReplayGainPlugin(BeetsPlugin): items, nothing is done. """ if not self.album_requires_gain(album): - self._log.info(u'Skipping album {0} - {1}', - album.albumartist, album.album) + self._log.info(u'Skipping album {0}', album) return - self._log.info(u'analyzing {0} - {1}', album.albumartist, album.album) + self._log.info(u'analyzing {0}', album) try: album_gain = self.backend_instance.compute_album_gain(album) if len(album_gain.track_gains) != len(album.items()): raise ReplayGainError( u"ReplayGain backend failed " - u"for some tracks in album {0} - {1}".format( - album.albumartist, album.album - ) + u"for some tracks in album {0}".format(album) ) self.store_album_gain(album, album_gain.album_gain) @@ -711,18 +706,16 @@ class ReplayGainPlugin(BeetsPlugin): in the item, nothing is done. """ if not self.track_requires_gain(item): - self._log.info(u'Skipping track {0.artist} - {0.title}', item) + self._log.info(u'Skipping track {0}', item) return - self._log.info(u'analyzing {0} - {1}', item.artist, item.title) + self._log.info(u'analyzing {0}', item) try: track_gains = self.backend_instance.compute_track_gain([item]) if len(track_gains) != 1: raise ReplayGainError( - u"ReplayGain backend failed for track {0} - {1}".format( - item.artist, item.title - ) + u"ReplayGain backend failed for track {0}".format(item) ) self.store_track_gain(item, track_gains[0]) diff --git a/test/test_library.py b/test/test_library.py index 58e24fd75..a83fae8a4 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- # This file is part of beets. # Copyright 2015, Adrian Sampson. # @@ -1078,6 +1079,23 @@ class TemplateTest(_common.LibTestCase): self.album.store() self.assertEqual(self.i.evaluate_template('$foo'), 'baz') + def test_album_and_item_format(self): + config['list_format_album'] = u'foö $foo' + album = beets.library.Album() + album.foo = 'bar' + album.tagada = 'togodo' + self.assertEqual(u"{0}".format(album), u"foö bar") + self.assertEqual(u"{0:$tagada}".format(album), u"togodo") + self.assertEqual(unicode(album), u"foö bar") + self.assertEqual(str(album), b"fo\xc3\xb6 bar") + + config['list_format_item'] = 'bar $foo' + item = beets.library.Item() + item.foo = 'bar' + item.tagada = 'togodo' + self.assertEqual("{0}".format(item), "bar bar") + self.assertEqual("{0:$tagada}".format(item), "togodo") + class UnicodePathTest(_common.LibTestCase): def test_unicode_path(self): diff --git a/test/test_ui.py b/test/test_ui.py index e32f9ed83..162694565 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -43,7 +43,7 @@ class ListTest(unittest.TestCase): self.lib.add(self.item) self.lib.add_album([self.item]) - def _run_list(self, query='', album=False, path=False, fmt=None): + def _run_list(self, query='', album=False, path=False, fmt=''): commands.list_items(self.lib, query, album, fmt) def test_list_outputs_item(self):