← Back to team overview

gtg team mailing list archive

[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)