From a27d83a4bf998d3d1614492b3e3b61b185d0f7cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stig=20Inge=20Lea=20Bj=C3=B8rnsen?= Date: Mon, 3 Feb 2014 18:21:23 +0100 Subject: [PATCH] Refactor the date query from being a plugin to being part of Beets core. Fields of the type DateType will now automatically be queried by DateQuery. --- beets/dbcore/query.py | 137 +++++++++++++++++++++++++++++++++++ beets/library.py | 2 +- beetsplug/datequery.py | 157 ----------------------------------------- test/test_datequery.py | 2 +- 4 files changed, 139 insertions(+), 159 deletions(-) delete mode 100644 beetsplug/datequery.py diff --git a/beets/dbcore/query.py b/beets/dbcore/query.py index 74f6cb903..e95ae14ed 100644 --- a/beets/dbcore/query.py +++ b/beets/dbcore/query.py @@ -16,6 +16,7 @@ """ import re from beets import util +from datetime import datetime, timedelta class Query(object): @@ -322,3 +323,139 @@ class FalseQuery(Query): def match(self, item): return False + +def _to_epoch_time(date): + epoch = datetime.utcfromtimestamp(0) + return int((date - epoch).total_seconds()) + +def _parse_periods(pattern): + """Parse two Periods separated by '..' + """ + parts = pattern.split('..', 1) + if len(parts) == 1: + instant = Period.parse(parts[0]) + return (instant, instant) + else: + start = Period.parse(parts[0]) + end = Period.parse(parts[1]) + return (start, end) + +class Period(object): + """A period of time given by a date, time and precision. + + Example: + 2014-01-01 10:50:30 with precision 'month' represent all instants of time + during January 2014. + """ + + precisions = ('year', 'month', 'day') + date_formats = ('%Y', '%Y-%m', '%Y-%m-%d') + + def __init__(self, date, precision): + if precision not in Period.precisions: + raise ValueError('Invalid precision ' + str(precision)) + self.date = date + self.precision = precision + + @classmethod + def parse(cls, string): + """Parse a date into a period. + """ + if not string: return None + ordinal = string.count('-') + if ordinal >= len(cls.date_formats): + raise ValueError('Date is not in one of the formats ' + + ', '.join(cls.date_formats)) + date_format = cls.date_formats[ordinal] + date = datetime.strptime(string, date_format) + precision = cls.precisions[ordinal] + return cls(date, precision) + + def open_right_endpoint(self): + """Based on the precision, convert the period to a precise datetime + for use as a right endpoint in a right-open interval. + """ + precision = self.precision + date = self.date + if 'year' == self.precision: + return date.replace(year=date.year + 1, month=1) + elif 'month' == precision: + if (date.month < 12): + return date.replace(month=date.month + 1) + else: + return date.replace(year=date.year + 1, month=1) + elif 'day' == precision: + return date + timedelta(days=1) + else: + raise ValueError('Unhandled precision ' + str(precision)) + +class DateInterval(object): + """A closed-open interval of dates. + + A left endpoint of None means since the beginning of time. + A right endpoint of None means towards infinity. + """ + + def __init__(self, start, end): + if start is not None and end is not None and not start < end: + raise ValueError("Start date {} is not before end date {}" + .format(start, end)) + self.start = start + self.end = end + + @classmethod + def from_periods(cls, start, end): + """Create an interval with two Periods as the endpoints. + """ + end_date = end.open_right_endpoint() if end is not None else None + start_date = start.date if start is not None else None + return cls(start_date, end_date) + + def contains(self, date): + if self.start is not None and date < self.start: + return False + if self.end is not None and date >= self.end: + return False + return True + + def __str__(self): + return'[{}, {})'.format(self.start, self.end) + +class DateQuery(FieldQuery): + """Matches date fields stored as seconds since Unix epoch time. + + Dates can be specified as year-month-day where only year is mandatory. + + The value of a date field can be matched against a date interval by using + an ellipses 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) + self.interval = DateInterval.from_periods(start, end) + + def match(self, item): + timestamp = float(item[self.field]) + date = datetime.utcfromtimestamp(timestamp) + return self.interval.contains(date) + + def col_clause(self): + if self.interval.start is not None and self.interval.end is not None: + start_epoch_time = _to_epoch_time(self.interval.start) + end_epoch_time = _to_epoch_time(self.interval.end) + template = ("date({}, 'unixepoch') >= date(?, 'unixepoch')" + " AND date({}, 'unixepoch') < date(?, 'unixepoch')") + clause = template.format(self.field, self.field) + return (clause, (start_epoch_time, end_epoch_time)) + elif self.interval.start is not None: + epoch_time = _to_epoch_time(self.interval.start) + template = "date({}, 'unixepoch') >= date(?, 'unixepoch')" + clause = template.format(self.field) + return clause.format(self.field), (epoch_time,) + elif self.interval.end is not None: + epoch_time = _to_epoch_time(self.interval.end) + template = "date({}, 'unixepoch') < date(?, 'unixepoch')" + clause = template.format(self.field) + return clause.format(self.field), (epoch_time,) + else: + return '1 = ?', (1,) # match any date diff --git a/beets/library.py b/beets/library.py index a66e1df78..d5b07c3e7 100644 --- a/beets/library.py +++ b/beets/library.py @@ -79,7 +79,7 @@ class SingletonQuery(dbcore.Query): class DateType(types.Type): sql = u'REAL' - query = dbcore.query.NumericQuery + query = dbcore.query.DateQuery def format(self, value): return time.strftime(beets.config['time_format'].get(unicode), diff --git a/beetsplug/datequery.py b/beetsplug/datequery.py deleted file mode 100644 index 8bdcfe8a2..000000000 --- a/beetsplug/datequery.py +++ /dev/null @@ -1,157 +0,0 @@ -"""Matches date fields stored as seconds since Unix epoch time. - -Dates can be specified as year-month-day where only year is mandatory. - -The value of a date field can be matched against a date interval by using an -ellipses interval syntax similar to that of NumericQuery. -""" - -from __future__ import unicode_literals, absolute_import, print_function -from beets.plugins import BeetsPlugin -from beets.dbcore import FieldQuery -from datetime import datetime, timedelta -from beets.library import ITEM_FIELDS, DateType - -_DATE_FIELDS = [fieldname for (fieldname, typedef, _, _) - in ITEM_FIELDS if isinstance(typedef, DateType) ] - -def _queryable(fieldname): - """Determine whether a field can by queried as a date. - """ - return fieldname in _DATE_FIELDS - -def _to_epoch_time(date): - epoch = datetime.utcfromtimestamp(0) - return int((date - epoch).total_seconds()) - -def _parse_periods(pattern): - """Parse two Periods separated by '..' - """ - parts = pattern.split('..', 1) - if len(parts) == 1: - instant = Period.parse(parts[0]) - return (instant, instant) - else: - start = Period.parse(parts[0]) - end = Period.parse(parts[1]) - return (start, end) - -class Period(object): - """A period of time given by a date, time and precision. - - Example: - 2014-01-01 10:50:30 with precision 'month' represent all instants of time - during January 2014. - """ - - precisions = ('year', 'month', 'day') - date_formats = ('%Y', '%Y-%m', '%Y-%m-%d') - - def __init__(self, date, precision): - if precision not in Period.precisions: - raise ValueError('Invalid precision ' + str(precision)) - self.date = date - self.precision = precision - - @classmethod - def parse(cls, string): - """Parse a date into a period. - """ - if not string: return None - ordinal = string.count('-') - if ordinal >= len(cls.date_formats): - raise ValueError('Date is not in one of the formats ' - + ', '.join(cls.date_formats)) - date_format = cls.date_formats[ordinal] - date = datetime.strptime(string, date_format) - precision = cls.precisions[ordinal] - return cls(date, precision) - - def open_right_endpoint(self): - """Based on the precision, convert the period to a precise datetime - for use as a right endpoint in a right-open interval. - """ - precision = self.precision - date = self.date - if 'year' == self.precision: - return date.replace(year=date.year + 1, month=1) - elif 'month' == precision: - if (date.month < 12): - return date.replace(month=date.month + 1) - else: - return date.replace(year=date.year + 1, month=1) - elif 'day' == precision: - return date + timedelta(days=1) - else: - raise ValueError('Unhandled precision ' + str(precision)) - -class DateInterval(object): - """A closed-open interval of dates. - - A left endpoint of None means since the beginning of time. - A right endpoint of None means towards infinity. - """ - - def __init__(self, start, end): - if start is not None and end is not None and not start < end: - raise ValueError("Start date {} is not before end date {}" - .format(start, end)) - self.start = start - self.end = end - - @classmethod - def from_periods(cls, start, end): - """Create an interval with two Periods as the endpoints. - """ - end_date = end.open_right_endpoint() if end is not None else None - start_date = start.date if start is not None else None - return cls(start_date, end_date) - - def contains(self, date): - if self.start is not None and date < self.start: - return False - if self.end is not None and date >= self.end: - return False - return True - - def __str__(self): - return'[{}, {})'.format(self.start, self.end) - -class DateQuery(FieldQuery): - def __init__(self, field, pattern, fast=True): - super(DateQuery, self).__init__(field, pattern, fast) - if not _queryable(field): - raise ValueError('Field {} cannot be queried as a date'.format(field)) - - (start, end) = _parse_periods(pattern) - self.interval = DateInterval.from_periods(start, end) - - def match(self, item): - timestamp = float(item[self.field]) - date = datetime.utcfromtimestamp(timestamp) - return self.interval.contains(date) - - def col_clause(self): - if self.interval.start is not None and self.interval.end is not None: - start_epoch_time = _to_epoch_time(self.interval.start) - end_epoch_time = _to_epoch_time(self.interval.end) - template = ("date({}, 'unixepoch') >= date(?, 'unixepoch')" - " AND date({}, 'unixepoch') < date(?, 'unixepoch')") - clause = template.format(self.field, self.field) - return (clause, (start_epoch_time, end_epoch_time)) - elif self.interval.start is not None: - epoch_time = _to_epoch_time(self.interval.start) - template = "date({}, 'unixepoch') >= date(?, 'unixepoch')" - clause = template.format(self.field) - return clause.format(self.field), (epoch_time,) - elif self.interval.end is not None: - epoch_time = _to_epoch_time(self.interval.end) - template = "date({}, 'unixepoch') < date(?, 'unixepoch')" - clause = template.format(self.field) - return clause.format(self.field), (epoch_time,) - else: - return '1 = ?', (1,) # match any date - -class DateQueryPlugin(BeetsPlugin): - def queries(self): - return {'T': DateQuery} diff --git a/test/test_datequery.py b/test/test_datequery.py index 906c822a0..b23fae7c5 100644 --- a/test/test_datequery.py +++ b/test/test_datequery.py @@ -1,6 +1,6 @@ import unittest from datetime import datetime -from beetsplug.datequery import _parse_periods, DateInterval +from beets.dbcore.query import _parse_periods, DateInterval def _date(string): return datetime.strptime(string, '%Y-%m-%dT%H:%M:%S')