some low-level tweaks to extensible queries (#214)

This commit is contained in:
Adrian Sampson 2013-03-13 21:59:03 -07:00
parent 292092bef7
commit 40b49ac786
6 changed files with 61 additions and 78 deletions

View file

@ -17,7 +17,6 @@
import sqlite3 import sqlite3
import os import os
import re import re
import difflib
import sys import sys
import logging import logging
import shlex import shlex
@ -515,7 +514,6 @@ class RegexpQuery(FieldQuery):
value = util.as_string(getattr(item, self.field)) value = util.as_string(getattr(item, self.field))
return self.regexp.search(value) is not None return self.regexp.search(value) is not None
class PluginQuery(FieldQuery): class PluginQuery(FieldQuery):
"""The base class to add queries using beets plugins. Plugins can add """The base class to add queries using beets plugins. Plugins can add
special queries by defining a subclass of PluginQuery and overriding special queries by defining a subclass of PluginQuery and overriding
@ -525,9 +523,18 @@ class PluginQuery(FieldQuery):
super(PluginQuery, self).__init__(field, pattern) super(PluginQuery, self).__init__(field, pattern)
def clause(self): def clause(self):
clause = "{name}(?, {field})".format(name=self.__class__.__name__, field=self.field) # Invoke the registered SQLite function.
clause = "{name}(?, {field})".format(name=self.__class__.__name__,
field=self.field)
return clause, [self.pattern] return clause, [self.pattern]
@classmethod
def register(cls, conn):
"""Register this query's matching function with the SQLite
connection.
"""
conn.create_function(cls.__name__, 2, cls(None, None).match)
class BooleanQuery(MatchQuery): class BooleanQuery(MatchQuery):
"""Matches a boolean field. Pattern should either be a boolean or a """Matches a boolean field. Pattern should either be a boolean or a
string reflecting a boolean. string reflecting a boolean.
@ -593,11 +600,14 @@ class CollectionQuery(Query):
@classmethod @classmethod
def _parse_query_part(cls, part): def _parse_query_part(cls, part):
"""Takes a query in the form of a key/value pair separated by a """Takes a query in the form of a key/value pair separated by a
colon. The value part is matched against a list of prefixes that can be colon. The value part is matched against a list of prefixes that
extended by plugins to add custom query types. For example, the colon can be extended by plugins to add custom query types. For
prefix denotes a regular exporession query. example, the colon prefix denotes a regular expression query.
The function returns a tuple of(key, value, Query) The function returns a tuple of `(key, value, cls)`. `key` may
be None, indicating that any field may be matched. `cls` is
either a subclass of `PluginQuery` or `None` indicating a
"normal" query.
For instance, For instance,
parse_query('stapler') == (None, 'stapler', None) parse_query('stapler') == (None, 'stapler', None)
@ -611,17 +621,17 @@ class CollectionQuery(Query):
part = part.strip() part = part.strip()
match = cls._pq_regex.match(part) match = cls._pq_regex.match(part)
cls.prefixes = {':': RegexpQuery} prefixes = {':': RegexpQuery}
cls.prefixes.update(plugins.queries()) prefixes.update(plugins.queries())
if match: if match:
key = match.group(1) key = match.group(1)
term = match.group(2).replace('\:', ':') term = match.group(2).replace('\:', ':')
# match the search term against the list of prefixes # Match the search term against the list of prefixes.
for pre, query in cls.prefixes.items(): for pre, query_class in prefixes.items():
if term.startswith(pre): if term.startswith(pre):
return (key, term[len(pre):], query) return key, term[len(pre):], query_class
return (key, term, None) # None means a normal query return key, term, None # None means a normal query.
@classmethod @classmethod
def from_strings(cls, query_parts, default_fields=None, def from_strings(cls, query_parts, default_fields=None,
@ -637,7 +647,7 @@ class CollectionQuery(Query):
if not res: if not res:
continue continue
key, pattern, prefix_query = res key, pattern, query_class = res
# No key specified. # No key specified.
if key is None: if key is None:
@ -646,8 +656,9 @@ class CollectionQuery(Query):
subqueries.append(PathQuery(pattern)) subqueries.append(PathQuery(pattern))
else: else:
# Match any field. # Match any field.
if prefix_query: if query_class:
subq = AnyPluginQuery(pattern, default_fields, cls=prefix_query) subq = AnyPluginQuery(pattern, default_fields,
cls=query_class)
else: else:
subq = AnySubstringQuery(pattern, default_fields) subq = AnySubstringQuery(pattern, default_fields)
subqueries.append(subq) subqueries.append(subq)
@ -662,8 +673,8 @@ class CollectionQuery(Query):
# Other (recognized) field. # Other (recognized) field.
elif key.lower() in all_keys: elif key.lower() in all_keys:
if prefix_query is not None: if query_class:
subqueries.append(prefix_query(key.lower(), pattern)) subqueries.append(query_class(key.lower(), pattern))
else: else:
subqueries.append(SubstringQuery(key.lower(), pattern)) subqueries.append(SubstringQuery(key.lower(), pattern))
@ -724,42 +735,10 @@ class AnySubstringQuery(CollectionQuery):
return True return True
return False return False
class AnyRegexpQuery(CollectionQuery):
"""A query that matches a regexp in any of a list of metadata
fields.
"""
def __init__(self, pattern, fields=None):
"""Create a query for regexp over the sequence of fields
given. If no fields are given, all available fields are
used.
"""
self.regexp = re.compile(pattern)
self.fields = fields or ITEM_KEYS_WRITABLE
subqueries = []
for field in self.fields:
subqueries.append(RegexpQuery(field, pattern))
super(AnyRegexpQuery, self).__init__(subqueries)
def clause(self):
return self.clause_with_joiner('or')
def match(self, item):
for fld in self.fields:
try:
val = getattr(item, fld)
except KeyError:
continue
if isinstance(val, basestring) and \
self.regexp.match(val) is not None:
return True
return False
class AnyPluginQuery(CollectionQuery): class AnyPluginQuery(CollectionQuery):
"""A query that dispatch the matching function to the match method of """A query that dispatch the matching function to the match method of
the cls provided to the contstructor using a list of metadata fields. the cls provided to the contstructor using a list of metadata fields.
""" """
def __init__(self, pattern, fields=None, cls=PluginQuery): def __init__(self, pattern, fields=None, cls=PluginQuery):
subqueries = [] subqueries = []
self.pattern = pattern self.pattern = pattern
@ -1174,9 +1153,9 @@ class Library(BaseLibrary):
# Add the REGEXP function to SQLite queries. # Add the REGEXP function to SQLite queries.
conn.create_function("REGEXP", 2, _regexp) conn.create_function("REGEXP", 2, _regexp)
# Register plugin queries # Register plugin queries.
for prefix, query in plugins.queries().items(): for prefix, query_class in plugins.queries().items():
conn.create_function(query.__name__, 2, query(None, None).match) query_class.register(conn)
self._connections[thread_id] = conn self._connections[thread_id] = conn
return conn return conn

View file

@ -56,7 +56,9 @@ class BeetsPlugin(object):
return () return ()
def queries(self): def queries(self):
"""Should return a dict of {prefix : beets.library.PluginQuery}""" """Should return a dict mapping prefixes to PluginQuery
subclasses.
"""
return {} return {}
def track_distance(self, item, info): def track_distance(self, item, info):
@ -215,8 +217,9 @@ def commands():
return out return out
def queries(): def queries():
"""Returns a dict of {prefix: beet.library.PluginQuery} objects from all """Returns a dict mapping prefix strings to beet.library.PluginQuery
loaded plugins. """ subclasses all loaded plugins.
"""
out = {} out = {}
for plugin in find_plugins(): for plugin in find_plugins():
out.update(plugin.queries()) out.update(plugin.queries())

View file

@ -12,12 +12,11 @@
# The above copyright notice and this permission notice shall be # The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software. # included in all copies or substantial portions of the Software.
"""Like beet list, but with fuzzy matching """Provides a fuzzy matching query.
""" """
from beets.plugins import BeetsPlugin from beets.plugins import BeetsPlugin
from beets.library import PluginQuery from beets.library import PluginQuery
from beets.ui import Subcommand, decargs, print_obj
from beets import util from beets import util
import beets import beets
from beets.util import confit from beets.util import confit
@ -28,7 +27,7 @@ class FuzzyQuery(PluginQuery):
def __init__(self, field, pattern): def __init__(self, field, pattern):
super(FuzzyQuery, self).__init__(field, pattern) super(FuzzyQuery, self).__init__(field, pattern)
try: try:
self.threshold = beets.config['fuzzy']['threshold'].as_number() self.threshold = beets.config['fuzzy']['threshold'].as_number()
except confit.NotFoundError: except confit.NotFoundError:
self.threshold = 0.7 self.threshold = 0.7
@ -37,7 +36,7 @@ class FuzzyQuery(PluginQuery):
return False return False
val = util.as_string(val) val = util.as_string(val)
# smartcase # smartcase
if(pattern.islower()): if pattern.islower():
val = val.lower() val = val.lower()
queryMatcher = difflib.SequenceMatcher(None, pattern, val) queryMatcher = difflib.SequenceMatcher(None, pattern, val)
return queryMatcher.quick_ratio() >= self.threshold return queryMatcher.quick_ratio() >= self.threshold

View file

@ -23,6 +23,12 @@ Other stuff:
track in MusicBrainz and updates your library to reflect it. This can help track in MusicBrainz and updates your library to reflect it. This can help
you easily correct errors that have been fixed in the MB database. Thanks to you easily correct errors that have been fixed in the MB database. Thanks to
Jakob Schnitzer. Jakob Schnitzer.
* :doc:`/plugins/fuzzy`: The ``fuzzy`` command was removed and replaced with a
new query type. To perform fuzzy searches, use the ``~`` prefix with
:ref:`list-cmd` or other commands. Thanks to Philippe Mongeau.
* As part of the above, plugins can now extend the query syntax and new kinds
of matching capabilities to beets. See :ref:`extend-query`. Thanks again to
Philippe Mongeau.
* :doc:`/plugins/convert`: A new ``--keep-new`` option lets you store * :doc:`/plugins/convert`: A new ``--keep-new`` option lets you store
transcoded files in your library while backing up the originals (instead of transcoded files in your library while backing up the originals (instead of
vice-versa). Thanks to Lucas Duailibe. vice-versa). Thanks to Lucas Duailibe.
@ -59,8 +65,6 @@ Other stuff:
the Echo Nest library. the Echo Nest library.
* :doc:`/plugins/chroma`: Catch Acoustid Web service errors when submitting * :doc:`/plugins/chroma`: Catch Acoustid Web service errors when submitting
fingerprints. fingerprints.
* :ref:`extend-query`: Plugins can now extend the query syntax. Thanks to
Philippe Mongeau
1.1b2 (February 16, 2013) 1.1b2 (February 16, 2013)
------------------------- -------------------------

View file

@ -1,8 +1,8 @@
Fuzzy Search Plugin Fuzzy Search Plugin
=================== ===================
The ``fuzzy`` plugin provides a query prefix that search you library using fuzzy The ``fuzzy`` plugin provides a prefixed query that search you library using
pattern matching. This can be useful if you want to find a track with fuzzy pattern matching. This can be useful if you want to find a track with
complicated characters in the title. complicated characters in the title.
First, enable the plugin named ``fuzzy`` (see :doc:`/plugins/index`). First, enable the plugin named ``fuzzy`` (see :doc:`/plugins/index`).
@ -11,14 +11,14 @@ You'll then be able to use the ``~`` prefix to use fuzzy matching::
$ beet ls '~Vareoldur' $ beet ls '~Vareoldur'
Sigur Rós - Valtari - Varðeldur Sigur Rós - Valtari - Varðeldur
The plugin provides to config option to let you choose the prefix and the The plugin provides config options that let you choose the prefix and the
threshold.:: threshold.::
fuzzy: fuzzy:
threshold: 0.8 threshold: 0.8
prefix: '@' prefix: '@'
A threshold value of ``1`` will show only perfect matches and a value of ``0`` A threshold value of 1.0 will show only perfect matches and a value of 0.0
will match everything. will match everything.
The default prefix ``~`` needs to be escaped or quoted in most shells. If this The default prefix ``~`` needs to be escaped or quoted in most shells. If this

View file

@ -327,32 +327,30 @@ to register it::
.. _extend-query: .. _extend-query:
Extend the Query Syntax Extend the Query Syntax
^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^
Beets already support searching using regular expressions by prepending search You can add new kinds of queries to beets' :doc:`query syntax
terms with the colon prefix. It is possible to add new prefix by extending the </reference/query>` indicated by a prefix. As an example, beets already
``PluginQuery`` class. supports regular expression queries, which are indicated by a colon
prefix---plugins can do the same.
The plugin then need to declare its new queries by returning a ``dict`` of To do so, define a subclass of the ``PluginQuery`` type from the
``{prefix: PluginQuery}`` from the ``queries`` method. ``beets.library`` module. Then, in the ``queries`` method of your plugin
class, return a dictionary mapping prefix strings to query classes.
The following example plugins declares a query using the ``@`` prefix. So the The following example plugins declares a query using the ``@`` prefix. So the
plugin will be called if we issue a command like ``beet ls @something`` or plugin will be called if we issue a command like ``beet ls @something`` or
``beet ls artist:@something``:: ``beet ls artist:@something``::
from beets.plugins import BeetsPlugin from beets.plugins import BeetsPlugin
from beets.Library import PluginQuery from beets.library import PluginQuery
class ExampleQuery(PluginQuery): class ExampleQuery(PluginQuery):
def match(self, pattern, val): def match(self, pattern, val):
return True # this will simply match everything return True # This will just match everything.
class ExamplePlugin(BeetsPlugin): class ExamplePlugin(BeetsPlugin):
def queries(): def queries():
# plugins need to declare theire queries by
# returning a dict of {prefix: PluginQuery}
# from the queries() function
return { return {
'@': ExampleQuery '@': ExampleQuery
} }