Skip to content

Commit c520747

Browse files
committed
Merge pull request #1429 from tomjaspers/sort-ignore-case
Sort can ignore case if configured to do so
2 parents 0711172 + 91ab207 commit c520747

File tree

8 files changed

+173
-57
lines changed

8 files changed

+173
-57
lines changed

beets/config_default.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ time_format: '%Y-%m-%d %H:%M:%S'
6767

6868
sort_album: albumartist+ album+
6969
sort_item: artist+ album+ disc+ track+
70+
sort_case_insensitive: yes
7071

7172
paths:
7273
default: $albumartist/$album%aunique{}/$track $title

beets/dbcore/query.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
unicode_literals)
1919

2020
import re
21-
from operator import attrgetter, mul
21+
from operator import mul
2222
from beets import util
2323
from datetime import datetime, timedelta
2424

@@ -717,16 +717,23 @@ class FieldSort(Sort):
717717
"""An abstract sort criterion that orders by a specific field (of
718718
any kind).
719719
"""
720-
def __init__(self, field, ascending=True):
720+
def __init__(self, field, ascending=True, case_insensitive=True):
721721
self.field = field
722722
self.ascending = ascending
723+
self.case_insensitive = case_insensitive
723724

724725
def sort(self, objs):
725726
# TODO: Conversion and null-detection here. In Python 3,
726727
# comparisons with None fail. We should also support flexible
727728
# attributes with different types without falling over.
728-
return sorted(objs, key=attrgetter(self.field),
729-
reverse=not self.ascending)
729+
730+
def key(item):
731+
field_val = getattr(item, self.field)
732+
if self.case_insensitive and isinstance(field_val, unicode):
733+
field_val = field_val.lower()
734+
return field_val
735+
736+
return sorted(objs, key=key, reverse=not self.ascending)
730737

