use separate shell arguments for queries to preserve whitespace

This commit is contained in:
Adrian Sampson 2011-06-26 00:12:45 -07:00
parent f3ac19622a
commit fcc2744ac5
6 changed files with 60 additions and 60 deletions

11
NEWS
View file

@ -1,5 +1,14 @@
1.0b9
-----
* Queries can now contain whitespace. Spaces passed as shell
arguments are now preserved, so you can use your shell's escaping
syntax to include spaces in queries. For example, typing this:
beet ls "the knife"
or this:
beet ls the\ knife
now matches the entire phrase. To match in specified fields, use
a construction like this:
beet ls "artist:the knife"
* Album art is now automatically discovered and copied from the
imported directories when available.
* The release label for albums and tracks is now fetched from
@ -70,7 +79,7 @@
"embedart", implements this functionality. Enable the plugin to
automatically embed downloaded album art into your music files'
metadata. The plugin also provides the "embedart" and "extractart"
commands for moving image files in and out of metadata. See the
commands for moving image files in and247ca93696d3 out of metadata. See the
wiki for more details. (Thanks, daenney!)
* The "distance" number, which quantifies how different an album's
current and proposed metadata are, is now displayed as "similarity"

View file

@ -381,46 +381,45 @@ class CollectionQuery(Query):
clause = (' ' + joiner + ' ').join(clause_parts)
return clause, subvals
# regular expression for _parse_query, below
_pq_regex = re.compile(r'(?:^|(?<=\s))' # zero-width match for whitespace
# or beginning of string
# non-grouping optional segment for the keyword
# regular expression for _parse_query_part, below
_pq_regex = re.compile(# non-grouping optional segment for the keyword
r'(?:'
r'(\S+?)' # the keyword
r'(?<!\\):' # unescaped :
r')?'
r'(\S+)', # the term itself
r'(.+)', # the term itself
re.I) # case-insensitive
@classmethod
def _parse_query(cls, query_string):
"""Takes a query in the form of a whitespace-separated list of
search terms that may be preceded with a key followed by a
colon. Returns a list of pairs (key, term) where key is None if
the search term has no key.
def _parse_query_part(cls, part):
"""Takes a query in the form of a key/value pair separated by a
colon. Returns pair (key, term) where key is None if the search
term has no key.
For instance,
parse_query('stapler color:red') ==
[(None, 'stapler'), ('color', 'red')]
parse_query('stapler') == (None, 'stapler')
parse_query('color:red') == ('color', 'red')
Colons may be 'escaped' with a backslash to disable the keying
behavior.
"""
out = []
for match in cls._pq_regex.finditer(query_string):
out.append((match.group(1), match.group(2).replace(r'\:',':')))
return out
part = part.strip()
match = cls._pq_regex.match(part)
if match:
return match.group(1), match.group(2).replace(r'\:', ':')
@classmethod
def from_string(cls, query_string, default_fields=None, all_keys=ITEM_KEYS):
"""Creates a query from a string in the format used by
_parse_query. If default_fields are specified, they are the
def from_strings(cls, query_parts, default_fields=None, all_keys=ITEM_KEYS):
"""Creates a query from a list of strings in the format used by
_parse_query_part. If default_fields are specified, they are the
fields to be searched by unqualified search terms. Otherwise,
all fields are searched for those terms.
"""
subqueries = []
for key, pattern in cls._parse_query(query_string):
for part in query_parts:
res = cls._parse_query_part(part)
if not res:
continue
key, pattern = res
if key is None: # no key specified; match any field
subqueries.append(AnySubstringQuery(pattern, default_fields))
elif key.lower() == 'comp': # a boolean field
@ -544,9 +543,10 @@ class BaseLibrary(object):
@classmethod
def _get_query(cls, val=None, album=False):
"""Takes a value which may be None, a query string, or a Query
object, and returns a suitable Query object. album determines
whether the query is to match items or albums.
"""Takes a value which may be None, a query string, a query
string list, or a Query object, and returns a suitable Query
object. album determines whether the query is to match items
or albums.
"""
if album:
default_fields = ALBUM_DEFAULT_FIELDS
@ -555,10 +555,15 @@ class BaseLibrary(object):
default_fields = ITEM_DEFAULT_FIELDS
all_keys = ITEM_KEYS
# Convert a single string into a list of space-separated
# criteria.
if isinstance(val, basestring):
val = val.split()
if val is None:
return TrueQuery()
elif isinstance(val, basestring):
return AndQuery.from_string(val, default_fields, all_keys)
elif isinstance(val, list) or isinstance(val, tuple):
return AndQuery.from_strings(val, default_fields, all_keys)
elif isinstance(val, Query):
return val
elif not isinstance(val, Query):

View file

@ -247,10 +247,6 @@ def input_yn(prompt, require=False, color=False):
)
return sel == 'y'
def make_query(criteria):
"""Make query string for the list of criteria."""
return ' '.join(criteria).strip() or None
def config_val(config, section, name, default, vtype=None):
"""Queries the configuration file for a value (given by the
section and name). If no value is present, returns default.

View file

@ -618,7 +618,7 @@ list_cmd.parser.add_option('-a', '--album', action='store_true',
list_cmd.parser.add_option('-p', '--path', action='store_true',
help='print paths for matched items or albums')
def list_func(lib, config, opts, args):
list_items(lib, ui.make_query(args), opts.album, opts.path)
list_items(lib, args, opts.album, opts.path)
list_cmd.func = list_func
default_commands.append(list_cmd)
@ -673,7 +673,7 @@ remove_cmd.parser.add_option("-d", "--delete", action="store_true",
remove_cmd.parser.add_option('-a', '--album', action='store_true',
help='match albums instead of tracks')
def remove_func(lib, config, opts, args):
remove_items(lib, ui.make_query(args), opts.album, opts.delete)
remove_items(lib, args, opts.album, opts.delete)
remove_cmd.func = remove_func
default_commands.append(remove_cmd)
@ -714,7 +714,7 @@ Albums: %i""" % (
stats_cmd = ui.Subcommand('stats',
help='show statistics about the library or a query')
def stats_func(lib, config, opts, args):
show_stats(lib, ui.make_query(args))
show_stats(lib, args)
stats_cmd.func = stats_func
default_commands.append(stats_cmd)

View file

@ -41,7 +41,7 @@ class EmbedCoverArtPlugin(BeetsPlugin):
if not args:
raise ui.UserError('specify an image file')
imagepath = normpath(args.pop(0))
embed(lib, imagepath, ui.make_query(args))
embed(lib, imagepath, args)
embed_cmd.func = embed_func
# Extract command.
@ -51,14 +51,14 @@ class EmbedCoverArtPlugin(BeetsPlugin):
help='image output file')
def extract_func(lib, config, opts, args):
outpath = normpath(opts.outpath or 'cover')
extract(lib, outpath, ui.make_query(args))
extract(lib, outpath, args)
extract_cmd.func = extract_func
# Clear command.
clear_cmd = ui.Subcommand('clearart',
help='remove images from file metadata')
def clear_func(lib, config, opts, args):
clear(lib, ui.make_query(args))
clear(lib, args)
clear_cmd.func = clear_func
return [embed_cmd, extract_cmd, clear_cmd]

View file

@ -20,45 +20,35 @@ import os
import _common
import beets.library
parse_query = beets.library.CollectionQuery._parse_query
pqp = beets.library.CollectionQuery._parse_query_part
some_item = _common.item()
class QueryParseTest(unittest.TestCase):
def test_one_basic_term(self):
q = 'test'
r = [(None, 'test')]
self.assertEqual(parse_query(q), r)
def test_three_basic_terms(self):
q = 'test one two'
r = [(None, 'test'), (None, 'one'), (None, 'two')]
self.assertEqual(parse_query(q), r)
r = (None, 'test')
self.assertEqual(pqp(q), r)
def test_one_keyed_term(self):
q = 'test:val'
r = [('test', 'val')]
self.assertEqual(parse_query(q), r)
def test_one_keyed_one_basic(self):
q = 'test:val one'
r = [('test', 'val'), (None, 'one')]
self.assertEqual(parse_query(q), r)
r = ('test', 'val')
self.assertEqual(pqp(q), r)
def test_colon_at_end(self):
q = 'test:'
r = [(None, 'test:')]
self.assertEqual(parse_query(q), r)
r = (None, 'test:')
self.assertEqual(pqp(q), r)
def test_colon_at_start(self):
q = ':test'
r = [(None, ':test')]
self.assertEqual(parse_query(q), r)
r = (None, ':test')
self.assertEqual(pqp(q), r)
def test_escaped_colon(self):
q = r'test\:val'
r = [((None), 'test:val')]
self.assertEqual(parse_query(q), r)
r = (None, 'test:val')
self.assertEqual(pqp(q), r)
class AnySubstringQueryTest(unittest.TestCase):
def setUp(self):