Merge pull request #2598 from beetbox/relativedate

Relative date queries (continuation of #2418)
This commit is contained in:
Adrian Sampson 2017-06-16 11:48:56 -04:00 committed by GitHub
commit d31a48345f
4 changed files with 145 additions and 4 deletions

View file

@ -562,6 +562,9 @@ class Period(object):
('%Y-%m-%dT%H:%M', '%Y-%m-%d %H:%M'), # minute
('%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M:%S') # second
)
relative_units = {'y': 365, 'm': 30, 'w': 7, 'd': 1}
relative_re = '(?P<sign>[+|-]?)(?P<quantity>[0-9]+)' + \
'(?P<timespan>[y|m|w|d])'
def __init__(self, date, precision):
"""Create a period with the given date (a `datetime` object) and
@ -575,9 +578,20 @@ class Period(object):
@classmethod
def parse(cls, string):
"""Parse a date and return a `Period` object, or `None` if the
"""Parse a date and return a `Period` object or `None` if the
string is empty, or raise an InvalidQueryArgumentValueError if
the string could not be parsed to a date.
the string cannot be parsed to a date.
The date may be absolute or relative. Absolute dates look like
`YYYY`, or `YYYY-MM-DD`, or `YYYY-MM-DD HH:MM:SS`, etc. Relative
dates have three parts:
- Optionally, a ``+`` or ``-`` sign indicating the future or the
past. The default is the future.
- A number: how much to add or subtract.
- A letter indicating the unit: days, weeks, months or years
(``d``, ``w``, ``m`` or ``y``). A "month" is exactly 30 days
and a "year" is exactly 365 days.
"""
def find_date_and_format(string):
@ -593,10 +607,27 @@ class Period(object):
if not string:
return None
# Check for a relative date.
match_dq = re.match(cls.relative_re, string)
if match_dq:
sign = match_dq.group('sign')
quantity = match_dq.group('quantity')
timespan = match_dq.group('timespan')
# Add or subtract the given amount of time from the current
# date.
multiplier = -1 if sign == '-' else 1
days = cls.relative_units[timespan]
date = datetime.now() + \
timedelta(days=int(quantity) * days) * multiplier
return cls(date, cls.precisions[5])
# Check for an absolute date.
date, ordinal = find_date_and_format(string)
if date is None:
raise InvalidQueryArgumentValueError(string,
'a valid datetime string')
'a valid date/time string')
precision = cls.precisions[ordinal]
return cls(date, precision)

View file

@ -13,6 +13,9 @@ Features:
* :ref:`Date queries <datequery>` can now include times, so you can filter
your music down to the second. Thanks to :user:`discopatrick`. :bug:`2506`
:bug:`2528`
* :ref:`Date queries <datequery>` can also be *relative*. You can say
``added:-1w..`` to match music added in the last week, for example. Thanks
to :user:`euri10`. :bug:`2598`
* A new :doc:`/plugins/gmusic` lets you interact with your Google Play Music
library. Thanks to :user:`tigranl`. :bug:`2553` :bug:`2586`
* :doc:`/plugins/replaygain`: We now keep R128 data in separate tags from

View file

@ -217,6 +217,25 @@ queries do the same thing::
$ beet ls 'added:2008-12-01t22:45:20'
$ beet ls 'added:2008-12-01 22:45:20'
You can also use *relative* dates. For example, ``-3w`` means three weeks ago,
and ``+4d`` means four days in the future. A relative date has three parts:
- Either ``+`` or ``-``, to indicate the past or the future. The sign is
optional; if you leave this off, it defaults to the future.
- A number.
- A letter indicating the unit: ``d``, ``w``, ``m`` or ``y``, meaning days,
weeks, months or years. (A "month" is always 30 days and a "year" is always
365 days.)
Here's an example that finds all the albums added since last week::
$ beet ls -a 'added:-1w..'
And here's an example that lists items added in a two-week period starting
four weeks ago::
$ beet ls 'added:-6w..-4w'
.. _not_query:
Query Term Negation

View file

