diff --git a/beets/library.py b/beets/library.py
index 59a20cabf..1c2fac944 100644
--- a/beets/library.py
+++ b/beets/library.py
@@ -86,8 +86,8 @@ class PathQuery(dbcore.FieldQuery):
colon = query_part.find(':')
if colon != -1:
query_part = query_part[:colon]
- return (os.sep in query_part
- and os.path.exists(syspath(normpath(query_part))))
+ return (os.sep in query_part and
+ os.path.exists(syspath(normpath(query_part))))
def match(self, item):
path = item.path if self.case_sensitive else item.path.lower()
diff --git a/beets/ui/commands.py b/beets/ui/commands.py
index 474b6650f..74a6da0ed 100644
--- a/beets/ui/commands.py
+++ b/beets/ui/commands.py
@@ -1236,7 +1236,10 @@ def show_stats(lib, query, exact):
for item in items:
if exact:
- total_size += os.path.getsize(item.path)
+ try:
+ total_size += os.path.getsize(item.path)
+ except OSError as exc:
+ log.info('could not get size of {}: {}', item.path, exc)
else:
total_size += int(item.length * item.bitrate / 8)
total_time += item.length
diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py
index fe32ab9ad..57d8e4c46 100644
--- a/beetsplug/fetchart.py
+++ b/beetsplug/fetchart.py
@@ -72,9 +72,9 @@ def _logged_get(log, *args, **kwargs):
else:
message = 'getting URL'
- req = requests.Request('GET', *args, **req_kwargs)
+ req = requests.Request(b'GET', *args, **req_kwargs)
with requests.Session() as s:
- s.headers = {'User-Agent': 'beets'}
+ s.headers = {b'User-Agent': b'beets'}
prepped = s.prepare_request(req)
log.debug('{}: {}', message, prepped.url)
return s.send(prepped, **send_kwargs)
diff --git a/beetsplug/info.py b/beetsplug/info.py
index 61f0c7971..a29a6ccfc 100644
--- a/beetsplug/info.py
+++ b/beetsplug/info.py
@@ -119,6 +119,25 @@ def print_data(data, item=None, fmt=None):
ui.print_(lineformat.format(field, value))
+def print_data_keys(data, item=None):
+ """Print only the keys (field names) for an item.
+ """
+ path = displayable_path(item.path) if item else None
+ formatted = []
+ for key, value in data.iteritems():
+ formatted.append(key)
+
+ if len(formatted) == 0:
+ return
+
+ line_format = u'{0}{{0}}'.format(u' ' * 4)
+ if path:
+ ui.print_(displayable_path(path))
+
+ for field in sorted(formatted):
+ ui.print_(line_format.format(field))
+
+
class InfoPlugin(BeetsPlugin):
def commands(self):
@@ -131,6 +150,8 @@ 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('-k', '--keys-only', action='store_true',
+ help='show only the keys')
cmd.parser.add_format_option(target='item')
return [cmd]
@@ -173,7 +194,10 @@ class InfoPlugin(BeetsPlugin):
else:
if not first:
ui.print_()
- print_data(data, item, opts.format)
+ if opts.keys_only:
+ print_data_keys(data, item)
+ else:
+ print_data(data, item, opts.format)
first = False
if opts.summarize:
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 56810ef6c..51fab3100 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -18,6 +18,8 @@ New:
* A new :doc:`/plugins/mbsubmit` lets you print the tracks of an album in a
format parseable by MusicBrainz track parser during an interactive import
session. :bug:`1779`
+* :doc:`/plugins/info`: A new option will print only fields' names and not
+ their values. Thanks to :user:`GuilhermeHideki`. :bug:`1812`
.. _AcousticBrainz: http://acousticbrainz.org/
@@ -45,6 +47,10 @@ Fixes:
* :doc:`/plugins/lyrics`: The Genius backend has been re-enabled.
* :doc:`/plugins/edit`: Editing metadata now moves files, when appropriate
(like the :ref:`modify-cmd` command). :bug:`1804`
+* The :ref:`stats-cmd` command no longer crashes when files are missing or
+ inaccessible. :bug:`1806`
+* :doc:`/plugins/fetchart`: Possibly fix a Unicode-related crash when using
+ some versions of pyOpenSSL. :bug:`1805`
.. _beets.io: http://beets.io/
.. _Beetbox: https://github.com/beetbox
diff --git a/docs/plugins/ihate.rst b/docs/plugins/ihate.rst
index f2224bf5a..f9cde39eb 100644
--- a/docs/plugins/ihate.rst
+++ b/docs/plugins/ihate.rst
@@ -25,7 +25,7 @@ Here's an example::
ihate:
warn:
- artist:rnb
- - genre: soul
+ - genre:soul
# Only warn about tribute albums in rock genre.
- genre:rock album:tribute
skip:
diff --git a/docs/plugins/info.rst b/docs/plugins/info.rst
index b36e69051..238a957ff 100644
--- a/docs/plugins/info.rst
+++ b/docs/plugins/info.rst
@@ -39,7 +39,7 @@ Additional command-line options include:
* ``--format`` or ``-f``: Specify a specific format with which to print every
item. This uses the same template syntax as beets’ :doc:`path formats
`.
-
+* ``--keys-only`` or ``-k``: Show the name of the tags without the values.
.. _id3v2: http://id3v2.sourceforge.net
.. _mp3info: http://www.ibiblio.org/mp3info/
diff --git a/test/_common.py b/test/_common.py
index 21c73858e..9f8b9f146 100644
--- a/test/_common.py
+++ b/test/_common.py
@@ -346,3 +346,11 @@ def system_mock(name):
yield
finally:
platform.system = old_system
+
+
+def slow_test(unused=None):
+ def _id(obj):
+ return obj
+ if 'SKIP_SLOW_TESTS' in os.environ:
+ return unittest.skip('test is slow')
+ return _id
diff --git a/test/test_art.py b/test/test_art.py
index e19c7d211..7902bb213 100644
--- a/test/test_art.py
+++ b/test/test_art.py
@@ -209,10 +209,10 @@ class AAOTest(UseThePlugin):
def test_aao_scraper_finds_image(self):
body = b"""
-
-
+
+
"""
self.mock_response(self.AAO_URL, body)
album = _common.Bag(asin=self.ASIN)
@@ -261,6 +261,7 @@ class GoogleImageTest(UseThePlugin):
self.assertEqual(list(result_url), [])
+@_common.slow_test()
class ArtImporterTest(UseThePlugin):
def setUp(self):
super(ArtImporterTest, self).setUp()
diff --git a/test/test_convert.py b/test/test_convert.py
index ab1fa2504..72d52feaa 100644
--- a/test/test_convert.py
+++ b/test/test_convert.py
@@ -64,6 +64,7 @@ class TestHelper(helper.TestHelper):
.format(path, tag))
+@_common.slow_test()
class ImportConvertTest(unittest.TestCase, TestHelper):
def setUp(self):
@@ -99,6 +100,7 @@ class ImportConvertTest(unittest.TestCase, TestHelper):
self.assertTrue(os.path.isfile(item.path))
+@_common.slow_test()
class ConvertCliTest(unittest.TestCase, TestHelper):
def setUp(self):
@@ -186,6 +188,7 @@ class ConvertCliTest(unittest.TestCase, TestHelper):
self.assertFalse(os.path.exists(converted))
+@_common.slow_test()
class NeverConvertLossyFilesTest(unittest.TestCase, TestHelper):
"""Test the effect of the `never_convert_lossy_files` option.
"""
diff --git a/test/test_dbcore.py b/test/test_dbcore.py
index a69ffedbe..39b7eea1e 100644
--- a/test/test_dbcore.py
+++ b/test/test_dbcore.py
@@ -19,8 +19,10 @@ from __future__ import (division, absolute_import, print_function,
unicode_literals)
import os
+import shutil
import sqlite3
+from test import _common
from test._common import unittest
from beets import dbcore
from tempfile import mkstemp
@@ -116,15 +118,28 @@ class TestDatabaseTwoModels(dbcore.Database):
pass
+class TestModelWithGetters(dbcore.Model):
+
+ @classmethod
+ def _getters(cls):
+ return {'aComputedField': (lambda s: 'thing')}
+
+ def _template_funcs(self):
+ return {}
+
+
+@_common.slow_test()
class MigrationTest(unittest.TestCase):
"""Tests the ability to change the database schema between
versions.
"""
- def setUp(self):
- handle, self.libfile = mkstemp('db')
+
+ @classmethod
+ def setUpClass(cls):
+ handle, cls.orig_libfile = mkstemp('orig_db')
os.close(handle)
# Set up a database with the two-field schema.
- old_lib = TestDatabase2(self.libfile)
+ old_lib = TestDatabase2(cls.orig_libfile)
# Add an item to the old library.
old_lib._connection().execute(
@@ -133,6 +148,15 @@ class MigrationTest(unittest.TestCase):
old_lib._connection().commit()
del old_lib
+ @classmethod
+ def tearDownClass(cls):
+ os.remove(cls.orig_libfile)
+
+ def setUp(self):
+ handle, self.libfile = mkstemp('db')
+ os.close(handle)
+ shutil.copyfile(self.orig_libfile, self.libfile)
+
def tearDown(self):
os.remove(self.libfile)
@@ -274,6 +298,40 @@ class ModelTest(unittest.TestCase):
model2.load()
self.assertNotIn('flex_field', model2)
+ def test_check_db_fails(self):
+ with self.assertRaisesRegexp(ValueError, 'no database'):
+ dbcore.Model()._check_db()
+ with self.assertRaisesRegexp(ValueError, 'no id'):
+ TestModel1(self.db)._check_db()
+
+ dbcore.Model(self.db)._check_db(need_id=False)
+
+ def test_missing_field(self):
+ with self.assertRaises(AttributeError):
+ TestModel1(self.db).nonExistingKey
+
+ def test_computed_field(self):
+ model = TestModelWithGetters()
+ self.assertEqual(model.aComputedField, 'thing')
+ with self.assertRaisesRegexp(KeyError, 'computed field .+ deleted'):
+ del model.aComputedField
+
+ def test_items(self):
+ model = TestModel1(self.db)
+ model.id = 5
+ self.assertEqual({('id', 5), ('field_one', None)},
+ set(model.items()))
+
+ def test_delete_internal_field(self):
+ model = dbcore.Model()
+ del model._db
+ with self.assertRaises(AttributeError):
+ model._db
+
+ def test_parse_nonstring(self):
+ with self.assertRaisesRegexp(TypeError, "must be a string"):
+ dbcore.Model._parse(None, 42)
+
class FormatTest(unittest.TestCase):
def test_format_fixed_field(self):
@@ -588,6 +646,15 @@ class ResultsIteratorTest(unittest.TestCase):
objs = self.db._fetch(TestModel1)
self.assertEqual(len(objs), 2)
+ def test_out_of_range(self):
+ objs = self.db._fetch(TestModel1)
+ with self.assertRaises(IndexError):
+ objs[100]
+
+ def test_no_results(self):
+ self.assertIsNone(self.db._fetch(
+ TestModel1, dbcore.query.FalseQuery()).get())
+
def suite():
return unittest.TestLoader().loadTestsFromName(__name__)
diff --git a/test/test_edit.py b/test/test_edit.py
index 522437eaa..25b24cea0 100644
--- a/test/test_edit.py
+++ b/test/test_edit.py
@@ -17,6 +17,7 @@ from __future__ import (division, absolute_import, print_function,
import codecs
from mock import patch
+from test import _common
from test._common import unittest
from test.helper import TestHelper, control_stdin
@@ -62,6 +63,7 @@ class ModifyFileMocker(object):
f.write(contents)
+@_common.slow_test()
class EditCommandTest(unittest.TestCase, TestHelper):
""" Black box tests for `beetsplug.edit`. Command line interaction is
simulated using `test.helper.control_stdin()`, and yaml editing via an
diff --git a/test/test_importer.py b/test/test_importer.py
index c088492d1..880590f4d 100644
--- a/test/test_importer.py
+++ b/test/test_importer.py
@@ -236,6 +236,7 @@ class ImportHelper(TestHelper):
self.assertEqual(len(os.listdir(self.libdir)), 0)
+@_common.slow_test()
class NonAutotaggedImportTest(_common.TestCase, ImportHelper):
def setUp(self):
self.setup_beets(disk=True)
diff --git a/test/test_logging.py b/test/test_logging.py
index 81df95a78..a3fe363b9 100644
--- a/test/test_logging.py
+++ b/test/test_logging.py
@@ -12,6 +12,7 @@ from StringIO import StringIO
import beets.logging as blog
from beets import plugins, ui
import beetsplug
+from test import _common
from test._common import unittest, TestCase
from test import helper
@@ -163,6 +164,7 @@ class LoggingLevelTest(unittest.TestCase, helper.TestHelper):
self.assertIn('dummy: debug import_stage', logs)
+@_common.slow_test()
class ConcurrentEventsTest(TestCase, helper.TestHelper):
"""Similar to LoggingLevelTest but lower-level and focused on multiple
events interaction. Since this is a bit heavy we don't do it in
diff --git a/test/test_mpdstats.py b/test/test_mpdstats.py
index 9d7d16881..f28e29d68 100644
--- a/test/test_mpdstats.py
+++ b/test/test_mpdstats.py
@@ -17,7 +17,7 @@ from __future__ import (division, absolute_import, print_function,
unicode_literals)
-from mock import Mock
+from mock import Mock, patch, call, ANY
from test._common import unittest
from test.helper import TestHelper
@@ -44,6 +44,44 @@ class MPDStatsTest(unittest.TestCase, TestHelper):
self.assertFalse(mpdstats.update_rating(item, True))
self.assertFalse(mpdstats.update_rating(None, True))
+ def test_get_item(self):
+ ITEM_PATH = '/foo/bar.flac'
+ item = Item(title='title', path=ITEM_PATH, id=1)
+ item.add(self.lib)
+
+ log = Mock()
+ mpdstats = MPDStats(self.lib, log)
+
+ self.assertEqual(str(mpdstats.get_item(ITEM_PATH)), str(item))
+ self.assertIsNone(mpdstats.get_item('/some/non-existing/path'))
+ self.assertIn('item not found:', log.info.call_args[0][0])
+
+ FAKE_UNKNOWN_STATE = 'some-unknown-one'
+ STATUSES = [{'state': FAKE_UNKNOWN_STATE},
+ {'state': 'pause'},
+ {'state': 'play', 'songid': 1, 'time': '0:1'},
+ {'state': 'stop'}]
+ EVENTS = [["player"]] * (len(STATUSES) - 1) + [KeyboardInterrupt]
+ ITEM_PATH = '/foo/bar.flac'
+
+ @patch("beetsplug.mpdstats.MPDClientWrapper", return_value=Mock(**{
+ "events.side_effect": EVENTS, "status.side_effect": STATUSES,
+ "playlist.return_value": {1: ITEM_PATH}}))
+ def test_run_MPDStats(self, mpd_mock):
+ item = Item(title='title', path=self.ITEM_PATH, id=1)
+ item.add(self.lib)
+
+ log = Mock()
+ try:
+ MPDStats(self.lib, log).run()
+ except KeyboardInterrupt:
+ pass
+
+ log.debug.assert_has_calls(
+ [call(u'unhandled status "{0}"', ANY)])
+ log.info.assert_has_calls(
+ [call(u'pause'), call(u'playing {0}', ANY), call(u'stop')])
+
def suite():
return unittest.TestLoader().loadTestsFromName(__name__)
diff --git a/test/test_ui.py b/test/test_ui.py
index b32a4c452..ed1740643 100644
--- a/test/test_ui.py
+++ b/test/test_ui.py
@@ -594,6 +594,7 @@ class InputTest(_common.TestCase):
self.assertEqual(album, u'\xc2me')
+@_common.slow_test()
class ConfigTest(unittest.TestCase, TestHelper):
def setUp(self):
self.setup_beets()
@@ -1035,6 +1036,7 @@ class PathFormatTest(_common.TestCase):
self.assertEqual(pf[1:], default_formats)
+@_common.slow_test()
class PluginTest(_common.TestCase):
def test_plugin_command_from_pluginpath(self):
config['pluginpath'] = [os.path.join(_common.RSRC, 'beetsplug')]
@@ -1042,6 +1044,7 @@ class PluginTest(_common.TestCase):
ui._raw_main(['test'])
+@_common.slow_test()
class CompletionTest(_common.TestCase):
def test_completion(self):
# Load plugin commands