Skip to content

Commit d31a483

Browse files
authored
Merge pull request #2598 from beetbox/relativedate
Relative date queries (continuation of #2418)
2 parents 63692ff + a52d3d5 commit d31a483

File tree

4 files changed

+145
-4
lines changed

4 files changed

+145
-4
lines changed

beets/dbcore/query.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,9 @@ class Period(object):
562562
('%Y-%m-%dT%H:%M', '%Y-%m-%d %H:%M'), # minute
563563
('%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M:%S') # second
564564
)
565+
relative_units = {'y': 365, 'm': 30, 'w': 7, 'd': 1}
566+
relative_re = '(?P<sign>[+|-]?)(?P<quantity>[0-9]+)' + \
567+
'(?P<timespan>[y|m|w|d])'
565568

566569
def __init__(self, date, precision):
567570
"""Create a period with the given date (a `datetime` object) and
@@ -575,9 +578,20 @@ def __init__(self, date, precision):
575578

576579
@classmethod
577580
def parse(cls, string):
578-
"""Parse a date and return a `Period` object, or `None` if the
581+
"""Parse a date and return a `Period` object or `None` if the
579582
string is empty, or raise an InvalidQueryArgumentValueError if
580-
the string could not be parsed to a date.
583+
the string cannot be parsed to a date.
584+
585+
The date may be absolute or relative. Absolute dates look like
586+
`YYYY`, or `YYYY-MM-DD`, or `YYYY-MM-DD HH:MM:SS`, etc. Relative
587+
dates have three parts:
588+
589+
- Optionally, a ``+`` or ``-`` sign indicating the future or the
590+
past. The default is the future.
591+
- A number: how much to add or subtract.
592+
- A letter indicating the unit: days, weeks, months or years
593+
(``d``, ``w``, ``m`` or ``y``). A "month" is exactly 30 days
594+
and a "year" is exactly 365 days.
581595
"""
582596

583597
def find_date_and_format(string):
@@ -593,10 +607,27 @@ def find_date_and_format(string):
593607

594608
if not string:
595609
return None
610+
611+
# Check for a relative date.
612+
match_dq = re.match(cls.relative_re, string)
613+
if match_dq:
614+
sign = match_dq.group('sign')
615+
quantity = match_dq.group('quantity')
616+
timespan = match_dq.group('timespan')
617+
618+
# Add or subtract the given amount of time from the current
619+
# date.
620+
multiplier = -1 if sign == '-' else 1
621+
days = cls.relative_units[timespan]
622+
date = datetime.now() + \
623+
timedelta(days=int(quantity) * days) * multiplier
624+
return cls(date, cls.precisions[5])
625+
626+
# Check for an absolute date.
596627
date, ordinal = find_date_and_format(string)
597628
if date is None:
598629
raise InvalidQueryArgumentValueError(string,
599-
'a valid datetime string')
630+
'a valid date/time string')
600631
precision = cls.precisions[ordinal]
601632
return cls(date, precision)
602633

docs/changelog.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ Features:
1313
* :ref:`Date queries <datequery>` can now include times, so you can filter
1414
your music down to the second. Thanks to :user:`discopatrick`. :bug:`2506`
1515
:bug:`2528`
16+
* :ref:`Date queries <datequery>` can also be *relative*. You can say
17+
``added:-1w..`` to match music added in the last week, for example. Thanks
18+
to :user:`euri10`. :bug:`2598`
1619
* A new :doc:`/plugins/gmusic` lets you interact with your Google Play Music
1720
library. Thanks to :user:`tigranl`. :bug:`2553` :bug:`2586`
1821
* :doc:`/plugins/replaygain`: We now keep R128 data in separate tags from

docs/reference/query.rst

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,25 @@ queries do the same thing::
217217
$ beet ls 'added:2008-12-01t22:45:20'
218218
$ beet ls 'added:2008-12-01 22:45:20'
219219

220+
You can also use *relative* dates. For example, ``-3w`` means three weeks ago,
221+
and ``+4d`` means four days in the future. A relative date has three parts:
222+
223+
- Either ``+`` or ``-``, to indicate the past or the future. The sign is
224+
optional; if you leave this off, it defaults to the future.
225+
- A number.
226+
- A letter indicating the unit: ``d``, ``w``, ``m`` or ``y``, meaning days,
227+
weeks, months or years. (A "month" is always 30 days and a "year" is always
228+
365 days.)
229+
230+
Here's an example that finds all the albums added since last week::
231+
232+
$ beet ls -a 'added:-1w..'
233+
234+
And here's an example that lists items added in a two-week period starting
235+
four weeks ago::
236+
237+
$ beet ls 'added:-6w..-4w'
238+
220239
.. _not_query:
221240

222241
Query Term Negation

test/test_datequery.py

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from __future__ import division, absolute_import, print_function
1919

2020
from test import _common
21-
from datetime import datetime
21+
from datetime import datetime, timedelta
2222
import unittest
2323
import time
2424
from beets.dbcore.query import _parse_periods, DateInterval, DateQuery,\
@@ -29,6 +29,10 @@ def _date(string):
2929
return datetime.strptime(string, '%Y-%m-%dT%H:%M:%S')
3030