@ -18,7 +18,7 @@
from __future__ import division, absolute_import, print_function
from test import _common
from datetime import datetime
from datetime import datetime, timedelta
import unittest
import time
from beets.dbcore.query import _parse_periods, DateInterval, DateQuery,\
@ -29,6 +29,10 @@ def _date(string):
return datetime.strptime(string, '%Y-%m-%dT%H:%M:%S')
def _datepattern(datetimedate):
return datetimedate.strftime('%Y-%m-%dT%H:%M:%S')
class DateIntervalTest(unittest.TestCase):
def test_year_precision_intervals(self):
self.assertContains('2000..2001', '2000-01-01T00:00:00')
@ -44,6 +48,9 @@ class DateIntervalTest(unittest.TestCase):
self.assertContains('..2001', '2001-12-31T23:59:59')
self.assertExcludes('..2001', '2002-01-01T00:00:00')
self.assertContains('-1d..1d', _datepattern(datetime.now()))
self.assertExcludes('-2d..-1d', _datepattern(datetime.now()))
def test_day_precision_intervals(self):
self.assertContains('2000-06-20..2000-06-20', '2000-06-20T00:00:00')
self.assertContains('2000-06-20..2000-06-20', '2000-06-20T10:20:30')
@ -161,6 +168,87 @@ class DateQueryTest(_common.LibTestCase):
self.assertEqual(len(matched), 0)
class DateQueryTestRelative(_common.LibTestCase):
def setUp(self):
super(DateQueryTestRelative, self).setUp()
self.i.added = _parsetime(datetime.now().strftime('%Y-%m-%d %H:%M'))
self.i.store()
def test_single_month_match_fast(self):
query = DateQuery('added', datetime.now().strftime('%Y-%m'))
matched = self.lib.items(query)
self.assertEqual(len(matched), 1)
def test_single_month_nonmatch_fast(self):
query = DateQuery('added', (datetime.now() + timedelta(days=30))
.strftime('%Y-%m'))
matched = self.lib.items(query)
self.assertEqual(len(matched), 0)
def test_single_month_match_slow(self):
query = DateQuery('added', datetime.now().strftime('%Y-%m'))
self.assertTrue(query.match(self.i))
def test_single_month_nonmatch_slow(self):
query = DateQuery('added', (datetime.now() + timedelta(days=30))
.strftime('%Y-%m'))
self.assertFalse(query.match(self.i))
def test_single_day_match_fast(self):
query = DateQuery('added', datetime.now().strftime('%Y-%m-%d'))
matched = self.lib.items(query)
self.assertEqual(len(matched), 1)
def test_single_day_nonmatch_fast(self):
query = DateQuery('added', (datetime.now() + timedelta(days=1))
.strftime('%Y-%m-%d'))
matched = self.lib.items(query)
self.assertEqual(len(matched), 0)
class DateQueryTestRelativeMore(_common.LibTestCase):
def setUp(self):
super(DateQueryTestRelativeMore, self).setUp()
self.i.added = _parsetime(datetime.now().strftime('%Y-%m-%d %H:%M'))
self.i.store()
def test_relative(self):
for timespan in ['d', 'w', 'm', 'y']:
query = DateQuery('added', '-4' + timespan + '..+4' + timespan)
matched = self.lib.items(query)
self.assertEqual(len(matched), 1)
def test_relative_fail(self):
for timespan in ['d', 'w', 'm', 'y']:
query = DateQuery('added', '-2' + timespan + '..-1' + timespan)
matched = self.lib.items(query)
self.assertEqual(len(matched), 0)
def test_start_relative(self):
for timespan in ['d', 'w', 'm', 'y']:
query = DateQuery('added', '-4' + timespan + '..')
matched = self.lib.items(query)
self.assertEqual(len(matched), 1)
def test_start_relative_fail(self):
for timespan in ['d', 'w', 'm', 'y']:
query = DateQuery('added', '4' + timespan + '..')
matched = self.lib.items(query)
self.assertEqual(len(matched), 0)
def test_end_relative(self):
for timespan in ['d', 'w', 'm', 'y']:
query = DateQuery('added', '..+4' + timespan)
matched = self.lib.items(query)
self.assertEqual(len(matched), 1)
def test_end_relative_fail(self):
for timespan in ['d', 'w', 'm', 'y']:
query = DateQuery('added', '..-4' + timespan)
matched = self.lib.items(query)
self.assertEqual(len(matched), 0)
class DateQueryConstructTest(unittest.TestCase):
def test_long_numbers(self):
with self.assertRaises(InvalidQueryArgumentValueError):