731738
def __repr__(self):
732739
return u'<{0}: {1}{2}>'.format(
@@ -749,7 +756,8 @@ class FixedFieldSort(FieldSort):
749756
"""
750757
def order_clause(self):
751758
order = "ASC" if self.ascending else "DESC"
752-
return "{0} {1}".format(self.field, order)
759+
collate = 'COLLATE NOCASE' if self.case_insensitive else ''
760+
return "{0} {1} {2}".format(self.field, collate, order)
753761

754762

755763
class SlowFieldSort(FieldSort):

beets/dbcore/queryparse.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import re
2020
import itertools
2121
from . import query
22-
22+
import beets
2323

2424
PARSE_QUERY_PART_REGEX = re.compile(
2525
# Non-capturing optional segment for the keyword.
@@ -138,13 +138,15 @@ def construct_sort_part(model_cls, part):
138138
assert direction in ('+', '-'), "part must end with + or -"
139139
is_ascending = direction == '+'
140140

141+
case_insensitive = beets.config['sort_case_insensitive'].get(bool)
141142
if field in model_cls._sorts:
142-
sort = model_cls._sorts[field](model_cls, is_ascending)
143+
sort = model_cls._sorts[field](model_cls, is_ascending,
144+
case_insensitive)
143145
elif field in model_cls._fields:
144-
sort = query.FixedFieldSort(field, is_ascending)
146+
sort = query.FixedFieldSort(field, is_ascending, case_insensitive)
145147
else:
146148
# Flexible or computed.
147-
sort = query.SlowFieldSort(field, is_ascending)
149+
sort = query.SlowFieldSort(field, is_ascending, case_insensitive)
148150
return sort
149151

150152

beets/library.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -197,25 +197,29 @@ class SmartArtistSort(dbcore.query.Sort):
197197
"""Sort by artist (either album artist or track artist),
198198
prioritizing the sort field over the raw field.
199199
"""
200-
def __init__(self, model_cls, ascending=True):
200+
def __init__(self, model_cls, ascending=True, case_insensitive=True):
201201
self.album = model_cls is Album
202202
self.ascending = ascending
203+
self.case_insensitive = case_insensitive
203204

204205
def order_clause(self):
205206
order = "ASC" if self.ascending else "DESC"
206-
if self.album:
207-
field = 'albumartist'
208-
else:
209-
field = 'artist'
207+
field = 'albumartist' if self.album else 'artist'
208+
collate = 'COLLATE NOCASE' if self.case_insensitive else ''
210209
return ('(CASE {0}_sort WHEN NULL THEN {0} '
211210
'WHEN "" THEN {0} '
212-
'ELSE {0}_sort END) {1}').format(field, order)
211+
'ELSE {0}_sort END) {1} {2}').format(field, collate, order)
213212

214213
def sort(self, objs):
215214
if self.album:
216-
key = lambda a: a.albumartist_sort or a.albumartist
215+
field = lambda a: a.albumartist_sort or a.albumartist
216+
else:
217+
field = lambda i: i.artist_sort or i.artist
218+
219+
if self.case_insensitive:
220+
key = lambda x: field(x).lower()
217221
else:
218-
key = lambda i: i.artist_sort or i.artist
222+
key = field
219223
return sorted(objs, key=key, reverse=not self.ascending)
220224

221225

docs/changelog.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ New features:
2323
* The autotagger's **matching algorithm is faster**. We now use the
2424
`Jellyfish`_ library to compute string similarity, which is better optimized
2525
than our hand-rolled edit distance implementation. :bug:`1389`
26+
* **Sorting is now case insensitive** by default. This means that artists will
27+
be sorted lexicographically regardless of case, e.g., *Bar foo Qux*.
28+
Previously this would have resulted in *Bar Qux foo*. This behavior can be
29+
configured via the :ref:`sort_case_insensitive` configuration option.
30+
See :ref:`query-sort`. :bug:`1429`
2631
* :doc:`/plugins/fetchart`: There are new settings to control what constitutes
2732
**"acceptable" images**. The `minwidth` option constrains the minimum image
2833
width in pixels and the `enforce_ratio` option requires that images be

docs/reference/config.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,15 @@ sort_album
204204
Default sort order to use when fetching items from the database. Defaults to
205205
``albumartist+ album+``. Explicit sort orders override this default.
206206

207+
.. _sort_case_insensitive:
208+
209+
sort_case_insensitive
210+
~~~~~~~~~~~~~~~~~~~~~
211+
Either ``yes`` or ``no``, indicating whether the case should be ignored when
212+
sorting lexicographic fields. When set to ``no``, lower-case values will be
213+
placed after upper-case values (e.g., *Bar Qux foo*), while ``yes`` would
214+
result in the more expected *Bar foo Qux*. Default: ``yes``.
215+
207216
.. _original_date:
208217

209218
original_date

docs/reference/query.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,5 +223,11 @@ The ``artist`` and ``albumartist`` keys are special: they attempt to use their
223223
corresponding ``artist_sort`` and ``albumartist_sort`` fields for sorting
224224
transparently (but fall back to the ordinary fields when those are empty).
225225

226+
Lexicographic sorts are case insensitive by default, resulting in the following
227+
sort order: ``Bar foo Qux``. This behavior can be changed with the
228+
:ref:`sort_case_insensitive` configuration option. Case sensitive sort will
229+
result in lower-case values being placed after upper-case values, e.g.,
230+
``Bar Qux foo``.
231+
226232
You can set the default sorting behavior with the :ref:`sort_item` and
227233
:ref:`sort_album` configuration options.

test/test_sort.py

Lines changed: 121 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -32,65 +32,65 @@ def setUp(self):
3232
self.lib = beets.library.Library(':memory:')
3333

3434
albums = [_common.album() for _ in range(3)]
35-
albums[0].album = "album A"
35+
albums[0].album = "Album A"
3636
albums[0].genre = "Rock"
3737
albums[0].year = "2001"
38-
albums[0].flex1 = "flex1-1"
39-
albums[0].flex2 = "flex2-A"
40-
albums[0].albumartist = "foo"
38+
albums[0].flex1 = "Flex1-1"
39+
albums[0].flex2 = "Flex2-A"
40+
albums[0].albumartist = "Foo"
4141
albums[0].albumartist_sort = None
42-
albums[1].album = "album B"
42+
albums[1].album = "Album B"
4343
albums[1].genre = "Rock"
4444
albums[1].year = "2001"
45-
albums[1].flex1 = "flex1-2"
46-
albums[1].flex2 = "flex2-A"
47-
albums[1].albumartist = "bar"
45+
albums[1].flex1 = "Flex1-2"
46+
albums[1].flex2 = "Flex2-A"
47+
albums[1].albumartist = "Bar"
4848
albums[1].albumartist_sort = None
49-
albums[2].album = "album C"
49+
albums[2].album = "Album C"
5050
albums[2].genre = "Jazz"
5151
albums[2].year = "2005"
52-
albums[2].flex1 = "flex1-1"
53-
albums[2].flex2 = "flex2-B"
54-
albums[2].albumartist = "baz"
52+
albums[2].flex1 = "Flex1-1"
53+
albums[2].flex2 = "Flex2-B"
54+
albums[2].albumartist = "Baz"
5555
albums[2].albumartist_sort = None
5656
for album in albums:
5757
self.lib.add(album)
5858

5959
items = [_common.item() for _ in range(4)]
60-
items[0].title = 'foo bar'
61-
items[0].artist = 'one'
62-
items[0].album = 'baz'
60+
items[0].title = 'Foo bar'
61+
items[0].artist = 'One'
62+
items[0].album = 'Baz'
6363
items[0].year = 2001
6464
items[0].comp = True
65-
items[0].flex1 = "flex1-0"
66-
items[0].flex2 = "flex2-A"
65+
items[0].flex1 = "Flex1-0"
66+
items[0].flex2 = "Flex2-A"
6767
items[0].album_id = albums[0].id
6868
items[0].artist_sort = None
69-
items[1].title = 'baz qux'
70-
items[1].artist = 'two'
71-
items[1].album = 'baz'
69+
items[1].title = 'Baz qux'
70+
items[1].artist = 'Two'
71+
items[1].album = 'Baz'
7272
items[1].year = 2002
7373
items[1].comp = True
74-
items[1].flex1 = "flex1-1"
75-
items[1].flex2 = "flex2-A"
74+
items[1].flex1 = "Flex1-1"
75+
items[1].flex2 = "Flex2-A"
7676
items[1].album_id = albums[0].id
7777
items[1].artist_sort = None
78-
items[2].title = 'beets 4 eva'
79-
items[2].artist = 'three'
80-
items[2].album = 'foo'
78+
items[2].title = 'Beets 4 eva'
79+
items[2].artist = 'Three'
80+
items[2].album = 'Foo'
8181
items[2].year = 2003
8282
items[2].comp = False
83-
items[2].flex1 = "flex1-2"
84-
items[2].flex2 = "flex1-B"
83+
items[2].flex1 = "Flex1-2"
84+
items[2].flex2 = "Flex1-B"
8585
items[2].album_id = albums[1].id
8686
items[2].artist_sort = None
87-
items[3].title = 'beets 4 eva'
88-
items[3].artist = 'three'
89-
items[3].album = 'foo2'
87+
items[3].title = 'Beets 4 eva'
88+
items[3].artist = 'Three'
89+
items[3].album = 'Foo2'
9090
items[3].year = 2004
9191
items[3].comp = False
92-
items[3].flex1 = "flex1-2"
93-
items[3].flex2 = "flex1-C"
92+
items[3].flex1 = "Flex1-2"
93+
items[3].flex2 = "Flex1-C"
9494
items[3].album_id = albums[2].id
9595
items[3].artist_sort = None
9696
for item in items:
@@ -132,8 +132,8 @@ def test_sort_two_field_asc(self):
132132
results = self.lib.items(q, sort)
133133
self.assertLessEqual(results[0]['album'], results[1]['album'])
134134
self.assertLessEqual(results[1]['album'], results[2]['album'])
135-
self.assertEqual(results[0]['album'], 'baz')
136-
self.assertEqual(results[1]['album'], 'baz')
135+
self.assertEqual(results[0]['album'], 'Baz')
136+
self.assertEqual(results[1]['album'], 'Baz')
137137
self.assertLessEqual(results[0]['year'], results[1]['year'])
138138
# same thing with query string
139139
q = 'album+ year+'
@@ -148,7 +148,7 @@ def test_sort_asc(self):
148148
sort = dbcore.query.SlowFieldSort("flex1", True)
149149
results = self.lib.items(q, sort)
150150
self.assertLessEqual(results[0]['flex1'], results[1]['flex1'])
151-
self.assertEqual(results[0]['flex1'], 'flex1-0')
151+
self.assertEqual(results[0]['flex1'], 'Flex1-0')
152152
# same thing with query string
153153
q = 'flex1+'
154154
results2 = self.lib.items(q)
@@ -162,7 +162,7 @@ def test_sort_desc(self):
162162
self.assertGreaterEqual(results[0]['flex1'], results[1]['flex1'])
163163
self.assertGreaterEqual(results[1]['flex1'], results[2]['flex1'])
164164
self.assertGreaterEqual(results[2]['flex1'], results[3]['flex1'])
165-
self.assertEqual(results[0]['flex1'], 'flex1-2')
165+
self.assertEqual(results[0]['flex1'], 'Flex1-2')
166166
# same thing with query string
167167
q = 'flex1-'
168168
results2 = self.lib.items(q)
@@ -179,8 +179,8 @@ def test_sort_two_field(self):
179179
results = self.lib.items(q, sort)
180180
self.assertGreaterEqual(results[0]['flex2'], results[1]['flex2'])
181181
self.assertGreaterEqual(results[1]['flex2'], results[2]['flex2'])
182-
self.assertEqual(results[0]['flex2'], 'flex2-A')
183-
self.assertEqual(results[1]['flex2'], 'flex2-A')
182+
self.assertEqual(results[0]['flex2'], 'Flex2-A')
183+
self.assertEqual(results[1]['flex2'], 'Flex2-A')
184184
self.assertLessEqual(results[0]['flex1'], results[1]['flex1'])
185185
# same thing with query string
186186
q = 'flex2- flex1+'
@@ -269,8 +269,8 @@ def test_sort_two_field_asc(self):
269269
results = self.lib.albums(q, sort)
270270
self.assertLessEqual(results[0]['flex2'], results[1]['flex2'])
271271
self.assertLessEqual(results[1]['flex2'], results[2]['flex2'])
272-
self.assertEqual(results[0]['flex2'], 'flex2-A')
273-
self.assertEqual(results[1]['flex2'], 'flex2-A')
272+
self.assertEqual(results[0]['flex2'], 'Flex2-A')
273+
self.assertEqual(results[1]['flex2'], 'Flex2-A')
274274
self.assertLessEqual(results[0]['flex1'], results[1]['flex1'])
275275
# same thing with query string
276276
q = 'flex2+ flex1+'
@@ -358,6 +358,87 @@ def test_config_opposite_sort_album(self):
358358
self.assertGreater(results[0].albumartist, results[1].albumartist)
359359

360360

361+
class CaseSensitivityTest(DummyDataTestCase, _common.TestCase):
362+
"""If case_insensitive is false, lower-case values should be placed
363+
after all upper-case values. E.g., `Foo Qux bar`
364+
"""
365+
366+
def setUp(self):
367+
super(CaseSensitivityTest, self).setUp()
368+
369+
album = _common.album()
370+
album.album = "album"
371+
album.genre = "alternative"
372+
album.year = "2001"
373+
album.flex1 = "flex1"
374+
album.flex2 = "flex2-A"
375+
album.albumartist = "bar"
376+
album.albumartist_sort = None
377+
self.lib.add(album)
378+
379+
item = _common.item()
380+
item.title = 'another'
381+
item.artist = 'lowercase'
382+
item.album = 'album'
383+
item.year = 2001
384+
item.comp = True
385+
item.flex1 = "flex1"
386+
item.flex2 = "flex2-A"
387+
item.album_id = album.id
388+
item.artist_sort = None
389+
self.lib.add(item)
390+
391+
self.new_album = album
392+
self.new_item = item
393+
394+
def tearDown(self):
395+
self.new_item.remove(delete=True)
396+
self.new_album.remove(delete=True)
397+
super(CaseSensitivityTest, self).tearDown()
398+
399+
def test_smart_artist_case_insensitive(self):
400+
config['sort_case_insensitive'] = True
401+
q = 'artist+'
402+
results = list(self.lib.items(q))
403+
self.assertEqual(results[0].artist, 'lowercase')
404+
self.assertEqual(results[1].artist, 'One')
405+
406+
def test_smart_artist_case_sensitive(self):
407+
config['sort_case_insensitive'] = False
408+
q = 'artist+'
409+
results = list(self.lib.items(q))
410+
self.assertEqual(results[0].artist, 'One')
411+
self.assertEqual(results[-1].artist, 'lowercase')
412+
413+
def test_fixed_field_case_insensitive(self):
414+
config['sort_case_insensitive'] = True
415+
q = 'album+'
416+
results = list(self.lib.albums(q))
417+
self.assertEqual(results[0].album, 'album')
418+
self.assertEqual(results[1].album, 'Album A')
419+
420+
def test_fixed_field_case_sensitive(self):
421+
config['sort_case_insensitive'] = False
422+
q = 'album+'
423+
results = list(self.lib.albums(q))
424+
self.assertEqual(results[0].album, 'Album A')
425+
self.assertEqual(results[-1].album, 'album')
426+
427+
def test_flex_field_case_insensitive(self):
428+
config['sort_case_insensitive'] = True
429+
q = 'flex1+'
430+
results = list(self.lib.items(q))
431+
self.assertEqual(results[0].flex1, 'flex1')
432+
self.assertEqual(results[1].flex1, 'Flex1-0')
433+
434+
def test_flex_field_case_sensitive(self):
435+
config['sort_case_insensitive'] = False
436+
q = 'flex1+'
437+
results = list(self.lib.items(q))
438+
self.assertEqual(results[0].flex1, 'Flex1-0')
439+
self.assertEqual(results[-1].flex1, 'flex1')
440+
441+
361442
def suite():
362443
return unittest.TestLoader().loadTestsFromName(__name__)
363444

0 commit comments

Comments
 (0)