diff --git a/beets/dbcore/query.py b/beets/dbcore/query.py index 51790b9fa..e90077c5f 100644 --- a/beets/dbcore/query.py +++ b/beets/dbcore/query.py @@ -533,6 +533,7 @@ class Period(object): instants of time during January 2014. """ + precisions = ('year', 'month', 'day', 'hour', 'minute', 'second') date_formats = ( ('%Y',), # year @@ -542,6 +543,8 @@ 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 = {'y': 365, 'm': 30, 'w': 7, 'd': 1} + def __init__(self, date, precision): """Create a period with the given date (a `datetime` object) and @@ -555,9 +558,21 @@ class Period(object): @classmethod def parse(cls, string): - """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. + """Parse a date and return a `Period` object or `None` if the + string is empty. + Depending on the string, the date can be absolute or + relative. + An absolute date has to be like one of the date_formats '%Y' or '%Y-%m' + or '%Y-%m-%d' + A relative date consists of three parts: + - a ``+`` or ``-`` sign is optional and defaults to ``+``. The ``+`` + sign will add a time quantity to the current date while the ``-`` sign + will do the opposite + - a number follows and indicates the amount to add or substract + - a final letter ends and represents the amount in either days, weeks, + months or years (``d``, ``w``, ``m`` or ``y``) + Please note that this relative calculation makes the assumption of 30 + days per month and 365 days per year. """ def find_date_and_format(string): @@ -573,6 +588,20 @@ class Period(object): if not string: return None + + pattern_dq = '(?P[+|-]?)(?P[0-9]+)(?P[y|m|w|d])' # noqa: E501 + match_dq = re.match(pattern_dq, string) + # test if the string matches the relative date pattern, add the parsed + # quantity to now in that case + if match_dq is not None: + sign = match_dq.group('sign') + quantity = match_dq.group('quantity') + timespan = match_dq.group('timespan') + multiplier = -1 if sign == '-' else 1 + days = cls.relative[timespan] + date = datetime.now() + multiplier * timedelta(days=int(quantity) * days) + string = date.strftime(cls.date_formats[5][0]) + date, ordinal = find_date_and_format(string) if date is None: raise InvalidQueryArgumentValueError(string, @@ -586,6 +615,8 @@ class Period(object): """ precision = self.precision date = self.date + if 'relative' == self.precision: + return date if 'year' == self.precision: return date.replace(year=date.year + 1, month=1) elif 'month' == precision: @@ -647,6 +678,7 @@ class DateQuery(FieldQuery): The value of a date field can be matched against a date interval by using an ellipsis interval syntax similar to that of NumericQuery. """ + def __init__(self, field, pattern, fast=True): super(DateQuery, self).__init__(field, pattern, fast) start, end = _parse_periods(pattern) @@ -690,6 +722,7 @@ class DurationQuery(NumericQuery): Raises InvalidQueryError when the pattern does not represent an int, float or M:SS time interval. """ + def _convert(self, s): """Convert a M:SS or numeric string to a float. diff --git a/docs/reference/query.rst b/docs/reference/query.rst index 112c1966d..fa1286c8c 100644 --- a/docs/reference/query.rst +++ b/docs/reference/query.rst @@ -164,6 +164,29 @@ Dates are written separated by hyphens, like ``year-month-day``, but the month and day are optional. If you leave out the day, for example, you will get matches for the whole month. +You can also use relative dates to the current time. +A relative date begins with an ``@``. +It looks like ``@-3w``, ``@2m`` or ``@-4d`` which means the date 3 weeks ago, +the date 2 months from now and the date 4 days ago. +A relative date consists of three parts: +- ``+`` or ``-`` sign is optional and defaults to ``+``. The ``+`` sign will +add a time quantity to the current date while the ``-`` sign will do the +opposite +- a number follows and indicates the amount to add or substract +- a final letter ends and represents the amount in either days, weeks, months or +years (``d``, ``w``, ``m`` or ``y``) + +Please note that this relative calculation makes the assumption of 30 days per +month and 365 days per year. + +Here is an example that finds all the albums added between now and last week:: + + $ beet ls -a 'added:-1w..' + +Find all items added in a 2 weeks period 4 weeks ago:: + + $ beet ls -a 'added:-6w..-4w' + Date *intervals*, like the numeric intervals described above, are separated by two dots (``..``). You can specify a start, an end, or both. diff --git a/test/test_datequery.py b/test/test_datequery.py index ba8857084..7b7776711 100644 --- a/test/test_datequery.py +++ b/test/test_datequery.py @@ -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):