GTG

Merge lp:~izidor/gtg/bug-993931 into lp:~gtg/gtg/old-trunk

Proposed by Izidor Matušov
Status: Merged
Merged at revision: 1178
Proposed branch: lp:~izidor/gtg/bug-993931
Merge into: lp:~gtg/gtg/old-trunk
Diff against target: 472 lines (+192/-78)
3 files modified
CHANGELOG (+1/-0)
GTG/tests/test_dates.py (+42/-7)
GTG/tools/dates.py (+149/-71)
To merge this branch: bzr merge lp:~izidor/gtg/bug-993931
Reviewer Review Type Date Requested Status
Bertrand Rousseau (community) Approve
Review via email: mp+104571@code.launchpad.net

Description of the change

- Added support for due:dd
- pylint likes GTG/tools/dates.py and GTG/tests/test_dates.py now

To post a comment you must log in.
Revision history for this message
Bertrand Rousseau (bertrand-rousseau) wrote :

Seems fine for me, could only spot two tiny things:

 - There's a print statement in GTG/tests/test_dates.py, line 86: "print repr(Date.parse("0101"))" is it intended for the test?
 - in dates.py, "comparsion" variable is mispelled.

review: Needs Information
lp:~izidor/gtg/bug-993931 updated
1179. By Izidor Matušov

Solving small issues, thanks to Bertrand for noticing them

Revision history for this message
Izidor Matušov (izidor) wrote :

I've updated those small details, thansk for catching them!

Revision history for this message
Bertrand Rousseau (bertrand-rousseau) wrote :

