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"""
- - View larger image + + \"View """ 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