diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index e92cba40c..71810ead2 100755 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -143,6 +143,11 @@ class Model(object): are subclasses of `Sort`. """ + _queries = {} + """Named queries that use a field-like `name:value` syntax but which + do not relate to any specific field. + """ + _always_dirty = False """By default, fields only become "dirty" when their value actually changes. Enabling this flag marks fields as dirty even when the new diff --git a/beets/dbcore/queryparse.py b/beets/dbcore/queryparse.py index ce88fa3bd..1cb25a8c7 100644 --- a/beets/dbcore/queryparse.py +++ b/beets/dbcore/queryparse.py @@ -119,12 +119,13 @@ def construct_query_part(model_cls, prefixes, query_part): if not query_part: return query.TrueQuery() - # Use `model_cls` to build up a map from field names to `Query` - # classes. + # Use `model_cls` to build up a map from field (or query) names to + # `Query` classes. query_classes = {} for k, t in itertools.chain(model_cls._fields.items(), model_cls._types.items()): query_classes[k] = t.query + query_classes.update(model_cls._queries) # Non-field queries. # Parse the string. key, pattern, query_class, negate = \ @@ -137,26 +138,27 @@ def construct_query_part(model_cls, prefixes, query_part): # The query type matches a specific field, but none was # specified. So we use a version of the query that matches # any field. - q = query.AnyFieldQuery(pattern, model_cls._search_fields, - query_class) - if negate: - return query.NotQuery(q) - else: - return q + out_query = query.AnyFieldQuery(pattern, model_cls._search_fields, + query_class) else: # Non-field query type. - if negate: - return query.NotQuery(query_class(pattern)) - else: - return query_class(pattern) + out_query = query_class(pattern) - # Otherwise, this must be a `FieldQuery`. Use the field name to - # construct the query object. - key = key.lower() - q = query_class(key.lower(), pattern, key in model_cls._fields) + # Field queries get constructed according to the name of the field + # they are querying. + elif issubclass(query_class, query.FieldQuery): + key = key.lower() + out_query = query_class(key.lower(), pattern, key in model_cls._fields) + + # Non-field (named) query. + else: + out_query = query_class(pattern) + + # Apply negation. if negate: - return query.NotQuery(q) - return q + return query.NotQuery(out_query) + else: + return out_query def query_from_strings(query_cls, model_cls, prefixes, query_parts): diff --git a/beets/plugins.py b/beets/plugins.py index 6dec7ef2a..f10dc5849 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -344,6 +344,16 @@ def types(model_cls): return types +def named_queries(model_cls): + # Gather `item_queries` and `album_queries` from the plugins. + attr_name = '{0}_queries'.format(model_cls.__name__.lower()) + queries = {} + for plugin in find_plugins(): + plugin_queries = getattr(plugin, attr_name, {}) + queries.update(plugin_queries) + return queries + + def track_distance(item, info): """Gets the track distance calculated by all loaded plugins. Returns a Distance object. diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 1abce2e67..327db6b04 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -1143,8 +1143,12 @@ def _setup(options, lib=None): if lib is None: lib = _open_library(config) plugins.send("library_opened", lib=lib) + + # Add types and queries defined by plugins. library.Item._types.update(plugins.types(library.Item)) library.Album._types.update(plugins.types(library.Album)) + library.Item._queries.update(plugins.named_queries(library.Item)) + library.Album._queries.update(plugins.named_queries(library.Album)) return subcommands, plugins, lib diff --git a/beetsplug/playlist.py b/beetsplug/playlist.py index e8683ff93..d2e1209d1 100644 --- a/beetsplug/playlist.py +++ b/beetsplug/playlist.py @@ -19,11 +19,11 @@ import fnmatch import beets -class PlaylistQuery(beets.dbcore.FieldQuery): +class PlaylistQuery(beets.dbcore.Query): """Matches files listed by a playlist file. """ - def __init__(self, field, pattern, fast=True): - super(PlaylistQuery, self).__init__(field, pattern, fast) + def __init__(self, pattern): + self.pattern = pattern config = beets.config['playlist'] # Get the full path to the playlist @@ -76,14 +76,8 @@ class PlaylistQuery(beets.dbcore.FieldQuery): return item.path in self.paths -class PlaylistType(beets.dbcore.types.String): - """Custom type for playlist query. - """ - query = PlaylistQuery - - class PlaylistPlugin(beets.plugins.BeetsPlugin): - item_types = {'playlist': PlaylistType()} + item_queries = {'playlist': PlaylistQuery} def __init__(self): super(PlaylistPlugin, self).__init__() diff --git a/docs/changelog.rst b/docs/changelog.rst index a254d5efc..fc6b4cd93 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -144,6 +144,14 @@ Fixes: .. _python-itunes: https://github.com/ocelma/python-itunes +For developers: + +* In addition to prefix-based field queries, plugins can now define *named + queries* that are not associated with any specific field. + For example, the new :doc:`/plugins/playlist` supports queries like + ``playlist:name`` although there is no field named ``playlist``. + See :ref:`extend-query` for details. + 1.4.7 (May 29, 2018) -------------------- diff --git a/docs/dev/plugins.rst b/docs/dev/plugins.rst index bab0e604d..c9018c394 100644 --- a/docs/dev/plugins.rst +++ b/docs/dev/plugins.rst @@ -443,15 +443,24 @@ Extend the Query Syntax ^^^^^^^^^^^^^^^^^^^^^^^ You can add new kinds of queries to beets' :doc:`query syntax -` indicated by a prefix. As an example, beets already +`. There are two ways to add custom queries: using a prefix +and using a name. Prefix-based query extension can apply to *any* field, while +named queries are not associated with any field. For example, beets already supports regular expression queries, which are indicated by a colon prefix---plugins can do the same. -To do so, define a subclass of the ``Query`` type from the -``beets.dbcore.query`` module. Then, in the ``queries`` method of your plugin -class, return a dictionary mapping prefix strings to query classes. +For either kind of query extension, define a subclass of the ``Query`` type +from the ``beets.dbcore.query`` module. Then: -One simple kind of query you can extend is the ``FieldQuery``, which +- To define a prefix-based query, define a ``queries`` method in your plugin + class. Return from this method a dictionary mapping prefix strings to query + classes. +- To define a named query, defined dictionaries named either ``item_queries`` + or ``album_queries``. These should map names to query types. So if you + use ``{ "foo": FooQuery }``, then the query ``foo:bar`` will construct a + query like ``FooQuery("bar")``. + +For prefix-based queries, you will want to extend ``FieldQuery``, which implements string comparisons on fields. To use it, create a subclass inheriting from that class and override the ``value_match`` class method. (Remember the ``@classmethod`` decorator!) The following example plugin diff --git a/test/helper.py b/test/helper.py index 92128f511..392d01a55 100644 --- a/test/helper.py +++ b/test/helper.py @@ -222,12 +222,19 @@ class TestHelper(object): beets.config['plugins'] = plugins beets.plugins.load_plugins(plugins) beets.plugins.find_plugins() - # Take a backup of the original _types to restore when unloading + + # Take a backup of the original _types and _queries to restore + # when unloading. Item._original_types = dict(Item._types) Album._original_types = dict(Album._types) Item._types.update(beets.plugins.types(Item)) Album._types.update(beets.plugins.types(Album)) + Item._original_queries = dict(Item._queries) + Album._original_queries = dict(Album._queries) + Item._queries.update(beets.plugins.named_queries(Item)) + Album._queries.update(beets.plugins.named_queries(Album)) + def unload_plugins(self): """Unload all plugins and remove the from the configuration. """ @@ -237,6 +244,8 @@ class TestHelper(object): beets.plugins._instances = {} Item._types = Item._original_types Album._types = Album._original_types + Item._queries = Item._original_queries + Album._queries = Album._original_queries def create_importer(self, item_count=1, album_count=1): """Create files to import and return corresponding session. diff --git a/test/test_dbcore.py b/test/test_dbcore.py index 89aca442b..34994e3b3 100644 --- a/test/test_dbcore.py +++ b/test/test_dbcore.py @@ -36,6 +36,17 @@ class TestSort(dbcore.query.FieldSort): pass +class TestQuery(dbcore.query.Query): + def __init__(self, pattern): + self.pattern = pattern + + def clause(self): + return None, () + + def match(self): + return True + + class TestModel1(dbcore.Model): _table = 'test' _flex_table = 'testflex' @@ -49,6 +60,9 @@ class TestModel1(dbcore.Model): _sorts = { 'some_sort': TestSort, } + _queries = { + 'some_query': TestQuery, + } @classmethod def _getters(cls): @@ -519,6 +533,10 @@ class QueryFromStringsTest(unittest.TestCase): q = self.qfs(['']) self.assertIsInstance(q.subqueries[0], dbcore.query.TrueQuery) + def test_parse_named_query(self): + q = self.qfs(['some_query:foo']) + self.assertIsInstance(q.subqueries[0], TestQuery) + class SortFromStringsTest(unittest.TestCase): def sfs(self, strings):