Repository: crsmithdev/arrow
Pull request #388 (2016-12-05 23:21:18)

Humanizegranularity
New feature allowing to select the granularity of the humanize function. We can receive for example 65 minutes ago rather than 1 hour ago.


Patch:
diff --git a/arrow/arrow.py b/arrow/arrow.py
index cda5536..440dcab 100644
--- a/arrow/arrow.py
+++ b/arrow/arrow.py
@@ -10,6 +10,7 @@ from __future__ import absolute_import
 from datetime import datetime, timedelta, tzinfo
 from dateutil import tz as dateutil_tz
 from dateutil.relativedelta import relativedelta
+from math import trunc
 import calendar
 import sys
 import warnings
@@ -116,7 +117,7 @@ class Arrow(object):
         dt = datetime.fromtimestamp(timestamp, tzinfo)
 
         return cls(dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second,
-            dt.microsecond, tzinfo)
+            dt.microsecond, dt.tzinfo)
 
     @classmethod
     def utcfromtimestamp(cls, timestamp):
@@ -312,6 +313,49 @@ class Arrow(object):
         _range = cls.range(frame, start, end, tz, limit)
         return [r.span(frame) for r in _range]
 
+    @classmethod
+    def interval(cls, frame, start, end, interval=1, tz=None):
+        ''' Returns an array of tuples, each :class:`Arrow <arrow.arrow.Arrow>` objects,
+        representing a series of intervals between two inputs.
+
+        :param frame: the timeframe.  Can be any ``datetime`` property (day, hour, minute...).
+        :param start: A datetime expression, the start of the range.
+        :param end: (optional) A datetime expression, the end of the range.
+        :param interval: (optional) Time interval for the given time frame.
+        :param tz: (optional) A timezone expression.  Defaults to UTC.
+        
+        Supported frame values: year, quarter, month, week, day, hour, minute, second
+
+        Recognized datetime expressions:
+
+            - An :class:`Arrow <arrow.arrow.Arrow>` object.
+            - A ``datetime`` object.
+
+        Recognized timezone expressions:
+
+            - A ``tzinfo`` object.
+            - A ``str`` describing a timezone, similar to 'US/Pacific', or 'Europe/Berlin'.
+            - A ``str`` in ISO-8601 style, as in '+07:00'.
+            - A ``str``, one of the following:  'local', 'utc', 'UTC'.
+
+        Usage:
+
+            >>> start = datetime(2013, 5, 5, 12, 30)
+            >>> end = datetime(2013, 5, 5, 17, 15)
+            >>> for r in arrow.Arrow.interval('hour', start, end, 2):
+            ...     print r
+            ...
+            (<Arrow [2013-05-05T12:00:00+00:00]>, <Arrow [2013-05-05T13:59:59.999999+00:00]>)
+            (<Arrow [2013-05-05T14:00:00+00:00]>, <Arrow [2013-05-05T15:59:59.999999+00:00]>)
+            (<Arrow [2013-05-05T16:00:00+00:00]>, <Arrow [2013-05-05T17:59:59.999999+00:0]>)
+        '''
+        if interval < 1:
+            raise ValueError("interval has to be a positive integer")
+
+        spanRange = cls.span_range(frame,start,end,tz)
+
+        bound = (len(spanRange) // interval) * interval
+        return [ (spanRange[i][0],spanRange[i+ interval - 1][1]) for i in range(0,bound, interval) ]
 
     # representations
 
@@ -471,13 +515,28 @@ class Arrow(object):
         >>> arw.shift(years=1, months=-1)
         <Arrow [2014-04-11T22:27:34.787885+00:00]>
 
+        Day-of-the-week relative shifting can use either Python's weekday numbers
+        (Monday = 0, Tuesday = 1 .. Sunday = 6) or using dateutil.relativedelta's
+        day instances (MO, TU .. SU).  When using weekday numbers, the returned
+        date will always be greater than or equal to the starting date.
+
+        Using the above code (which is a Saturday) and asking it to shift to Saturday:
+
+        >>> arw.shift(weekday=5)
+        <Arrow [2013-05-11T22:27:34.787885+00:00]>
+
+        While asking for a Monday:
+
+        >>> arw.shift(weekday=0)
+        <Arrow [2013-05-13T22:27:34.787885+00:00]>
+
         '''
 
         relative_kwargs = {}
 
         for key, value in kwargs.items():
 
-            if key in self._ATTRS_PLURAL or key in ['weeks', 'quarters']:
+            if key in self._ATTRS_PLURAL or key in ['weeks', 'quarters', 'weekday']:
                 relative_kwargs[key] = value
             else:
                 raise AttributeError()
@@ -641,14 +700,14 @@ class Arrow(object):
         return formatter.DateTimeFormatter(locale).format(self._datetime, fmt)
 
 
-    def humanize(self, other=None, locale='en_us', only_distance=False):
+    def humanize(self, other=None, locale='en_us', only_distance=False, granularity='auto'):
         ''' Returns a localized, humanized representation of a relative difference in time.
 
         :param other: (optional) an :class:`Arrow <arrow.arrow.Arrow>` or ``datetime`` object.
             Defaults to now in the current :class:`Arrow <arrow.arrow.Arrow>` object's timezone.
         :param locale: (optional) a ``str`` specifying a locale.  Defaults to 'en_us'.
         :param only_distance: (optional) returns only time difference eg: "11 seconds" without "in" or "ago" part.
-
+        :param granularity: (optinal) defines the precision of the output. Set it to strings 'second', 'minute', 'hour', 'day', 'month' or 'year'.
         Usage::
 
             >>> earlier = arrow.utcnow().replace(hours=-2)
@@ -683,48 +742,71 @@ class Arrow(object):
         sign = -1 if delta < 0 else 1
         diff = abs(delta)
         delta = diff
+        
+        if granularity=='auto':
+            if diff < 10:
+                return locale.describe('now', only_distance=only_distance)
+    
+            if diff < 45:
+                seconds = sign * delta
+                return locale.describe('seconds', seconds, only_distance=only_distance)
+    
+            elif diff < 90:
+                return locale.describe('minute', sign, only_distance=only_distance)
+            elif diff < 2700:
+                minutes = sign * int(max(delta / 60, 2))
+                return locale.describe('minutes', minutes, only_distance=only_distance)
+    
+            elif diff < 5400:
+                return locale.describe('hour', sign, only_distance=only_distance)
+            elif diff < 79200:
+                hours = sign * int(max(delta / 3600, 2))
+                return locale.describe('hours', hours, only_distance=only_distance)
+    
+            elif diff < 129600:
+                return locale.describe('day', sign, only_distance=only_distance)
+            elif diff < 2160000:
+                days = sign * int(max(delta / 86400, 2))
+                return locale.describe('days', days, only_distance=only_distance)
+    
+            elif diff < 3888000:
+                return locale.describe('month', sign, only_distance=only_distance)
+            elif diff < 29808000:
+                self_months = self._datetime.year * 12 + self._datetime.month
+                other_months = dt.year * 12 + dt.month
+    
+                months = sign * int(max(abs(other_months - self_months), 2))
+    
+                return locale.describe('months', months, only_distance=only_distance)
+    
+            elif diff < 47260800:
+                return locale.describe('year', sign, only_distance=only_distance)
+            else:
+                years = sign * int(max(delta / 31536000, 2))
+                return locale.describe('years', years, only_distance=only_distance)
 
-        if diff < 10:
-            return locale.describe('now', only_distance=only_distance)
-
-        if diff < 45:
-            return locale.describe('seconds', sign, only_distance=only_distance)
-
-        elif diff < 90:
-            return locale.describe('minute', sign, only_distance=only_distance)
-        elif diff < 2700:
-            minutes = sign * int(max(delta / 60, 2))
-            return locale.describe('minutes', minutes, only_distance=only_distance)
-
-        elif diff < 5400:
-            return locale.describe('hour', sign, only_distance=only_distance)
-        elif diff < 79200:
-            hours = sign * int(max(delta / 3600, 2))
-            return locale.describe('hours', hours, only_distance=only_distance)
-
-        elif diff < 129600:
-            return locale.describe('day', sign, only_distance=only_distance)
-        elif diff < 2160000:
-            days = sign * int(max(delta / 86400, 2))
-            return locale.describe('days', days, only_distance=only_distance)
-
-        elif diff < 3888000:
-            return locale.describe('month', sign, only_distance=only_distance)
-        elif diff < 29808000:
-            self_months = self._datetime.year * 12 + self._datetime.month
-            other_months = dt.year * 12 + dt.month
-
-            months = sign * int(max(abs(other_months - self_months), 2))
-
-            return locale.describe('months', months, only_distance=only_distance)
-
-        elif diff < 47260800:
-            return locale.describe('year', sign, only_distance=only_distance)
         else:
-            years = sign * int(max(delta / 31536000, 2))
-            return locale.describe('years', years, only_distance=only_distance)
-
-
+            if granularity == 'second':
+                delta = sign * delta
+                if(abs(delta) < 2):
+                    return locale.describe('now', only_distance=only_distance)
+            elif granularity == 'minute':
+                delta = sign * delta / float(60)
+            elif granularity == 'hour':
+                delta = sign * delta / float(60*60)
+            elif granularity == 'day':
+                delta = sign * delta / float(60*60*24)
+            elif granularity == 'month':
+                delta = sign * delta / float(60*60*24*30.5)
+            elif granularity == 'year':
+                delta = sign * delta / float(60*60*24*365.25)
+            else:
+                raise AttributeError('Error. Could not understand your level of granularity. Please select between \
+                "second", "minute", "hour", "day", "week", "month" or "year"') 
+            
+            if(trunc(abs(delta)) != 1):
+                granularity += 's'
+            return locale.describe(granularity, delta, only_distance=False)
     # math
 
     def __add__(self, other):
@@ -747,7 +829,7 @@ class Arrow(object):
 
         elif isinstance(other, Arrow):
             return self._datetime - other._datetime
-
+        # print granularity
         raise TypeError()
 
     def __rsub__(self, other):
diff --git a/arrow/locales.py b/arrow/locales.py
index 7cf7f4c..db36114 100644
--- a/arrow/locales.py
+++ b/arrow/locales.py
@@ -4,6 +4,7 @@ from __future__ import unicode_literals
 
 import inspect
 import sys
+from math import trunc
 
 
 def get_locale(name):
@@ -169,8 +170,7 @@ class Locale(object):
         return dict(map(lambda i: (i[1].lower(), i[0] + 1), enumerate(lst[1:])))
 
     def _format_timeframe(self, timeframe, delta):
-
-        return self.timeframes[timeframe].format(abs(delta))
+        return self.timeframes[timeframe].format(trunc(abs(delta)))
 
     def _format_relative(self, humanized, timeframe, delta):
 
@@ -839,13 +839,10 @@ class GermanLocale(_DeutschLocaleCommonMixin, Locale):
 
     names = ['de', 'de_de']
 
-    timeframes = _DeutschLocaleCommonMixin.timeframes.copy()
-    timeframes['days'] = '{0} Tagen'
-
 
 class AustriaLocale(_DeutschLocaleCommonMixin, Locale):
 
-    names = ['de', 'de_at']
+    names = ['de_at']
 
     timeframes = _DeutschLocaleCommonMixin.timeframes.copy()
     timeframes['days'] = '{0} Tage'
@@ -1758,7 +1755,7 @@ class ThaiLocale(Locale):
     }
 
     month_names = ['', 'มกราคม', 'กุมภาพันธ์', 'มีนาคม', 'เมษายน',
-                   'พฤษภาคม', 'มิถุนายน', 'กรกฏาคม', 'สิงหาคม',
+                   'พฤษภาคม', 'มิถุนายน', 'กรกฎาคม', 'สิงหาคม',
                    'กันยายน', 'ตุลาคม', 'พฤศจิกายน', 'ธันวาคม']
     month_abbreviations = ['', 'ม.ค.', 'ก.พ.', 'มี.ค.', 'เม.ย.', 'พ.ค.',
                            'มิ.ย.', 'ก.ค.', 'ส.ค.', 'ก.ย.', 'ต.ค.',


Test patch:
diff --git a/tests/arrow_tests.py b/tests/arrow_tests.py
index 65022f9..e5a8b52 100644
--- a/tests/arrow_tests.py
+++ b/tests/arrow_tests.py
@@ -6,6 +6,7 @@ from chai import Chai
 
 from datetime import date, datetime, timedelta
 from dateutil import tz
+from dateutil.relativedelta import MO, TU, WE, TH, FR, SA, SU
 import simplejson as json
 import warnings
 import calendar
@@ -592,6 +593,48 @@ class ArrowShiftTests(Chai):
         assertEqual(arw.shift(seconds=1), arrow.Arrow(2013, 5, 5, 12, 30, 46))
         assertEqual(arw.shift(microseconds=1), arrow.Arrow(2013, 5, 5, 12, 30, 45, 1))
 
+        # Remember: Python's weekday 0 is Monday
+        assertEqual(arw.shift(weekday=0), arrow.Arrow(2013, 5, 6, 12, 30, 45))
+        assertEqual(arw.shift(weekday=1), arrow.Arrow(2013, 5, 7, 12, 30, 45))
+        assertEqual(arw.shift(weekday=2), arrow.Arrow(2013, 5, 8, 12, 30, 45))
+        assertEqual(arw.shift(weekday=3), arrow.Arrow(2013, 5, 9, 12, 30, 45))
+        assertEqual(arw.shift(weekday=4), arrow.Arrow(2013, 5, 10, 12, 30, 45))
+        assertEqual(arw.shift(weekday=5), arrow.Arrow(2013, 5, 11, 12, 30, 45))
+        assertEqual(arw.shift(weekday=6), arw)
+
+        with assertRaises(IndexError):
+            arw.shift(weekday=7)
+
+        # Use dateutil.relativedelta's convenient day instances
+        assertEqual(arw.shift(weekday=MO), arrow.Arrow(2013, 5, 6, 12, 30, 45))
+        assertEqual(arw.shift(weekday=MO(0)), arrow.Arrow(2013, 5, 6, 12, 30, 45))
+        assertEqual(arw.shift(weekday=MO(1)), arrow.Arrow(2013, 5, 6, 12, 30, 45))
+        assertEqual(arw.shift(weekday=MO(2)), arrow.Arrow(2013, 5, 13, 12, 30, 45))
+        assertEqual(arw.shift(weekday=TU), arrow.Arrow(2013, 5, 7, 12, 30, 45))
+        assertEqual(arw.shift(weekday=TU(0)), arrow.Arrow(2013, 5, 7, 12, 30, 45))
+        assertEqual(arw.shift(weekday=TU(1)), arrow.Arrow(2013, 5, 7, 12, 30, 45))
+        assertEqual(arw.shift(weekday=TU(2)), arrow.Arrow(2013, 5, 14, 12, 30, 45))
+        assertEqual(arw.shift(weekday=WE), arrow.Arrow(2013, 5, 8, 12, 30, 45))
+        assertEqual(arw.shift(weekday=WE(0)), arrow.Arrow(2013, 5, 8, 12, 30, 45))
+        assertEqual(arw.shift(weekday=WE(1)), arrow.Arrow(2013, 5, 8, 12, 30, 45))
+        assertEqual(arw.shift(weekday=WE(2)), arrow.Arrow(2013, 5, 15, 12, 30, 45))
+        assertEqual(arw.shift(weekday=TH), arrow.Arrow(2013, 5, 9, 12, 30, 45))
+        assertEqual(arw.shift(weekday=TH(0)), arrow.Arrow(2013, 5, 9, 12, 30, 45))
+        assertEqual(arw.shift(weekday=TH(1)), arrow.Arrow(2013, 5, 9, 12, 30, 45))
+        assertEqual(arw.shift(weekday=TH(2)), arrow.Arrow(2013, 5, 16, 12, 30, 45))
+        assertEqual(arw.shift(weekday=FR), arrow.Arrow(2013, 5, 10, 12, 30, 45))
+        assertEqual(arw.shift(weekday=FR(0)), arrow.Arrow(2013, 5, 10, 12, 30, 45))
+        assertEqual(arw.shift(weekday=FR(1)), arrow.Arrow(2013, 5, 10, 12, 30, 45))
+        assertEqual(arw.shift(weekday=FR(2)), arrow.Arrow(2013, 5, 17, 12, 30, 45))
+        assertEqual(arw.shift(weekday=SA), arrow.Arrow(2013, 5, 11, 12, 30, 45))
+        assertEqual(arw.shift(weekday=SA(0)), arrow.Arrow(2013, 5, 11, 12, 30, 45))
+        assertEqual(arw.shift(weekday=SA(1)), arrow.Arrow(2013, 5, 11, 12, 30, 45))
+        assertEqual(arw.shift(weekday=SA(2)), arrow.Arrow(2013, 5, 18, 12, 30, 45))
+        assertEqual(arw.shift(weekday=SU), arw)
+        assertEqual(arw.shift(weekday=SU(0)), arw)
+        assertEqual(arw.shift(weekday=SU(1)), arw)
+        assertEqual(arw.shift(weekday=SU(2)), arrow.Arrow(2013, 5, 12, 12, 30, 45))
+
     def test_shift_negative(self):
 
         arw = arrow.Arrow(2013, 5, 5, 12, 30, 45)
@@ -607,6 +650,27 @@ class ArrowShiftTests(Chai):
         assertEqual(arw.shift(seconds=-1), arrow.Arrow(2013, 5, 5, 12, 30, 44))
         assertEqual(arw.shift(microseconds=-1), arrow.Arrow(2013, 5, 5, 12, 30, 44, 999999))
 
+        # Not sure how practical these negative weekdays are
+        assertEqual(arw.shift(weekday=-1), arw.shift(weekday=SU))
+        assertEqual(arw.shift(weekday=-2), arw.shift(weekday=SA))
+        assertEqual(arw.shift(weekday=-3), arw.shift(weekday=FR))
+        assertEqual(arw.shift(weekday=-4), arw.shift(weekday=TH))
+        assertEqual(arw.shift(weekday=-5), arw.shift(weekday=WE))
+        assertEqual(arw.shift(weekday=-6), arw.shift(weekday=TU))
+        assertEqual(arw.shift(weekday=-7), arw.shift(weekday=MO))
+
+        with assertRaises(IndexError):
+            arw.shift(weekday=-8)
+
+        assertEqual(arw.shift(weekday=MO(-1)), arrow.Arrow(2013, 4, 29, 12, 30, 45))
+        assertEqual(arw.shift(weekday=TU(-1)), arrow.Arrow(2013, 4, 30, 12, 30, 45))
+        assertEqual(arw.shift(weekday=WE(-1)), arrow.Arrow(2013, 5, 1, 12, 30, 45))
+        assertEqual(arw.shift(weekday=TH(-1)), arrow.Arrow(2013, 5, 2, 12, 30, 45))
+        assertEqual(arw.shift(weekday=FR(-1)), arrow.Arrow(2013, 5, 3, 12, 30, 45))
+        assertEqual(arw.shift(weekday=SA(-1)), arrow.Arrow(2013, 5, 4, 12, 30, 45))
+        assertEqual(arw.shift(weekday=SU(-1)), arw)
+        assertEqual(arw.shift(weekday=SU(-2)), arrow.Arrow(2013, 4, 28, 12, 30, 45))
+
     def test_shift_quarters_bug(self):
 
         arw = arrow.Arrow(2013, 5, 5, 12, 30, 45)
@@ -923,6 +987,23 @@ class ArrowSpanRangeTests(Chai):
             assertEqual(f.tzinfo, tz.gettz('US/Central'))
             assertEqual(c.tzinfo, tz.gettz('US/Central'))
 
+class ArrowIntervalTests(Chai):
+
+    def test_incorrect_input(self):
+        correct = True
+        try:
+            result = arrow.Arrow.interval('month', datetime(2013, 1, 2), datetime(2013, 4, 15),0)
+        except:
+            correct = False
+
+        assertEqual(correct,False)
+
+    def test_correct(self):
+        result = arrow.Arrow.interval('hour', datetime(2013, 5, 5, 12, 30), datetime(2013, 5, 5, 17, 15),2)
+
+        assertEqual(result,[(arrow.Arrow(2013, 5, 5, 12), arrow.Arrow(2013, 5, 5, 13, 59, 59, 999999)),
+            (arrow.Arrow(2013, 5, 5, 14), arrow.Arrow(2013, 5, 5, 15, 59, 59, 999999)),
+            (arrow.Arrow(2013, 5, 5, 16), arrow.Arrow(2013, 5, 5, 17, 59, 59, 999999))])
 
 class ArrowSpanTests(Chai):
 
@@ -1034,6 +1115,60 @@ class ArrowHumanizeTests(Chai):
         self.datetime = datetime(2013, 1, 1)
         self.now = arrow.Arrow.utcnow()
 
+    def test_granularity(self):
+    
+        assertEqual(self.now.humanize(granularity = 'second'), 'just now')
+        
+        later1 = self.now.shift(seconds=1)
+        assertEqual(self.now.humanize(later1, granularity = 'second'), 'just now')
+        assertEqual(later1.humanize(self.now, granularity = 'second'), 'just now')
+        assertEqual(self.now.humanize(later1, granularity = 'minute'), '0 minutes ago')
+        assertEqual(later1.humanize(self.now, granularity = 'minute'), 'in 0 minutes')
+        
+        later100 = self.now.shift(seconds=100)
+        assertEqual(self.now.humanize(later100, granularity = 'second'), 'seconds ago')
+        assertEqual(later100.humanize(self.now, granularity = 'second'), 'in seconds')
+        assertEqual(self.now.humanize(later100, granularity = 'minute'), 'a minute ago')
+        assertEqual(later100.humanize(self.now, granularity = 'minute'), 'in a minute')
+        assertEqual(self.now.humanize(later100, granularity = 'hour'), '0 hours ago')
+        assertEqual(later100.humanize(self.now, granularity = 'hour'), 'in 0 hours')
+        
+        later4000 = self.now.shift(seconds=4000)
+        assertEqual(self.now.humanize(later4000, granularity = 'minute'), '66 minutes ago')
+        assertEqual(later4000.humanize(self.now, granularity = 'minute'), 'in 66 minutes')
+        assertEqual(self.now.humanize(later4000, granularity = 'hour'), 'an hour ago')
+        assertEqual(later4000.humanize(self.now, granularity = 'hour'), 'in an hour')
+        assertEqual(self.now.humanize(later4000, granularity = 'day'), '0 days ago')
+        assertEqual(later4000.humanize(self.now, granularity = 'day'), 'in 0 days')
+        
+        later105 = self.now.shift(seconds=10 ** 5)
+        assertEqual(self.now.humanize(later105, granularity = 'hour'), '27 hours ago')
+        assertEqual(later105.humanize(self.now, granularity = 'hour'), 'in 27 hours')
+        assertEqual(self.now.humanize(later105, granularity = 'day'), 'a day ago')
+        assertEqual(later105.humanize(self.now, granularity = 'day'), 'in a day')
+        assertEqual(self.now.humanize(later105, granularity = 'month'), '0 months ago')
+        assertEqual(later105.humanize(self.now, granularity = 'month'), 'in 0 months')
+        
+        later106 = self.now.shift(seconds=3 * 10 ** 6)
+        assertEqual(self.now.humanize(later106, granularity = 'day'), '34 days ago')
+        assertEqual(later106.humanize(self.now, granularity = 'day'), 'in 34 days')
+        assertEqual(self.now.humanize(later106, granularity = 'month'), 'a month ago')
+        assertEqual(later106.humanize(self.now, granularity = 'month'), 'in a month')
+        assertEqual(self.now.humanize(later106, granularity = 'year'), '0 years ago')
+        assertEqual(later106.humanize(self.now, granularity = 'year'), 'in 0 years')
+        
+        later506 = self.now.shift(seconds=50 * 10 ** 6)
+        assertEqual(self.now.humanize(later506, granularity = 'month'), '18 months ago')
+        assertEqual(later506.humanize(self.now, granularity = 'month'), 'in 18 months')
+        assertEqual(self.now.humanize(later506, granularity = 'year'), 'a year ago')
+        assertEqual(later506.humanize(self.now, granularity = 'year'), 'in a year')
+        
+        later108 = self.now.shift(seconds=10 ** 8)
+        assertEqual(self.now.humanize(later108, granularity = 'year'), '3 years ago')
+        assertEqual(later108.humanize(self.now, granularity = 'year'), 'in 3 years')
+        with assertRaises(AttributeError):
+            self.now.humanize(later108, granularity = 'years')
+    
     def test_seconds(self):
 
         later = self.now.shift(seconds=10)