From 1ef407672a5575f9a2f769b6e0456593d7899838 Mon Sep 17 00:00:00 2001 From: Diego Moreda Date: Tue, 24 Nov 2015 16:31:01 +0100 Subject: [PATCH 1/8] info: add item format and length format arguments * Add custom output formatting via a format string to InfoPlugin. The command accepts a formatting string via the "-f" parameter, which is handled by its CommonOptionsParser and applied during print_data(). * Modify the emitters in order to include an Item into the list of fields, that is formatted according to the format string if specified. * Add an argument to allow the user to choose if track lengths are displayed as raw floats or using a human-readable form (mm:ss), defaulting to human-readable form. --- beetsplug/info.py | 42 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/beetsplug/info.py b/beetsplug/info.py index d6988d097..e8c6d987f 100644 --- a/beetsplug/info.py +++ b/beetsplug/info.py @@ -25,6 +25,7 @@ import re from beets.plugins import BeetsPlugin from beets import ui from beets import mediafile +from beets.library import Item from beets.util import displayable_path, normpath, syspath @@ -52,7 +53,11 @@ def tag_data_emitter(path): tags[field] = getattr(mf, field) tags['art'] = mf.art is not None tags['path'] = displayable_path(path) + # create a temporary Item to take advantage of __format__ + tags['item'] = Item(db=None, **tags) + return tags + return emitter @@ -65,6 +70,7 @@ def library_data_emitter(item): def emitter(): data = dict(item.formatted()) data['path'] = displayable_path(item.path) + data['item'] = item return data return emitter @@ -78,14 +84,36 @@ def update_summary(summary, tags): return summary -def print_data(data): +def print_data(data, fmt=None, human_length=True): + """Print, with optional formatting, the fields of a single item. + + If no format string `fmt` is passed, the entries on `data` are printed one + in each line, with the format 'field: value'. If `fmt` is not `None`, the + item is printed according to `fmt`, using the `Item.__format__` machinery. + + If `raw_length` is `True`, the `length` field is displayed using its raw + value (float with the number of seconds and miliseconds). If not, a human + readable form is displayed instead (mm:ss). + """ + item = data.pop('item', None) + if fmt: + # use fmt specified by the user, prettifying length if needed + if human_length and '$length' in fmt: + item['humanlength'] = ui.human_seconds_short(item.length) + fmt = fmt.replace('$length', '$humanlength') + ui.print_(format(item, fmt)) + return + path = data.pop('path', None) formatted = {} for key, value in data.iteritems(): if isinstance(value, list): formatted[key] = u'; '.join(value) if value is not None: - formatted[key] = value + if human_length and key == 'length': + formatted[key] = ui.human_seconds_short(float(value)) + else: + formatted[key] = value if len(formatted) == 0: return @@ -115,6 +143,10 @@ class InfoPlugin(BeetsPlugin): cmd.parser.add_option('-i', '--include-keys', default=[], action='append', dest='included_keys', help='comma separated list of keys to show') + cmd.parser.add_option('-r', '--raw-length', action='store_true', + default=False, + help='display length as seconds') + cmd.parser.add_format_option(target='item') return [cmd] def run(self, lib, opts, args): @@ -151,18 +183,20 @@ class InfoPlugin(BeetsPlugin): continue path = data.get('path') + item = data.get('item') data = key_filter(data) data['path'] = path # always show path + data['item'] = item # always include item, to avoid filtering if opts.summarize: update_summary(summary, data) else: if not first: ui.print_() - print_data(data) + print_data(data, opts.format, not opts.raw_length) first = False if opts.summarize: - print_data(summary) + print_data(summary, human_length=not opts.raw_length) def make_key_filter(include): From 8d9db9ffe6e4eebfabd1d4f338c6530770ae05ab Mon Sep 17 00:00:00 2001 From: Diego Moreda Date: Tue, 24 Nov 2015 16:41:56 +0100 Subject: [PATCH 2/8] info: minor cleanups * Rename filter() function to avoid warning of reserved built-in symbol. * Remove mediafile fixture on two tests. --- beetsplug/info.py | 4 ++-- test/test_info.py | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/beetsplug/info.py b/beetsplug/info.py index e8c6d987f..b719723bc 100644 --- a/beetsplug/info.py +++ b/beetsplug/info.py @@ -215,14 +215,14 @@ def make_key_filter(include): key = key.replace(r'\*', '.*') matchers.append(re.compile(key + '$')) - def filter(data): + def filter_(data): filtered = dict() for key, value in data.items(): if any(map(lambda m: m.match(key), matchers)): filtered[key] = value return filtered - return filter + return filter_ def identity(val): diff --git a/test/test_info.py b/test/test_info.py index 797950be3..bb8ffcccc 100644 --- a/test/test_info.py +++ b/test/test_info.py @@ -52,6 +52,7 @@ class InfoTest(unittest.TestCase, TestHelper): self.assertIn('disctitle: DDD', out) self.assertIn('genres: a; b; c', out) self.assertNotIn('composer:', out) + self.remove_mediafile_fixtures() def test_item_query(self): item1, item2 = self.add_item_fixtures(count=2) @@ -93,6 +94,7 @@ class InfoTest(unittest.TestCase, TestHelper): self.assertIn(u'album: AAA', out) self.assertIn(u'tracktotal: 5', out) self.assertIn(u'title: [various]', out) + self.remove_mediafile_fixtures() def test_include_pattern(self): item, = self.add_item_fixtures() From 67af8af7dd5eb482bd2d6a694992d74de30efb09 Mon Sep 17 00:00:00 2001 From: Diego Moreda Date: Tue, 24 Nov 2015 17:16:14 +0100 Subject: [PATCH 3/8] info: add unit tests * Add tests for length (human/raw, library/path) and custom format. --- test/test_info.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/test/test_info.py b/test/test_info.py index bb8ffcccc..395382b34 100644 --- a/test/test_info.py +++ b/test/test_info.py @@ -107,6 +107,44 @@ class InfoTest(unittest.TestCase, TestHelper): self.assertNotIn(u'title:', out) self.assertIn(u'album: xxxx', out) + def test_length_human_library(self): + item, = self.add_item_fixtures() + item.album = 'loool' + item.length = 123.4 + item.write() + item.store() + + out = self.run_with_output('--library') + self.assertIn(u'length: 2:03', out) + + def test_length_raw_library(self): + item, = self.add_item_fixtures() + item.album = 'loool' + item.length = 123.4 + item.write() + item.store() + + out = self.run_with_output('--library', '--raw-length') + self.assertIn(u'length: 123.4', out) + + def test_length_human_path(self): + path = self.create_mediafile_fixture() + out = self.run_with_output(path) + self.assertIn(u'length: 0:01', out) + self.remove_mediafile_fixtures() + + def test_length_raw_path(self): + path = self.create_mediafile_fixture() + out = self.run_with_output(path, '--raw-length') + self.assertIn(u'length: 1.071', out) + self.remove_mediafile_fixtures() + + def test_custom_format(self): + self.add_item_fixtures() + out = self.run_with_output('--library', '--format', + '$track. $title - $artist ($length)') + self.assertEqual(u'02. tïtle 0 - the artist (0:01)\n', out) + def suite(): return unittest.TestLoader().loadTestsFromName(__name__) From 83279ebe5b16ed9fdf8c30aa3378c6280cea7c80 Mon Sep 17 00:00:00 2001 From: Diego Moreda Date: Wed, 25 Nov 2015 16:06:19 +0100 Subject: [PATCH 4/8] info: revert human_length changes * Remove human length changes from the plugin and the tests, as they will eventually be handled at a higher level. --- beetsplug/info.py | 21 ++++----------------- test/test_info.py | 34 +--------------------------------- 2 files changed, 5 insertions(+), 50 deletions(-) diff --git a/beetsplug/info.py b/beetsplug/info.py index b719723bc..854a2441b 100644 --- a/beetsplug/info.py +++ b/beetsplug/info.py @@ -90,17 +90,10 @@ def print_data(data, fmt=None, human_length=True): If no format string `fmt` is passed, the entries on `data` are printed one in each line, with the format 'field: value'. If `fmt` is not `None`, the item is printed according to `fmt`, using the `Item.__format__` machinery. - - If `raw_length` is `True`, the `length` field is displayed using its raw - value (float with the number of seconds and miliseconds). If not, a human - readable form is displayed instead (mm:ss). """ item = data.pop('item', None) if fmt: - # use fmt specified by the user, prettifying length if needed - if human_length and '$length' in fmt: - item['humanlength'] = ui.human_seconds_short(item.length) - fmt = fmt.replace('$length', '$humanlength') + # use fmt specified by the user ui.print_(format(item, fmt)) return @@ -110,10 +103,7 @@ def print_data(data, fmt=None, human_length=True): if isinstance(value, list): formatted[key] = u'; '.join(value) if value is not None: - if human_length and key == 'length': - formatted[key] = ui.human_seconds_short(float(value)) - else: - formatted[key] = value + formatted[key] = value if len(formatted) == 0: return @@ -143,9 +133,6 @@ class InfoPlugin(BeetsPlugin): cmd.parser.add_option('-i', '--include-keys', default=[], action='append', dest='included_keys', help='comma separated list of keys to show') - cmd.parser.add_option('-r', '--raw-length', action='store_true', - default=False, - help='display length as seconds') cmd.parser.add_format_option(target='item') return [cmd] @@ -192,11 +179,11 @@ class InfoPlugin(BeetsPlugin): else: if not first: ui.print_() - print_data(data, opts.format, not opts.raw_length) + print_data(data, opts.format) first = False if opts.summarize: - print_data(summary, human_length=not opts.raw_length) + print_data(summary) def make_key_filter(include): diff --git a/test/test_info.py b/test/test_info.py index 395382b34..aaabed980 100644 --- a/test/test_info.py +++ b/test/test_info.py @@ -107,43 +107,11 @@ class InfoTest(unittest.TestCase, TestHelper): self.assertNotIn(u'title:', out) self.assertIn(u'album: xxxx', out) - def test_length_human_library(self): - item, = self.add_item_fixtures() - item.album = 'loool' - item.length = 123.4 - item.write() - item.store() - - out = self.run_with_output('--library') - self.assertIn(u'length: 2:03', out) - - def test_length_raw_library(self): - item, = self.add_item_fixtures() - item.album = 'loool' - item.length = 123.4 - item.write() - item.store() - - out = self.run_with_output('--library', '--raw-length') - self.assertIn(u'length: 123.4', out) - - def test_length_human_path(self): - path = self.create_mediafile_fixture() - out = self.run_with_output(path) - self.assertIn(u'length: 0:01', out) - self.remove_mediafile_fixtures() - - def test_length_raw_path(self): - path = self.create_mediafile_fixture() - out = self.run_with_output(path, '--raw-length') - self.assertIn(u'length: 1.071', out) - self.remove_mediafile_fixtures() - def test_custom_format(self): self.add_item_fixtures() out = self.run_with_output('--library', '--format', '$track. $title - $artist ($length)') - self.assertEqual(u'02. tïtle 0 - the artist (0:01)\n', out) + self.assertEqual(u'02. tïtle 0 - the artist (1.1)\n', out) def suite(): From ca63311101ed1bef5253375d90ecce493df1d78c Mon Sep 17 00:00:00 2001 From: Diego Moreda Date: Wed, 25 Nov 2015 17:36:51 +0100 Subject: [PATCH 5/8] info: revert human_length changes 2 * Remove human_length parameter from print_data() --- beetsplug/info.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/info.py b/beetsplug/info.py index 854a2441b..f13434a5b 100644 --- a/beetsplug/info.py +++ b/beetsplug/info.py @@ -84,7 +84,7 @@ def update_summary(summary, tags): return summary -def print_data(data, fmt=None, human_length=True): +def print_data(data, fmt=None): """Print, with optional formatting, the fields of a single item. If no format string `fmt` is passed, the entries on `data` are printed one From be5138376bfc100e7cf2eed0c7d3dfb489b7ee4e Mon Sep 17 00:00:00 2001 From: Diego Moreda Date: Wed, 25 Nov 2015 18:04:49 +0100 Subject: [PATCH 6/8] info: add changelog entry for format option --- docs/changelog.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index ce707ab88..746ede4a6 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -24,6 +24,8 @@ New: *exclude* matching music from the results. For example, ``beet list -a beatles ^album:1`` will find all your albums by the Beatles except for their singles compilation, "1." See :ref:`not_query`. :bug:`819` :bug:`1728` +* :doc:`/plugins/info`: The plugin now accepts the ``-f/--format`` option for + customizing how items are displayed. :bug:`1737` For developers: From a8f9f016d842d2a0698d36bf5e1c62c20815d115 Mon Sep 17 00:00:00 2001 From: Diego Moreda Date: Wed, 25 Nov 2015 18:12:04 +0100 Subject: [PATCH 7/8] info: add format note to docs --- docs/plugins/info.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/plugins/info.rst b/docs/plugins/info.rst index 5bfde0b41..b36e69051 100644 --- a/docs/plugins/info.rst +++ b/docs/plugins/info.rst @@ -36,6 +36,10 @@ Additional command-line options include: * ``--summarize`` or ``-s``: Merge all the information from multiple files into a single list of values. If the tags differ across the files, print ``[various]``. +* ``--format`` or ``-f``: Specify a specific format with which to print every + item. This uses the same template syntax as beets’ :doc:`path formats + `. + .. _id3v2: http://id3v2.sourceforge.net .. _mp3info: http://www.ibiblio.org/mp3info/ From 3cdcaa45a87431e4738f4d08a3bcab1c4ca01985 Mon Sep 17 00:00:00 2001 From: Diego Moreda Date: Thu, 26 Nov 2015 15:55:00 +0100 Subject: [PATCH 8/8] info: modify emitter output, clearer path handling * Make emitters produce a pair (dict, Item), in order to preserve the output at print_data (dict is used if no custom format is specified, Item otherwise). * Simplify the handling of the paths, printed at the top of print_data. The path key is removed from the dict entirely and fetched from the Item. --- beetsplug/info.py | 30 ++++++++++++------------------ 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/beetsplug/info.py b/beetsplug/info.py index f13434a5b..5f68336e9 100644 --- a/beetsplug/info.py +++ b/beetsplug/info.py @@ -52,12 +52,10 @@ def tag_data_emitter(path): for field in fields: tags[field] = getattr(mf, field) tags['art'] = mf.art is not None - tags['path'] = displayable_path(path) # create a temporary Item to take advantage of __format__ - tags['item'] = Item(db=None, **tags) - - return tags + item = Item.from_path(syspath(path)) + return tags, item return emitter @@ -69,9 +67,9 @@ def library_data(lib, args): def library_data_emitter(item): def emitter(): data = dict(item.formatted()) - data['path'] = displayable_path(item.path) - data['item'] = item - return data + data.pop('path', None) # path is fetched from item + + return data, item return emitter @@ -84,20 +82,20 @@ def update_summary(summary, tags): return summary -def print_data(data, fmt=None): - """Print, with optional formatting, the fields of a single item. +def print_data(data, item=None, fmt=None): + """Print, with optional formatting, the fields of a single element. If no format string `fmt` is passed, the entries on `data` are printed one in each line, with the format 'field: value'. If `fmt` is not `None`, the - item is printed according to `fmt`, using the `Item.__format__` machinery. + `item` is printed according to `fmt`, using the `Item.__format__` + machinery. """ - item = data.pop('item', None) if fmt: # use fmt specified by the user ui.print_(format(item, fmt)) return - path = data.pop('path', None) + path = displayable_path(item.path) if item else None formatted = {} for key, value in data.iteritems(): if isinstance(value, list): @@ -164,22 +162,18 @@ class InfoPlugin(BeetsPlugin): summary = {} for data_emitter in data_collector(lib, ui.decargs(args)): try: - data = data_emitter() + data, item = data_emitter() except (mediafile.UnreadableFileError, IOError) as ex: self._log.error(u'cannot read file: {0}', ex) continue - path = data.get('path') - item = data.get('item') data = key_filter(data) - data['path'] = path # always show path - data['item'] = item # always include item, to avoid filtering if opts.summarize: update_summary(summary, data) else: if not first: ui.print_() - print_data(data, opts.format) + print_data(data, item, opts.format) first = False if opts.summarize: