1from natural.language import _, _multi
2import datetime
3import math
4import six
5
6
7# Wed, 02 Oct 2002 08:00:00 EST
8# Wed, 02 Oct 2002 13:00:00 GMT
9# Wed, 02 Oct 2002 15:00:00 +0200
10RFC2822_DATETIME_FORMAT = r'%a, %d %b %Y %T %z'
11# Wed, 02 Oct 02 08:00:00 EST
12# Wed, 02 Oct 02 13:00:00 GMT
13# Wed, 02 Oct 02 15:00:00 +0200
14RFC822_DATETIME_FORMAT = r'%a, %d %b %y %T %z'
15# 2012-06-13T15:24:17
16ISO8601_DATETIME_FORMAT = r'%Y-%m-%dT%H:%M:%S'
17# Wed, 02 Oct 2002
18RFC2822_DATE_FORMAT = r'%a, %d %b %Y'
19# Wed, 02 Oct 02
20RFC822_DATE_FORMAT = r'%a, %d %b %y'
21# 2012-06-13
22ISO8601_DATE_FORMAT = r'%Y-%m-%d'
23# All date formats
24ALL_DATE_FORMATS = (
25    RFC2822_DATE_FORMAT,
26    RFC822_DATE_FORMAT,
27    ISO8601_DATE_FORMAT,
28)
29ALL_DATETIME_FORMATS = ALL_DATE_FORMATS + (
30    RFC822_DATETIME_FORMAT,
31    ISO8601_DATETIME_FORMAT,
32)
33
34# Precalculated timestamps
35TIME_MINUTE = 60
36TIME_HOUR = 3600
37TIME_DAY = 86400
38TIME_WEEK = 604800
39
40
41def _total_seconds(t):
42    '''
43    Takes a `datetime.timedelta` object and returns the delta in seconds.
44
45    >>> _total_seconds(datetime.timedelta(23, 42, 123456))
46    1987242
47    >>> _total_seconds(datetime.timedelta(23, 42, 654321))
48    1987243
49    '''
50    return sum([
51        int(t.days * 86400 + t.seconds),
52        int(round(t.microseconds / 1000000.0))
53    ])
54
55
56def _to_datetime(t):
57    '''
58    Internal function that tries whatever to convert ``t`` into a
59    :class:`datetime.datetime` object.
60
61
62    >>> _to_datetime('2013-12-11')
63    datetime.datetime(2013, 12, 11, 0, 0)
64    >>> _to_datetime('Wed, 11 Dec 2013')
65    datetime.datetime(2013, 12, 11, 0, 0)
66    >>> _to_datetime('Wed, 11 Dec 13')
67    datetime.datetime(2013, 12, 11, 0, 0)
68    >>> _to_datetime('2012-06-13T15:24:17')
69    datetime.datetime(2012, 6, 13, 15, 24, 17)
70
71    '''
72
73    if isinstance(t, six.integer_types + (float, )):
74        return datetime.datetime.fromtimestamp(t).replace(microsecond=0)
75
76    elif isinstance(t, six.string_types):
77        for date_format in ALL_DATETIME_FORMATS:
78            try:
79                d = datetime.datetime.strptime(t, date_format)
80                return d.replace(microsecond=0)
81            except ValueError:
82                pass
83
84        raise ValueError(_('Format "%s" not supported') % t)
85
86    elif isinstance(t, datetime.datetime):
87        return t.replace(microsecond=0)
88
89    elif isinstance(t, datetime.date):
90        d = datetime.datetime.combine(t, datetime.time(0, 0))
91        return d.replace(microsecond=0)
92
93    else:
94        raise TypeError
95
96
97def _to_date(t):
98    '''
99    Internal function that tries whatever to convert ``t`` into a
100    :class:`datetime.date` object.
101
102    >>> _to_date('2013-12-11')
103    datetime.date(2013, 12, 11)
104    >>> _to_date('Wed, 11 Dec 2013')
105    datetime.date(2013, 12, 11)
106    >>> _to_date('Wed, 11 Dec 13')
107    datetime.date(2013, 12, 11)
108    '''
109
110    if isinstance(t, six.integer_types + (float, )):
111        return datetime.date.fromtimestamp(t)
112
113    elif isinstance(t, six.string_types):
114        for date_format in ALL_DATE_FORMATS:
115            try:
116                return datetime.datetime.strptime(t, date_format).date()
117            except ValueError:
118                pass
119
120        raise ValueError('Format not supported')
121
122    elif isinstance(t, datetime.datetime):
123        return t.date()
124
125    elif isinstance(t, datetime.date):
126        return t
127
128    else:
129        raise TypeError
130
131
132def delta(t1, t2, words=True, justnow=datetime.timedelta(seconds=10)):
133    '''
134    Calculates the estimated delta between two time objects in human-readable
135    format. Used internally by the :func:`day` and :func:`duration` functions.
136
137    :param t1: timestamp, :class:`datetime.date` or :class:`datetime.datetime`
138               object
139    :param t2: timestamp, :class:`datetime.date` or :class:`datetime.datetime`
140               object
141    :param words: default ``True``, allow words like "yesterday", "tomorrow"
142               and "just now"
143    :param justnow: default ``datetime.timedelta(seconds=10)``,
144               :class:`datetime.timedelta` object representing tolerance for
145               considering a delta as meaning 'just now'
146
147    >>> delta(_to_datetime('2012-06-13T15:24:17'), \
148_to_datetime('2013-12-11T12:34:56'))
149    ('77 weeks', -594639)
150    '''
151
152    t1 = _to_datetime(t1)
153    t2 = _to_datetime(t2)
154    diff = t1 - t2
155    date_diff = t1.date() - t2.date()
156
157    # The datetime module includes milliseconds with float precision. Floats
158    # will give unexpected results here, so we round the value here
159    total = math.ceil(_total_seconds(diff))
160    total_abs = abs(total)
161
162    if total_abs < TIME_DAY:
163        if abs(diff) < justnow and words:
164            return (
165                _('just now'),
166                0,
167            )
168
169        elif total_abs < TIME_MINUTE:
170            seconds = total_abs
171            return (
172                _multi(
173                    _('%d second'),
174                    _('%d seconds'),
175                    seconds
176                ) % (seconds,),
177                0,
178            )
179        elif total_abs < TIME_MINUTE * 2 and words:
180            return (
181                _('a minute'),
182                0,
183            )
184
185        elif total_abs < TIME_HOUR:
186            minutes, seconds = divmod(total_abs, TIME_MINUTE)
187            if total < 0:
188                seconds *= -1
189            return (
190                _multi(
191                    _('%d minute'),
192                    _('%d minutes'),
193                    minutes
194                ) % (minutes,),
195                seconds,
196            )
197
198        elif total_abs < TIME_HOUR * 2 and words:
199            return (
200                _('an hour'),
201                0,
202            )
203
204        else:
205            hours, seconds = divmod(total_abs, TIME_HOUR)
206            if total < 0:
207                seconds *= -1
208
209            return (
210                _multi(
211                    _('%d hour'),
212                    _('%d hours'),
213                    hours
214                ) % (hours,),
215                seconds,
216            )
217
218    elif date_diff.days == 1 and words:
219        return (_('tomorrow'), 0)
220
221    elif date_diff.days == -1 and words:
222        return (_('yesterday'), 0)
223
224    elif total_abs < TIME_WEEK:
225        days, seconds = divmod(total_abs, TIME_DAY)
226        if total < 0:
227            seconds *= -1
228        return (
229            _multi(
230                _('%d day'),
231                _('%d days'),
232                days
233            ) % (days,),
234            seconds,
235        )
236
237    elif abs(diff.days) == TIME_WEEK and words:
238        if total > 0:
239            return (_('next week'), diff.seconds)
240        else:
241            return (_('last week'), diff.seconds)
242
243# FIXME
244#
245# The biggest reliable unit we can supply to the user is a week (for now?),
246# because we can not safely determine the amount of days in the covered
247# month/year span.
248
249    else:
250        weeks, seconds = divmod(total_abs, TIME_WEEK)
251        if total < 0:
252            seconds *= -1
253        return (
254            _multi(
255                _('%d week'),
256                _('%d weeks'),
257                weeks
258            ) % (weeks,),
259            seconds,
260        )
261
262
263def day(t, now=None, format='%B %d'):
264    '''
265    Date delta compared to ``t``. You can override ``now`` to specify what date
266    to compare to.
267
268    You can override the date format by supplying a ``format`` parameter.
269
270    :param t: timestamp, :class:`datetime.date` or :class:`datetime.datetime`
271              object
272    :param now: default ``None``, optionally a :class:`datetime.datetime`
273                object
274    :param format: default ``'%B %d'``
275
276    >>> import time
277    >>> day(time.time())
278    'today'
279    >>> day(time.time() - 86400)
280    'yesterday'
281    >>> day(time.time() - 604800)
282    'last week'
283    >>> day(time.time() + 86400)
284    'tomorrow'
285    >>> day(time.time() + 604800)
286    'next week'
287    '''
288    t1 = _to_date(t)
289    t2 = _to_date(now or datetime.datetime.now())
290    diff = t1 - t2
291    secs = _total_seconds(diff)
292    days = abs(diff.days)
293
294    if days == 0:
295        return _('today')
296    elif days == 1:
297        if secs < 0:
298            return _('yesterday')
299        else:
300            return _('tomorrow')
301    elif days == 7:
302        if secs < 0:
303            return _('last week')
304        else:
305            return _('next week')
306    else:
307        return t1.strftime(format)
308
309
310def duration(t, now=None, precision=1, pad=', ', words=None,
311             justnow=datetime.timedelta(seconds=10)):
312    '''
313    Time delta compared to ``t``. You can override ``now`` to specify what time
314    to compare to.
315
316    :param t: timestamp, :class:`datetime.date` or :class:`datetime.datetime`
317              object
318    :param now: default ``None``, optionally a :class:`datetime.datetime`
319                object
320    :param precision: default ``1``, number of fragments to return
321    :param words: default ``None``, allow words like "yesterday", if set to
322                  ``None`` this will be enabled if ``precision`` is set to
323                  ``1``
324    :param justnow: default ``datetime.timedelta(seconds=10)``,
325                    :class:`datetime.timedelta` object passed to :func:`delta`
326                    representing tolerance for considering argument ``t`` as
327                    meaning 'just now'
328
329    >>> import time
330    >>> from datetime import datetime
331    >>> duration(time.time() + 1)
332    'just now'
333    >>> duration(time.time() + 11)
334    '11 seconds from now'
335    >>> duration(time.time() - 1)
336    'just now'
337    >>> duration(time.time() - 11)
338    '11 seconds ago'
339    >>> duration(time.time() - 3601)
340    'an hour ago'
341    >>> duration(time.time() - 7201)
342    '2 hours ago'
343    >>> duration(time.time() - 1234567)
344    '2 weeks ago'
345    >>> duration(time.time() + 7200, precision=1)
346    '2 hours from now'
347    >>> duration(time.time() - 1234567, precision=3)
348    '2 weeks, 6 hours, 56 minutes ago'
349    >>> duration(datetime(2014, 9, 8), now=datetime(2014, 9, 9))
350    'yesterday'
351    >>> duration(datetime(2014, 9, 7, 23), now=datetime(2014, 9, 9))
352    '1 day ago'
353    >>> duration(datetime(2014, 9, 10), now=datetime(2014, 9, 9))
354    'tomorrow'
355    >>> duration(datetime(2014, 9, 11, 1), now=datetime(2014, 9, 9, 23))
356    '1 day from now'
357    '''
358
359    if words is None:
360        words = precision == 1
361
362    t1 = _to_datetime(t)
363    t2 = _to_datetime(now or datetime.datetime.now())
364
365    if t1 < t2:
366        format = _('%s ago')
367    else:
368        format = _('%s from now')
369
370    result, remains = delta(t1, t2, words=words, justnow=justnow)
371    if result in (
372            _('just now'),
373            _('yesterday'),
374            _('tomorrow'),
375            _('last week'),
376            _('next week'),
377    ):
378        return result
379
380    elif precision > 1 and remains:
381        t3 = t2 - datetime.timedelta(seconds=remains)
382        return pad.join([
383            result,
384            duration(t2, t3, precision - 1, pad, words=False),
385        ])
386
387    else:
388        return format % (result,)
389
390
391def compress(t, sign=False, pad=''):
392    '''
393    Convert the input to compressed format, works with a
394    :class:`datetime.timedelta` object or a number that represents the number
395    of seconds you want to compress.  If you supply a timestamp or a
396    :class:`datetime.datetime` object, it will give the delta relative to the
397    current time.
398
399    You can enable showing a sign in front of the compressed format with the
400    ``sign`` parameter, the default is not to show signs.
401
402    Optionally, you can chose to pad the output. If you wish your values to be
403    separated by spaces, set ``pad`` to ``' '``.
404
405    :param t: seconds or :class:`datetime.timedelta` object
406    :param sign: default ``False``
407    :param pad: default ``''``
408
409    >>> compress(1)
410    '1s'
411    >>> compress(12)
412    '12s'
413    >>> compress(123)
414    '2m3s'
415    >>> compress(1234)
416    '20m34s'
417    >>> compress(12345)
418    '3h25m45s'
419    >>> compress(123456)
420    '1d10h17m36s'
421
422    '''
423
424    if isinstance(t, datetime.timedelta):
425        seconds = t.seconds + (t.days * 86400)
426    elif isinstance(t, six.integer_types + (float, )):
427        return compress(datetime.timedelta(seconds=t), sign, pad)
428    else:
429        return compress(datetime.datetime.now() - _to_datetime(t), sign, pad)
430
431    parts = []
432    if sign:
433        parts.append('-' if t.days < 0 else '+')
434
435    weeks, seconds = divmod(seconds, TIME_WEEK)
436    days, seconds = divmod(seconds, TIME_DAY)
437    hours, seconds = divmod(seconds, TIME_HOUR)
438    minutes, seconds = divmod(seconds, TIME_MINUTE)
439
440    if weeks:
441        parts.append(_('%dw') % (weeks,))
442    if days:
443        parts.append(_('%dd') % (days,))
444    if hours:
445        parts.append(_('%dh') % (hours,))
446    if minutes:
447        parts.append(_('%dm') % (minutes,))
448    if seconds:
449        parts.append(_('%ds') % (seconds,))
450
451    return pad.join(parts)
452