Merge branch 'flextypes'

This commit is contained in:
Thomas Scholtes 2014-09-14 13:16:43 +02:00
commit ffc75c333d
15 changed files with 377 additions and 6 deletions

View file

@ -737,7 +737,7 @@ class Database(object):
id INTEGER PRIMARY KEY,
entity_id INTEGER,
key TEXT,
value TEXT,
value NONE,
UNIQUE(entity_id, key) ON CONFLICT REPLACE);
CREATE INDEX IF NOT EXISTS {0}_by_entity
ON {0} (entity_id);

View file

@ -208,7 +208,9 @@ class NumericQuery(FieldQuery):
self.rangemax = self._convert(parts[1])
def match(self, item):
value = getattr(item, self.field)
if self.field not in item:
return False
value = item[self.field]
if isinstance(value, basestring):
value = self._convert(value)

View file

@ -62,6 +62,8 @@ class PathQuery(dbcore.FieldQuery):
class DateType(types.Type):
# TODO representation should be `datetime` object
# TODO distinguish beetween date and time types
sql = u'REAL'
query = dbcore.query.DateQuery
null = 0.0

View file

@ -31,6 +31,14 @@ LASTFM_KEY = '2dc3914abf35f0d9c92d97d8f8e42b43'
log = logging.getLogger('beets')
class PluginConflictException(Exception):
"""Indicates that the services provided by one plugin conflict with
those of another.
For example two plugins may define different types for flexible fields.
"""
# Managing the plugins themselves.
class BeetsPlugin(object):
@ -136,7 +144,7 @@ class BeetsPlugin(object):
>>> @MyPlugin.listen("imported")
>>> def importListener(**kwargs):
>>> pass
... pass
"""
def helper(func):
if cls.listeners is None:
@ -247,6 +255,22 @@ def queries():
return out
def types(model_cls):
# Gives us `item_types` and `album_types`
attr_name = '{0}_types'.format(model_cls.__name__.lower())
types = {}
for plugin in find_plugins():
plugin_types = getattr(plugin, attr_name, {})
for field in plugin_types:
if field in types:
raise PluginConflictException(
u'Plugin {0} defines flexible field {1} '
'which has already been defined.'
.format(plugin.name,))
types.update(plugin_types)
return types
def track_distance(item, info):
"""Gets the track distance calculated by all loaded plugins.
Returns a Distance object.

View file

@ -869,6 +869,8 @@ def _setup(options, lib=None):
if lib is None:
lib = _open_library(config)
plugins.send("library_opened", lib=lib)
library.Item._types = plugins.types(library.Item)
library.Album._types = plugins.types(library.Album)
return subcommands, plugins, lib

View file

