gtg team mailing list archive
-
gtg team
-
Mailing list archive
-
Message #03506
[Merge] lp:~izidor/gtg/bug-993931 into lp:gtg
Izidor Matušov has proposed merging lp:~izidor/gtg/bug-993931 into lp:gtg.
Requested reviews:
Gtg developers (gtg)
Related bugs:
Bug #993931 in Getting Things GNOME!: "Crash when giving non-canonical date to due:"
https://bugs.launchpad.net/gtg/+bug/993931
For more details, see:
https://code.launchpad.net/~izidor/gtg/bug-993931/+merge/104571
- Added support for due:dd
- pylint likes GTG/tools/dates.py and GTG/tests/test_dates.py now
--
https://code.launchpad.net/~izidor/gtg/bug-993931/+merge/104571
Your team Gtg developers is requested to review the proposed merge of lp:~izidor/gtg/bug-993931 into lp:gtg.
=== modified file 'CHANGELOG'
--- CHANGELOG 2012-04-22 15:16:24 +0000
+++ CHANGELOG 2012-05-03 15:13:22 +0000
@@ -1,6 +1,7 @@
2012-0?-?? Getting Things GNOME! 0.3
* Hide tasks with due date someday, #931376
* New Date class by Paul Kishimoto and Izidor Matušov
+ * Parse due:3 as next 3rd day in month
* Urgency Color plugin by Wolter Hellmund
* Allow GTG to run from different locations than /usr/share or /usr/local/share
* Removed module GTG.tools.openurl and replaced by python's webbrowser.open
=== modified file 'GTG/tests/test_dates.py'
--- GTG/tests/test_dates.py 2012-03-30 12:44:08 +0000
+++ GTG/tests/test_dates.py 2012-05-03 15:13:22 +0000
@@ -27,12 +27,26 @@
from GTG import _
from GTG.tools.dates import Date
-class TestDates(unittest.TestCase):
- '''
- Tests for the various Date classes
- '''
+
+def next_month(aday, day=None):
+ """ Increase month, change 2012-02-13 into 2012-03-13.
+ If day is set, replace day in month as well
+
+ @return updated date """
+ if day is None:
+ day = aday.day
+
+ if aday.month == 12:
+ return aday.replace(day=day, month=1, year=aday.year + 1)
+ else:
+ return aday.replace(day=day, month=aday.month + 1)
+
+
+class TestDates(unittest.TestCase): # pylint: disable-msg=R0904
+ """ Tests for the various Date classes """
def test_parse_dates(self):
+ """ Parse common numeric date """
self.assertEqual(str(Date.parse("1985-03-29")), "1985-03-29")
self.assertEqual(str(Date.parse("19850329")), "1985-03-29")
self.assertEqual(str(Date.parse("1985/03/29")), "1985-03-29")
@@ -42,6 +56,7 @@
self.assertEqual(Date.parse(parse_string), today)
def test_parse_fuzzy_dates(self):
+ """ Parse fuzzy dates like now, soon, later, someday """
self.assertEqual(Date.parse("now"), Date.now())
self.assertEqual(Date.parse("soon"), Date.soon())
self.assertEqual(Date.parse("later"), Date.someday())
@@ -49,6 +64,7 @@
self.assertEqual(Date.parse(""), Date.no_date())
def test_parse_local_fuzzy_dates(self):
+ """ Parse fuzzy dates in their localized version """
self.assertEqual(Date.parse(_("now")), Date.now())
self.assertEqual(Date.parse(_("soon")), Date.soon())
self.assertEqual(Date.parse(_("later")), Date.someday())
@@ -56,6 +72,7 @@
self.assertEqual(Date.parse(""), Date.no_date())
def test_parse_fuzzy_dates_str(self):
+ """ Print fuzzy dates in localized version """
self.assertEqual(str(Date.parse("now")), _("now"))
self.assertEqual(str(Date.parse("soon")), _("soon"))
self.assertEqual(str(Date.parse("later")), _("someday"))
@@ -63,8 +80,9 @@
self.assertEqual(str(Date.parse("")), "")
def test_parse_week_days(self):
+ """ Parse name of week days and don't care about case-sensitivity """
weekday = date.today().weekday()
- for i, day in enumerate(['Monday', 'Tuesday', 'Wednesday',
+ for i, day in enumerate(['Monday', 'Tuesday', 'Wednesday',
'Thursday', 'Friday', 'Saturday', 'Sunday']):
if i <= weekday:
expected = date.today() + timedelta(7+i-weekday)
@@ -99,9 +117,28 @@
aday = aday.replace(year=aday.year+1, month=1, day=1)
+ print repr(Date.parse("0101"))
self.assertEqual(Date.parse("0101"), aday)
+ def test_on_certain_day(self):
+ """ Parse due:3 as 3rd day this month or next month
+ if it is already more or already 3rd day """
+ for i in range(28):
+ i += 1
+ aday = date.today()
+ if i <= aday.day:
+ aday = next_month(aday, i)
+ else:
+ aday = aday.replace(day=i)
+
+ self.assertEqual(Date.parse(str(i)), aday)
+
+ def test_prevent_regression(self):
+ """ A day represented in GTG Date must be still the same """
+ aday = date.today()
+ self.assertEqual(Date(aday), aday)
+
def test_suite():
+ """ Return unittests """
return unittest.TestLoader().loadTestsFromTestCase(TestDates)
-
=== modified file 'GTG/tools/dates.py'
--- GTG/tools/dates.py 2012-03-30 12:44:08 +0000
+++ GTG/tools/dates.py 2012-05-03 15:13:22 +0000
@@ -17,6 +17,13 @@
# this program. If not, see <http://www.gnu.org/licenses/>.
# -----------------------------------------------------------------------------
+""" General class for representing dates in GTG.
+
+Dates Could be normal like 2012-04-01 or fuzzy like now, soon,
+someday, later or no date.
+
+Date.parse() parses all possible representations of a date. """
+
import calendar
import datetime
import locale
@@ -54,48 +61,57 @@
}
# functions giving absolute dates for fuzzy dates + no date
FUNCS = {
- NOW: lambda: datetime.date.today(),
- SOON: lambda: datetime.date.today() + datetime.timedelta(15),
- SOMEDAY: lambda: datetime.date.max,
- NODATE: lambda: datetime.date.max - datetime.timedelta(1),
+ NOW: datetime.date.today(),
+ SOON: datetime.date.today() + datetime.timedelta(15),
+ SOMEDAY: datetime.date.max,
+ NODATE: datetime.date.max - datetime.timedelta(1),
}
# ISO 8601 date format
ISODATE = '%Y-%m-%d'
-def convert_datetime_to_date(dt):
- return datetime.date(dt.year, dt.month, dt.day)
+
+def convert_datetime_to_date(aday):
+ """ Convert python's datetime to date.
+ Strip unusable time information. """
+ return datetime.date(aday.year, aday.month, aday.day)
+
class Date(object):
"""A date class that supports fuzzy dates.
-
+
Date supports all the methods of the standard datetime.date class. A Date
can be constructed with:
* the fuzzy strings 'now', 'soon', '' (no date, default), or 'someday'
* a string containing an ISO format date: YYYY-MM-DD, or
* a datetime.date or Date instance.
-
+
"""
_real_date = None
_fuzzy = None
def __init__(self, value=''):
+ self._parse_init_value(value)
+
+ def _parse_init_value(self, value):
+ """ Parse many possible values and setup date """
if value is None:
- self.__init__(NODATE)
+ self._parse_init_value(NODATE)
elif isinstance(value, datetime.date):
self._real_date = value
elif isinstance(value, Date):
- self._real_date = value._real_date
- self._fuzzy = value._fuzzy
+ # Copy internal values from other Date object, make pylint silent
+ self._real_date = value._real_date # pylint: disable-msg=W0212
+ self._fuzzy = value._fuzzy # pylint: disable-msg=W0212
elif isinstance(value, str) or isinstance(value, unicode):
try:
- dt = datetime.datetime.strptime(value, ISODATE).date()
- self._real_date = convert_datetime_to_date(dt)
+ da_ti = datetime.datetime.strptime(value, ISODATE).date()
+ self._real_date = convert_datetime_to_date(da_ti)
except ValueError:
# it must be a fuzzy date
try:
value = str(value.lower())
- self.__init__(LOOKUP[value])
+ self._parse_init_value(LOOKUP[value])
except KeyError:
raise ValueError("Unknown value for date: '%s'" % value)
elif isinstance(value, int):
@@ -103,46 +119,47 @@
else:
raise ValueError("Unknown value for date: '%s'" % value)
- def _date(self):
+ def date(self):
+ """ Map date into real date, i.e. convert fuzzy dates """
if self.is_fuzzy():
- return FUNCS[self._fuzzy]()
+ return FUNCS[self._fuzzy]
else:
return self._real_date
def __add__(self, other):
if isinstance(other, datetime.timedelta):
- return Date(self._date() + other)
+ return Date(self.date() + other)
else:
raise NotImplementedError
__radd__ = __add__
def __sub__(self, other):
- if hasattr(other, '_date'):
- return self._date() - other._date()
+ if hasattr(other, 'date'):
+ return self.date() - other.date()
else:
- return self._date() - other
+ return self.date() - other
def __rsub__(self, other):
- if hasattr(other, '_date'):
- return other._date() - self._date()
+ if hasattr(other, 'date'):
+ return other.date() - self.date()
else:
- return other - self._date()
+ return other - self.date()
def __cmp__(self, other):
""" Compare with other Date instance """
if isinstance(other, Date):
- c = cmp(self._date(), other._date())
+ comparsion = cmp(self.date(), other.date())
# Keep fuzzy dates below normal dates
- if c == 0:
+ if comparsion == 0:
if self.is_fuzzy() and not other.is_fuzzy():
return 1
elif not self.is_fuzzy() and other.is_fuzzy():
return -1
- return c
+ return comparsion
elif isinstance(other, datetime.date):
- return cmp(self._date(), other)
+ return cmp(self.date(), other)
else:
raise NotImplementedError
@@ -170,7 +187,7 @@
try:
return self.__dict__[name]
except KeyError:
- return getattr(self._date(), name)
+ return getattr(self.date(), name)
def is_fuzzy(self):
""" True if the Date is one of the fuzzy values """
@@ -181,56 +198,95 @@
if self._fuzzy == NODATE:
return None
else:
- return (self._date() - datetime.date.today()).days
+ return (self.date() - datetime.date.today()).days
@classmethod
def today(cls):
+ """ Return date for today """
return Date(datetime.date.today())
@classmethod
def tomorrow(cls):
+ """ Return date for tomorrow """
return Date(datetime.date.today() + datetime.timedelta(1))
@classmethod
def now(cls):
+ """ Return date representing fuzzy date now """
return Date(NOW)
@classmethod
def no_date(cls):
+ """ Return date representing no (set) date """
return Date(NODATE)
@classmethod
def soon(cls):
+ """ Return date representing fuzzy date soon """
return Date(SOON)
@classmethod
def someday(cls):
+ """ Return date representing fuzzy date someday """
return Date(SOMEDAY)
@classmethod
- def parse(cls, string):
- """Return a Date corresponding to string, or None.
-
- string may be in one of the following formats:
- * YYYY/MM/DD, YYYYMMDD, MMDD
- * fuzzy dates
- * 'today', 'tomorrow', 'next week', 'next month' or 'next year' in
- English or the system locale.
- """
- # sanitize input
- if string is None:
- string = ''
- else:
- string = string.lower()
-
- # try the default formats
- try:
- return Date(string)
- except ValueError:
- pass
-
+ def _parse_only_month_day(cls, string):
+ """ Parse next Xth day in month """
+ try:
+ mday = int(string)
+ if not 1 <= mday <= 31 or string.startswith('0'):
+ return None
+ except ValueError:
+ return None
+
+ today = datetime.date.today()
+ try:
+ result = today.replace(day=mday)
+ except ValueError:
+ result = None
+
+ if result is None or result <= today:
+ if today.month == 12:
+ next_month = 1
+ next_year = today.year + 1
+ else:
+ next_month = today.month + 1
+ next_year = today.year
+
+ try:
+ result = datetime.date(next_year, next_month, mday)
+ except ValueError:
+ pass
+
+ return result
+
+ @classmethod
+ def _parse_numerical_format(cls, string):
+ """ Parse numerical formats like %Y/%m/%d, %Y%m%d or %m%d """
result = None
today = datetime.date.today()
+ for fmt in ['%Y/%m/%d', '%Y%m%d', '%m%d']:
+ try:
+ da_ti = datetime.datetime.strptime(string, fmt)
+ result = convert_datetime_to_date(da_ti)
+ if '%Y' not in fmt:
+ # If the day has passed, assume the next year
+ if result.month > today.month or \
+ (result.month == today.month and
+ result.day >= today.day):
+ year = today.year
+ else:
+ year = today.year +1
+ result = result.replace(year=year)
+ except ValueError:
+ continue
+ return result
+
+ @classmethod
+ def _parse_text_representation(cls, string):
+ """ Match common text representation for date """
+ today = datetime.date.today()
# accepted date formats
formats = {
@@ -260,32 +316,54 @@
formats[english.lower()] = offset
formats[local.lower()] = offset
- # attempt to parse the string with known formats
- for fmt in ['%Y/%m/%d', '%Y%m%d', '%m%d']:
- try:
- dt = datetime.datetime.strptime(string, fmt)
- result = convert_datetime_to_date(dt)
- if '%Y' not in fmt:
- # If the day has passed, assume the next year
- if result.month > today.month or \
- (result.month == today.month and result.day >= today.day):
- year = today.year
- else:
- year = today.year +1
- result = result.replace(year=year)
- except ValueError:
- continue
-
offset = formats.get(string, None)
- if result is None and offset is not None:
- result = today + datetime.timedelta(offset)
-
+ if offset is None:
+ return None
+ else:
+ return today + datetime.timedelta(offset)
+
+ @classmethod
+ def parse(cls, string):
+ """Return a Date corresponding to string, or None.
+
+ string may be in one of the following formats:
+ * YYYY/MM/DD, YYYYMMDD, MMDD, D
+ * fuzzy dates
+ * 'today', 'tomorrow', 'next week', 'next month' or 'next year' in
+ English or the system locale.
+ """
+ # sanitize input
+ if string is None:
+ string = ''
+ else:
+ string = string.lower()
+
+ # try the default formats
+ try:
+ return Date(string)
+ except ValueError:
+ pass
+
+ # do several parsing
+ result = cls._parse_only_month_day(string)
+ if result is None:
+ result = cls._parse_numerical_format(string)
+ if result is None:
+ result = cls._parse_text_representation(string)
+
+ # Announce the result
if result is not None:
return Date(result)
else:
raise ValueError("Can't parse date '%s'" % string)
def to_readable_string(self):
+ """ Return nice representation of date.
+
+ Fuzzy dates => localized version
+ Close dates => Today, Tomorrow, In X days
+ Other => with locale dateformat, stripping year for this year
+ """
if self._fuzzy is not None:
return STRINGS[self._fuzzy]
@@ -307,6 +385,6 @@
year_len = 365
if float(days_left) / year_len < 1.0:
#if it's in less than a year, don't show the year field
- locale_format = locale_format.replace('/%Y','')
- locale_format = locale_format.replace('.%Y','.')
+ locale_format = locale_format.replace('/%Y', '')
+ locale_format = locale_format.replace('.%Y', '.')
return self._real_date.strftime(locale_format)