3131

32+
def _datepattern(datetimedate):
33+
return datetimedate.strftime('%Y-%m-%dT%H:%M:%S')
34+
35+
3236
class DateIntervalTest(unittest.TestCase):
3337
def test_year_precision_intervals(self):
3438
self.assertContains('2000..2001', '2000-01-01T00:00:00')
@@ -44,6 +48,9 @@ def test_year_precision_intervals(self):
4448
self.assertContains('..2001', '2001-12-31T23:59:59')
4549
self.assertExcludes('..2001', '2002-01-01T00:00:00')
4650

51+
self.assertContains('-1d..1d', _datepattern(datetime.now()))
52+
self.assertExcludes('-2d..-1d', _datepattern(datetime.now()))
53+
4754
def test_day_precision_intervals(self):
4855
self.assertContains('2000-06-20..2000-06-20', '2000-06-20T00:00:00')
4956
self.assertContains('2000-06-20..2000-06-20', '2000-06-20T10:20:30')
@@ -161,6 +168,87 @@ def test_single_day_nonmatch_fast(self):
161168
self.assertEqual(len(matched), 0)
162169

163170

171+
class DateQueryTestRelative(_common.LibTestCase):
172+
def setUp(self):
173+
super(DateQueryTestRelative, self).setUp()
174+
self.i.added = _parsetime(datetime.now().strftime('%Y-%m-%d %H:%M'))
175+
self.i.store()
176+
177+
def test_single_month_match_fast(self):
178+
query = DateQuery('added', datetime.now().strftime('%Y-%m'))
179+
matched = self.lib.items(query)
180+
self.assertEqual(len(matched), 1)
181+
182+
def test_single_month_nonmatch_fast(self):
183+
query = DateQuery('added', (datetime.now() + timedelta(days=30))
184+
.strftime('%Y-%m'))
185+
matched = self.lib.items(query)
186+
self.assertEqual(len(matched), 0)
187+
188+
def test_single_month_match_slow(self):
189+
query = DateQuery('added', datetime.now().strftime('%Y-%m'))
190+
self.assertTrue(query.match(self.i))
191+
192+
def test_single_month_nonmatch_slow(self):
193+
query = DateQuery('added', (datetime.now() + timedelta(days=30))
194+
.strftime('%Y-%m'))
195+
self.assertFalse(query.match(self.i))
196+
197+
def test_single_day_match_fast(self):
198+
query = DateQuery('added', datetime.now().strftime('%Y-%m-%d'))
199+
matched = self.lib.items(query)
200+
self.assertEqual(len(matched), 1)
201+
202+
def test_single_day_nonmatch_fast(self):
203+
query = DateQuery('added', (datetime.now() + timedelta(days=1))
204+
.strftime('%Y-%m-%d'))
205+
matched = self.lib.items(query)
206+
self.assertEqual(len(matched), 0)
207+
208+
209+
class DateQueryTestRelativeMore(_common.LibTestCase):
210+
def setUp(self):
211+
super(DateQueryTestRelativeMore, self).setUp()
212+
self.i.added = _parsetime(datetime.now().strftime('%Y-%m-%d %H:%M'))
213+
self.i.store()
214+
215+
def test_relative(self):
216+
for timespan in ['d', 'w', 'm', 'y']:
217+
query = DateQuery('added', '-4' + timespan + '..+4' + timespan)
218+
matched = self.lib.items(query)
219+
self.assertEqual(len(matched), 1)
220+
221+
def test_relative_fail(self):
222+
for timespan in ['d', 'w', 'm', 'y']:
223+
query = DateQuery('added', '-2' + timespan + '..-1' + timespan)
224+
matched = self.lib.items(query)
225+
self.assertEqual(len(matched), 0)
226+
227+
def test_start_relative(self):
228+
for timespan in ['d', 'w', 'm', 'y']:
229+
query = DateQuery('added', '-4' + timespan + '..')
230+
matched = self.lib.items(query)
231+
self.assertEqual(len(matched), 1)
232+
233+
def test_start_relative_fail(self):
234+
for timespan in ['d', 'w', 'm', 'y']:
235+
query = DateQuery('added', '4' + timespan + '..')
236+
matched = self.lib.items(query)
237+
self.assertEqual(len(matched), 0)
238+
239+
def test_end_relative(self):
240+
for timespan in ['d', 'w', 'm', 'y']:
241+
query = DateQuery('added', '..+4' + timespan)
242+
matched = self.lib.items(query)
243+
self.assertEqual(len(matched), 1)
244+
245+
def test_end_relative_fail(self):
246+
for timespan in ['d', 'w', 'm', 'y']:
247+
query = DateQuery('added', '..-4' + timespan)
248+
matched = self.lib.items(query)
249+
self.assertEqual(len(matched), 0)
250+
251+
164252
class DateQueryConstructTest(unittest.TestCase):
165253
def test_long_numbers(self):
166254
with self.assertRaises(InvalidQueryArgumentValueError):

0 commit comments

Comments
 (0)