@ -23,6 +23,7 @@ from string import Template
import subprocess
from beets import util, config, plugins, ui
from beets.dbcore import types
import pyechonest
import pyechonest.song
import pyechonest.track
@ -38,7 +39,9 @@ DEVNULL = open(os.devnull, 'wb')
ALLOWED_FORMATS = ('MP3', 'OGG', 'AAC')
UPLOAD_MAX_SIZE = 50 * 1024 * 1024
# The attributes we can import and where to store them in beets fields.
# Maps attribute names from echonest to their field names in beets.
# The attributes are retrieved from a songs `audio_summary`. See:
# http://echonest.github.io/pyechonest/song.html#pyechonest.song.profile
ATTRIBUTES = {
'energy': 'energy',
'liveness': 'liveness',
@ -49,6 +52,16 @@ ATTRIBUTES = {
'tempo': 'bpm',
}
# Types for the flexible fields added by `ATTRIBUTES`
FIELD_TYPES = {
'energy': types.FLOAT,
'liveness': types.FLOAT,
'speechiness': types.FLOAT,
'acousticness': types.FLOAT,
'danceability': types.FLOAT,
'valence': types.FLOAT,
}
MUSICAL_SCALE = ['C', 'C#', 'D', 'D#', 'E' 'F',
'F#', 'G', 'G#', 'A', 'A#', 'B']
@ -104,6 +117,9 @@ def similar(lib, src_item, threshold=0.15, fmt='${difference}: ${path}'):
class EchonestMetadataPlugin(plugins.BeetsPlugin):
item_types = FIELD_TYPES
def __init__(self):
super(EchonestMetadataPlugin, self).__init__()
self.config.add({

View file

@ -25,6 +25,7 @@ from beets import config
from beets import plugins
from beets import library
from beets.util import displayable_path
from beets.dbcore import types
log = logging.getLogger('beets')
@ -308,6 +309,14 @@ class MPDStats(object):
class MPDStatsPlugin(plugins.BeetsPlugin):
item_types = {
'play_count': types.INTEGER,
'skip_count': types.INTEGER,
'last_played': library.Date(),
'rating': types.FLOAT,
}
def __init__(self):
super(MPDStatsPlugin, self).__init__()
self.config.add({

42
beetsplug/types.py Normal file
View file

@ -0,0 +1,42 @@
# This file is part of beets.
# Copyright 2014, Thomas Scholtes.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
from beets.plugins import BeetsPlugin
from beets.dbcore import types
from beets.util.confit import ConfigValueError
from beets import library
class TypesPlugin(BeetsPlugin):
@property
def item_types(self):
if not self.config.exists():
return {}
mytypes = {}
for key, value in self.config.items():
if value.get() == 'int':
mytypes[key] = types.INTEGER
elif value.get() == 'float':
mytypes[key] = types.FLOAT
elif value.get() == 'bool':
mytypes[key] = types.BOOLEAN
elif value.get() == 'date':
mytypes[key] = library.DateType()
else:
raise ConfigValueError(
u"unknown type '{0}' for the '{1}' field"
.format(value, key))
return mytypes

View file

@ -397,3 +397,37 @@ plugin will be used if we issue a command like ``beet ls @something`` or
return {
'@': ExactMatchQuery
}
Flexible Field Types
^^^^^^^^^^^^^^^^^^^^
If your plugin uses flexible fields to store numbers or other
non-string values you can specify the types of those fields. A rating
plugin, for example might look like this. ::
from beets.plugins import BeetsPlugin
from beets.dbcore import types
class RatingPlugin(BeetsPlugin):
item_types = {'rating': types.INTEGER}
@property
def album_types(self):
return {'rating': types.INTEGER}
A plugin may define two attributes, `item_types` and `album_types`.
Each of those attributes is a dictionary mapping a flexible field name
to a type instance. You can find the built-in types in the
`beets.dbcore.types` and `beets.library` modules or implement your own
ones.
Specifying types has the following advantages.
* The flexible field accessors ``item['my_field']`` return the
specified type instead of a string.
* Users can use advanced queries (like :ref:`ranges <numericquery>`)
from the command line.
* User input for flexible fields may be validated.

View file

@ -62,6 +62,7 @@ by typing ``beet version``.
importadded
bpm
spotify
types
Autotagger Extensions
---------------------
@ -137,6 +138,7 @@ Miscellaneous
* :doc:`missing`: List missing tracks.
* :doc:`duplicates`: List duplicate tracks or albums.
* :doc:`spotify`: Create Spotify playlists from the Beets library.
* :doc:`types`: Declare types for flexible attributes.
.. _MPD: http://www.musicpd.org/
.. _MPD clients: http://mpd.wikia.com/wiki/Clients

17
docs/plugins/types.rst Normal file
View file

@ -0,0 +1,17 @@
Types Plugin
============
The ``types`` plugin lets you declare types for attributes you use in your
library. For example, you can declare that a ``rating`` field is numeric so
that you can query it with ranges---which isn't possible when the field is
considered a string, which is the default.
Enable the plugin as described in :doc:`/plugins/index` and then add a
``types`` section to your :doc:`configuration file </reference/config>`. The
configuration section should map field name to one of ``int``, ``float``,
``bool``, or ``date``.
Here's an example:
types:
rating: int

View file

@ -43,7 +43,7 @@ from enum import Enum
import beets
from beets import config
import beets.plugins
from beets.library import Library, Item
from beets.library import Library, Item, Album
from beets import importer
from beets.autotag.hooks import AlbumInfo, TrackInfo
from beets.mediafile import MediaFile
@ -168,18 +168,24 @@ class TestHelper(object):
Similar setting a list of plugins in the configuration. Make
sure you call ``unload_plugins()`` afterwards.
"""
# FIXME this should eventually be handled by a plugin manager
beets.config['plugins'] = plugins
beets.plugins.load_plugins(plugins)
beets.plugins.find_plugins()
Item._types = beets.plugins.types(Item)
Album._types = beets.plugins.types(Album)
def unload_plugins(self):
"""Unload all plugins and remove the from the configuration.
"""
# FIXME this should eventually be handled by a plugin manager
beets.config['plugins'] = []
for plugin in beets.plugins._classes:
plugin.listeners = None
beets.plugins._classes = set()
beets.plugins._instances = {}
Item._types = {}
Album._types = {}
def create_importer(self, item_count=1, album_count=1):
"""Create files to import and return corresponding session.

View file

@ -70,9 +70,17 @@ class EchonestCliTest(unittest.TestCase, TestHelper):
self.run_command('echonest')
item.load()
self.assertEqual(item['danceability'], '0.5')
self.assertEqual(item['danceability'], 0.5)
self.assertEqual(item['liveness'], 0.5)
self.assertEqual(item['bpm'], 120)
self.assertEqual(item['initial_key'], 'C#m')
def test_custom_field_range_query(self):
item = Item(liveness=2.2)
item.add(self.lib)
item = self.lib.items('liveness:2.2..3').get()
self.assertEqual(item['liveness'], 2.2)
def suite():
return unittest.TestLoader().loadTestsFromName(__name__)

75
test/test_plugins.py Normal file
View file

@ -0,0 +1,75 @@
# This file is part of beets.
# Copyright 2014, Thomas Scholtes.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
from mock import patch
from _common import unittest
from helper import TestHelper
from beets import plugins
from beets.library import Item
from beets.dbcore import types
class PluginTest(unittest.TestCase, TestHelper):
def setUp(self):
# FIXME the mocking code is horrific, but this is the lowest and
# earliest level of the plugin mechanism we can hook into.
self._plugin_loader_patch = patch('beets.plugins.load_plugins')
self._plugin_classes = set()
load_plugins = self._plugin_loader_patch.start()
def myload(names=()):
plugins._classes.update(self._plugin_classes)
load_plugins.side_effect = myload
self.setup_beets()
def tearDown(self):
self._plugin_loader_patch.stop()
self.unload_plugins()
self.teardown_beets()
def test_flex_field_type(self):
class RatingPlugin(plugins.BeetsPlugin):
item_types = {'rating': types.Float()}
self.register_plugin(RatingPlugin)
self.config['plugins'] = 'rating'
item = Item(path='apath', artist='aaa')
item.add(self.lib)
# Do not match unset values
out = self.run_with_output('ls', 'rating:1..3')
self.assertNotIn('aaa', out)
self.run_command('modify', 'rating=2', '--yes')
# Match in range
out = self.run_with_output('ls', 'rating:1..3')
self.assertIn('aaa', out)
# Don't match out of range
out = self.run_with_output('ls', 'rating:3..5')
self.assertNotIn('aaa', out)
def register_plugin(self, plugin_class):
self._plugin_classes.add(plugin_class)
def suite():
return unittest.TestLoader().loadTestsFromName(__name__)
if __name__ == '__main__':
unittest.main(defaultTest='suite')

132
test/test_types_plugin.py Normal file
View file

@ -0,0 +1,132 @@
# This file is part of beets.
# Copyright 2014, Thomas Scholtes.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
import time
from datetime import datetime
from _common import unittest
from helper import TestHelper
from beets.util.confit import ConfigValueError
class TypesPluginTest(unittest.TestCase, TestHelper):
def setUp(self):
self.setup_beets()
self.load_plugins('types')
def tearDown(self):
self.unload_plugins()
self.teardown_beets()
def test_integer_modify_and_query(self):
self.config['types'] = {'myint': 'int'}
item = self.add_item(artist='aaa')
# Do not match unset values
out = self.list('myint:1..3')
self.assertEqual('', out)
self.modify('myint=2')
item.load()
self.assertEqual(item['myint'], 2)
# Match in range
out = self.list('myint:1..3')
self.assertIn('aaa', out)
def test_float_modify_and_query(self):
self.config['types'] = {'myfloat': 'float'}
item = self.add_item(artist='aaa')
self.modify('myfloat=-9.1')
item.load()
self.assertEqual(item['myfloat'], -9.1)
# Match in range
out = self.list('myfloat:-10..0')
self.assertIn('aaa', out)
def test_bool_modify_and_query(self):
self.config['types'] = {'mybool': 'bool'}
true = self.add_item(artist='true')
false = self.add_item(artist='false')
self.add_item(artist='unset')
# Set true
self.modify('mybool=1', 'artist:true')
true.load()
self.assertEqual(true['mybool'], True)
# Set false
self.modify('mybool=false', 'artist:false')
false.load()
self.assertEqual(false['mybool'], False)
# Query bools
out = self.list('mybool:true', '$artist $mybool')
self.assertEqual('true True', out)
out = self.list('mybool:false', '$artist $mybool')
# Dealing with unset fields?
# self.assertEqual('false False', out)
# out = self.list('mybool:', '$artist $mybool')
# self.assertIn('unset $mybool', out)
def test_date_modify_and_query(self):
self.config['types'] = {'mydate': 'date'}
# FIXME parsing should also work with default time format
self.config['time_format'] = '%Y-%m-%d'
old = self.add_item(artist='prince')
new = self.add_item(artist='britney')
self.modify('mydate=1999-01-01', 'artist:prince')
old.load()
self.assertEqual(old['mydate'], mktime(1999, 01, 01))
self.modify('mydate=1999-12-30', 'artist:britney')
new.load()
self.assertEqual(new['mydate'], mktime(1999, 12, 30))
# Match in range
out = self.list('mydate:..1999-07', '$artist $mydate')
self.assertEqual('prince 1999-01-01', out)
# FIXME some sort of timezone issue here
# out = self.list('mydate:1999-12-30', '$artist $mydate')
# self.assertEqual('britney 1999-12-30', out)
def test_unknown_type_error(self):
self.config['types'] = {'flex': 'unkown type'}
with self.assertRaises(ConfigValueError):
self.run_command('ls')
def modify(self, *args):
return self.run_with_output('modify', '--yes', '--nowrite', *args)
def list(self, query, fmt='$artist - $album - $title'):
return self.run_with_output('ls', '-f', fmt, query).strip()
def mktime(*args):
return time.mktime(datetime(*args).timetuple())
def suite():
return unittest.TestLoader().loadTestsFromName(__name__)
if __name__ == '__main__':
unittest.main(defaultTest='suite')