Ok, good to go!

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'CHANGELOG'
2--- CHANGELOG 2012-05-02 09:09:23 +0000
3+++ CHANGELOG 2012-05-05 04:37:21 +0000
4@@ -1,6 +1,7 @@
5 2012-0?-?? Getting Things GNOME! 0.3
6 * Hide tasks with due date someday, #931376
7 * New Date class by Paul Kishimoto and Izidor Matušov
8+ * Parse due:3 as next 3rd day in month
9 * Urgency Color plugin by Wolter Hellmund
10 * Allow GTG to run from different locations than /usr/share or /usr/local/share
11 * Removed module GTG.tools.openurl and replaced by python's webbrowser.open
12
13=== modified file 'GTG/tests/test_dates.py'
14--- GTG/tests/test_dates.py 2012-03-30 12:44:08 +0000
15+++ GTG/tests/test_dates.py 2012-05-05 04:37:21 +0000
16@@ -27,12 +27,26 @@
17 from GTG import _
18 from GTG.tools.dates import Date
19
20-class TestDates(unittest.TestCase):
21- '''
22- Tests for the various Date classes
23- '''
24+
25+def next_month(aday, day=None):
26+ """ Increase month, change 2012-02-13 into 2012-03-13.
27+ If day is set, replace day in month as well
28+
29+ @return updated date """
30+ if day is None:
31+ day = aday.day
32+
33+ if aday.month == 12:
34+ return aday.replace(day=day, month=1, year=aday.year + 1)
35+ else:
36+ return aday.replace(day=day, month=aday.month + 1)
37+
38+
39+class TestDates(unittest.TestCase): # pylint: disable-msg=R0904
40+ """ Tests for the various Date classes """
41
42 def test_parse_dates(self):
43+ """ Parse common numeric date """
44 self.assertEqual(str(Date.parse("1985-03-29")), "1985-03-29")
45 self.assertEqual(str(Date.parse("19850329")), "1985-03-29")
46 self.assertEqual(str(Date.parse("1985/03/29")), "1985-03-29")
47@@ -42,6 +56,7 @@
48 self.assertEqual(Date.parse(parse_string), today)
49
50 def test_parse_fuzzy_dates(self):
51+ """ Parse fuzzy dates like now, soon, later, someday """
52 self.assertEqual(Date.parse("now"), Date.now())
53 self.assertEqual(Date.parse("soon"), Date.soon())
54 self.assertEqual(Date.parse("later"), Date.someday())
55@@ -49,6 +64,7 @@
56 self.assertEqual(Date.parse(""), Date.no_date())
57
58 def test_parse_local_fuzzy_dates(self):
59+ """ Parse fuzzy dates in their localized version """
60 self.assertEqual(Date.parse(_("now")), Date.now())
61 self.assertEqual(Date.parse(_("soon")), Date.soon())
62 self.assertEqual(Date.parse(_("later")), Date.someday())
63@@ -56,6 +72,7 @@
64 self.assertEqual(Date.parse(""), Date.no_date())
65
66 def test_parse_fuzzy_dates_str(self):
67+ """ Print fuzzy dates in localized version """
68 self.assertEqual(str(Date.parse("now")), _("now"))
69 self.assertEqual(str(Date.parse("soon")), _("soon"))
70 self.assertEqual(str(Date.parse("later")), _("someday"))
71@@ -63,8 +80,9 @@
72 self.assertEqual(str(Date.parse("")), "")
73
74 def test_parse_week_days(self):
75+ """ Parse name of week days and don't care about case-sensitivity """
76 weekday = date.today().weekday()
77- for i, day in enumerate(['Monday', 'Tuesday', 'Wednesday',
78+ for i, day in enumerate(['Monday', 'Tuesday', 'Wednesday',
79 'Thursday', 'Friday', 'Saturday', 'Sunday']):
80 if i <= weekday:
81 expected = date.today() + timedelta(7+i-weekday)
82@@ -98,10 +116,27 @@
83 return
84
85 aday = aday.replace(year=aday.year+1, month=1, day=1)
86-
87 self.assertEqual(Date.parse("0101"), aday)
88
89+ def test_on_certain_day(self):
90+ """ Parse due:3 as 3rd day this month or next month
91+ if it is already more or already 3rd day """
92+ for i in range(28):
93+ i += 1
94+ aday = date.today()
95+ if i <= aday.day:
96+ aday = next_month(aday, i)
97+ else:
98+ aday = aday.replace(day=i)
99+
100+ self.assertEqual(Date.parse(str(i)), aday)
101+
102+ def test_prevent_regression(self):
103+ """ A day represented in GTG Date must be still the same """
104+ aday = date.today()
105+ self.assertEqual(Date(aday), aday)
106+
107
108 def test_suite():
109+ """ Return unittests """
110 return unittest.TestLoader().loadTestsFromTestCase(TestDates)
111-
112
113=== modified file 'GTG/tools/dates.py'
114--- GTG/tools/dates.py 2012-03-30 12:44:08 +0000
115+++ GTG/tools/dates.py 2012-05-05 04:37:21 +0000
116@@ -17,6 +17,13 @@
117 # this program. If not, see <http://www.gnu.org/licenses/>.
118 # -----------------------------------------------------------------------------
119
120+""" General class for representing dates in GTG.
121+
122+Dates Could be normal like 2012-04-01 or fuzzy like now, soon,
123+someday, later or no date.
124+
125+Date.parse() parses all possible representations of a date. """
126+
127 import calendar
128 import datetime
129 import locale
130@@ -54,48 +61,57 @@
131 }
132 # functions giving absolute dates for fuzzy dates + no date
133 FUNCS = {
134- NOW: lambda: datetime.date.today(),
135- SOON: lambda: datetime.date.today() + datetime.timedelta(15),
136- SOMEDAY: lambda: datetime.date.max,
137- NODATE: lambda: datetime.date.max - datetime.timedelta(1),
138+ NOW: datetime.date.today(),
139+ SOON: datetime.date.today() + datetime.timedelta(15),
140+ SOMEDAY: datetime.date.max,
141+ NODATE: datetime.date.max - datetime.timedelta(1),
142 }
143
144 # ISO 8601 date format
145 ISODATE = '%Y-%m-%d'
146
147-def convert_datetime_to_date(dt):
148- return datetime.date(dt.year, dt.month, dt.day)
149+
150+def convert_datetime_to_date(aday):
151+ """ Convert python's datetime to date.
152+ Strip unusable time information. """
153+ return datetime.date(aday.year, aday.month, aday.day)
154+
155
156 class Date(object):
157 """A date class that supports fuzzy dates.
158-
159+
160 Date supports all the methods of the standard datetime.date class. A Date
161 can be constructed with:
162 * the fuzzy strings 'now', 'soon', '' (no date, default), or 'someday'
163 * a string containing an ISO format date: YYYY-MM-DD, or
164 * a datetime.date or Date instance.
165-
166+
167 """
168 _real_date = None
169 _fuzzy = None
170
171 def __init__(self, value=''):
172+ self._parse_init_value(value)
173+
174+ def _parse_init_value(self, value):
175+ """ Parse many possible values and setup date """
176 if value is None:
177- self.__init__(NODATE)
178+ self._parse_init_value(NODATE)
179 elif isinstance(value, datetime.date):
180 self._real_date = value
181 elif isinstance(value, Date):
182- self._real_date = value._real_date
183- self._fuzzy = value._fuzzy
184+ # Copy internal values from other Date object, make pylint silent
185+ self._real_date = value._real_date # pylint: disable-msg=W0212
186+ self._fuzzy = value._fuzzy # pylint: disable-msg=W0212
187 elif isinstance(value, str) or isinstance(value, unicode):
188 try:
189- dt = datetime.datetime.strptime(value, ISODATE).date()
190- self._real_date = convert_datetime_to_date(dt)
191+ da_ti = datetime.datetime.strptime(value, ISODATE).date()
192+ self._real_date = convert_datetime_to_date(da_ti)
193 except ValueError:
194 # it must be a fuzzy date
195 try:
196 value = str(value.lower())
197- self.__init__(LOOKUP[value])
198+ self._parse_init_value(LOOKUP[value])
199 except KeyError:
200 raise ValueError("Unknown value for date: '%s'" % value)
201 elif isinstance(value, int):
202@@ -103,46 +119,47 @@
203 else:
204 raise ValueError("Unknown value for date: '%s'" % value)
205
206- def _date(self):
207+ def date(self):
208+ """ Map date into real date, i.e. convert fuzzy dates """
209 if self.is_fuzzy():
210- return FUNCS[self._fuzzy]()
211+ return FUNCS[self._fuzzy]
212 else:
213 return self._real_date
214
215 def __add__(self, other):
216 if isinstance(other, datetime.timedelta):
217- return Date(self._date() + other)
218+ return Date(self.date() + other)
219 else:
220 raise NotImplementedError
221 __radd__ = __add__
222
223 def __sub__(self, other):
224- if hasattr(other, '_date'):
225- return self._date() - other._date()
226+ if hasattr(other, 'date'):
227+ return self.date() - other.date()
228 else:
229- return self._date() - other
230+ return self.date() - other
231
232 def __rsub__(self, other):
233- if hasattr(other, '_date'):
234- return other._date() - self._date()
235+ if hasattr(other, 'date'):
236+ return other.date() - self.date()
237 else:
238- return other - self._date()
239+ return other - self.date()
240
241 def __cmp__(self, other):
242 """ Compare with other Date instance """
243 if isinstance(other, Date):
244- c = cmp(self._date(), other._date())
245+ comparison = cmp(self.date(), other.date())
246
247 # Keep fuzzy dates below normal dates
248- if c == 0:
249+ if comparison == 0:
250 if self.is_fuzzy() and not other.is_fuzzy():
251 return 1
252 elif not self.is_fuzzy() and other.is_fuzzy():
253 return -1
254
255- return c
256+ return comparison
257 elif isinstance(other, datetime.date):
258- return cmp(self._date(), other)
259+ return cmp(self.date(), other)
260 else:
261 raise NotImplementedError
262
263@@ -170,7 +187,7 @@
264 try:
265 return self.__dict__[name]
266 except KeyError:
267- return getattr(self._date(), name)
268+ return getattr(self.date(), name)
269
270 def is_fuzzy(self):
271 """ True if the Date is one of the fuzzy values """
272@@ -181,56 +198,95 @@
273 if self._fuzzy == NODATE:
274 return None
275 else:
276- return (self._date() - datetime.date.today()).days
277+ return (self.date() - datetime.date.today()).days
278
279 @classmethod
280 def today(cls):
281+ """ Return date for today """
282 return Date(datetime.date.today())
283
284 @classmethod
285 def tomorrow(cls):
286+ """ Return date for tomorrow """
287 return Date(datetime.date.today() + datetime.timedelta(1))
288
289 @classmethod
290 def now(cls):
291+ """ Return date representing fuzzy date now """
292 return Date(NOW)
293
294 @classmethod
295 def no_date(cls):
296+ """ Return date representing no (set) date """
297 return Date(NODATE)
298
299 @classmethod
300 def soon(cls):
301+ """ Return date representing fuzzy date soon """
302 return Date(SOON)
303
304 @classmethod
305 def someday(cls):
306+ """ Return date representing fuzzy date someday """
307 return Date(SOMEDAY)
308
309 @classmethod
310- def parse(cls, string):
311- """Return a Date corresponding to string, or None.
312-
313- string may be in one of the following formats:
314- * YYYY/MM/DD, YYYYMMDD, MMDD
315- * fuzzy dates
316- * 'today', 'tomorrow', 'next week', 'next month' or 'next year' in
317- English or the system locale.
318- """
319- # sanitize input
320- if string is None:
321- string = ''
322- else:
323- string = string.lower()
324-
325- # try the default formats
326- try:
327- return Date(string)
328- except ValueError:
329- pass
330-
331+ def _parse_only_month_day(cls, string):
332+ """ Parse next Xth day in month """
333+ try:
334+ mday = int(string)
335+ if not 1 <= mday <= 31 or string.startswith('0'):
336+ return None
337+ except ValueError:
338+ return None
339+
340+ today = datetime.date.today()
341+ try:
342+ result = today.replace(day=mday)
343+ except ValueError:
344+ result = None
345+
346+ if result is None or result <= today:
347+ if today.month == 12:
348+ next_month = 1
349+ next_year = today.year + 1
350+ else:
351+ next_month = today.month + 1
352+ next_year = today.year
353+
354+ try:
355+ result = datetime.date(next_year, next_month, mday)
356+ except ValueError:
357+ pass
358+
359+ return result
360+
361+ @classmethod
362+ def _parse_numerical_format(cls, string):
363+ """ Parse numerical formats like %Y/%m/%d, %Y%m%d or %m%d """
364 result = None
365 today = datetime.date.today()
366+ for fmt in ['%Y/%m/%d', '%Y%m%d', '%m%d']:
367+ try:
368+ da_ti = datetime.datetime.strptime(string, fmt)
369+ result = convert_datetime_to_date(da_ti)
370+ if '%Y' not in fmt:
371+ # If the day has passed, assume the next year
372+ if result.month > today.month or \
373+ (result.month == today.month and
374+ result.day >= today.day):
375+ year = today.year
376+ else:
377+ year = today.year +1
378+ result = result.replace(year=year)
379+ except ValueError:
380+ continue
381+ return result
382+
383+ @classmethod
384+ def _parse_text_representation(cls, string):
385+ """ Match common text representation for date """
386+ today = datetime.date.today()
387
388 # accepted date formats
389 formats = {
390@@ -260,32 +316,54 @@
391 formats[english.lower()] = offset
392 formats[local.lower()] = offset
393
394- # attempt to parse the string with known formats
395- for fmt in ['%Y/%m/%d', '%Y%m%d', '%m%d']:
396- try:
397- dt = datetime.datetime.strptime(string, fmt)
398- result = convert_datetime_to_date(dt)
399- if '%Y' not in fmt:
400- # If the day has passed, assume the next year
401- if result.month > today.month or \
402- (result.month == today.month and result.day >= today.day):
403- year = today.year
404- else:
405- year = today.year +1
406- result = result.replace(year=year)
407- except ValueError:
408- continue
409-
410 offset = formats.get(string, None)
411- if result is None and offset is not None:
412- result = today + datetime.timedelta(offset)
413-
414+ if offset is None:
415+ return None
416+ else:
417+ return today + datetime.timedelta(offset)
418+
419+ @classmethod
420+ def parse(cls, string):
421+ """Return a Date corresponding to string, or None.
422+
423+ string may be in one of the following formats:
424+ * YYYY/MM/DD, YYYYMMDD, MMDD, D
425+ * fuzzy dates
426+ * 'today', 'tomorrow', 'next week', 'next month' or 'next year' in
427+ English or the system locale.
428+ """
429+ # sanitize input
430+ if string is None:
431+ string = ''
432+ else:
433+ string = string.lower()
434+
435+ # try the default formats
436+ try:
437+ return Date(string)
438+ except ValueError:
439+ pass
440+
441+ # do several parsing
442+ result = cls._parse_only_month_day(string)
443+ if result is None:
444+ result = cls._parse_numerical_format(string)
445+ if result is None:
446+ result = cls._parse_text_representation(string)
447+
448+ # Announce the result
449 if result is not None:
450 return Date(result)
451 else:
452 raise ValueError("Can't parse date '%s'" % string)
453
454 def to_readable_string(self):
455+ """ Return nice representation of date.
456+
457+ Fuzzy dates => localized version
458+ Close dates => Today, Tomorrow, In X days
459+ Other => with locale dateformat, stripping year for this year
460+ """
461 if self._fuzzy is not None:
462 return STRINGS[self._fuzzy]
463
464@@ -307,6 +385,6 @@
465 year_len = 365
466 if float(days_left) / year_len < 1.0:
467 #if it's in less than a year, don't show the year field
468- locale_format = locale_format.replace('/%Y','')
469- locale_format = locale_format.replace('.%Y','.')
470+ locale_format = locale_format.replace('/%Y', '')
471+ locale_format = locale_format.replace('.%Y', '.')
472 return self._real_date.strftime(locale_format)

Subscribers

People subscribed via source and target branches

to status/vote changes: