Merge lp:~izidor/gtg/bug-993931 into lp:~gtg/gtg/old-trunk
- bug-993931
- Merge into 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 |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Bertrand Rousseau (community) | Approve | ||
Review via email: mp+104571@code.launchpad.net |
Commit message
Description of the change
- Added support for due:dd
- pylint likes GTG/tools/dates.py and GTG/tests/
To post a comment you must log in.
Revision history for this message
Bertrand Rousseau (bertrand-rousseau) wrote : | # |
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) |
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.