mirror of
https://github.com/beetbox/beets.git
synced 2025-12-31 04:52:49 +01:00
use separate shell arguments for queries to preserve whitespace
This commit is contained in:
parent
f3ac19622a
commit
fcc2744ac5
6 changed files with 60 additions and 60 deletions
11
NEWS
11
NEWS
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Reference in a new issue