From 6b70d809705696da1d73c153343bb7759f287412 Mon Sep 17 00:00:00 2001 From: Howard Willard Date: Tue, 26 Nov 2019 10:18:54 -0500 Subject: [PATCH 1/4] Added support for multiple granularities in humanize function --- arrow/arrow.py | 67 ++++++++++++++++++++++++++++++++++++++------ arrow/locales.py | 37 ++++++++++++++++++++++++ setup.cfg | 2 +- tests/arrow_tests.py | 60 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 157 insertions(+), 9 deletions(-) diff --git a/arrow/arrow.py b/arrow/arrow.py index d752e393a..2e5b219db 100644 --- a/arrow/arrow.py +++ b/arrow/arrow.py @@ -13,6 +13,7 @@ from datetime import tzinfo as dt_tzinfo from math import trunc +import six from dateutil import tz as dateutil_tz from dateutil.relativedelta import relativedelta @@ -56,6 +57,12 @@ class Arrow(object): _ATTRS = ["year", "month", "day", "hour", "minute", "second", "microsecond"] _ATTRS_PLURAL = ["{}s".format(a) for a in _ATTRS] _MONTHS_PER_QUARTER = 3 + _SECS_PER_MINUTE = float(60) + _SECS_PER_HOUR = float(3600) # 60 * 60 + _SECS_PER_DAY = float(86400) # 60 * 60 * 24 + _SECS_PER_WEEK = float(604800) # 60 * 60 * 24 * 7 + _SECS_PER_MONTH = float(2635200) # 60 * 60 * 24 * 30.5 + _SECS_PER_YEAR = float(31557600) # 60 * 60 * 24 * 365.25 def __init__( self, year, month, day, hour=0, minute=0, second=0, microsecond=0, tzinfo=None @@ -844,7 +851,8 @@ def humanize( Defaults to now in the current :class:`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: (optional) defines the precision of the output. Set it to strings 'second', 'minute', 'hour', 'day', 'week', 'month' or 'year'. + :param granularity: (optional) defines the precision of the output. Set it to strings 'second', 'minute', + 'hour', 'day', 'week', 'month' or 'year' or a list of any combination of these strings Usage:: @@ -877,6 +885,9 @@ def humanize( else: raise TypeError() + if isinstance(granularity, list) and len(granularity) == 1: + granularity = granularity[0] + delta = int(round(util.total_seconds(self._datetime - dt))) sign = -1 if delta < 0 else 1 diff = abs(delta) @@ -937,23 +948,23 @@ def humanize( years = sign * int(max(delta / 31536000, 2)) return locale.describe("years", years, only_distance=only_distance) - else: + elif isinstance(granularity, six.string_types): 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) + delta = sign * delta / self._SECS_PER_MINUTE elif granularity == "hour": - delta = sign * delta / float(60 * 60) + delta = sign * delta / self._SECS_PER_HOUR elif granularity == "day": - delta = sign * delta / float(60 * 60 * 24) + delta = sign * delta / self._SECS_PER_DAY elif granularity == "week": - delta = sign * delta / float(60 * 60 * 24 * 7) + delta = sign * delta / self._SECS_PER_WEEK elif granularity == "month": - delta = sign * delta / float(60 * 60 * 24 * 30.5) + delta = sign * delta / self._SECS_PER_MONTH elif granularity == "year": - delta = sign * delta / float(60 * 60 * 24 * 365.25) + delta = sign * delta / self._SECS_PER_YEAR else: raise AttributeError( "Invalid level of granularity. Please select between 'second', 'minute', 'hour', 'day', 'week', 'month' or 'year'" @@ -962,6 +973,46 @@ def humanize( if trunc(abs(delta)) != 1: granularity += "s" return locale.describe(granularity, delta, only_distance=only_distance) + + else: + timeframes = [] + if "year" in granularity: + years = sign * delta / self._SECS_PER_YEAR + delta -= sign * trunc(years) * self._SECS_PER_YEAR + timeframes.append(["year", years]) + if "month" in granularity: + months = sign * delta / self._SECS_PER_MONTH + delta -= sign * trunc(months) * self._SECS_PER_MONTH + timeframes.append(["month", months]) + if "week" in granularity: + weeks = sign * delta / self._SECS_PER_WEEK + delta -= sign * trunc(weeks) * self._SECS_PER_WEEK + timeframes.append(["week", weeks]) + if "day" in granularity: + days = sign * delta / self._SECS_PER_DAY + delta -= sign * trunc(days) * self._SECS_PER_DAY + timeframes.append(["day", days]) + if "hour" in granularity: + hours = sign * delta / self._SECS_PER_HOUR + delta -= sign * trunc(hours) * self._SECS_PER_HOUR + timeframes.append(["hour", hours]) + if "minute" in granularity: + minutes = sign * delta / self._SECS_PER_MINUTE + delta -= sign * trunc(minutes) * self._SECS_PER_MINUTE + timeframes.append(["minute", minutes]) + if "second" in granularity: + seconds = sign * delta + timeframes.append(["second", seconds]) + if len(timeframes) < len(granularity): + raise AttributeError( + "Invalid level of granularity. Please select between 'second', 'minute', 'hour', 'day', 'week', 'month' or 'year'" + ) + for index in range(len(timeframes)): + gran, delta = timeframes[index] + if trunc(abs(delta)) != 1: + timeframes[index][0] += "s" + return locale.describe_multi(timeframes, only_distance=only_distance) + except KeyError as e: raise ValueError( "Humanization of the {} granularity is not currently translated in the '{}' locale. Please consider making a contribution to this locale.".format( diff --git a/arrow/locales.py b/arrow/locales.py index 1a50662e9..23db2762f 100644 --- a/arrow/locales.py +++ b/arrow/locales.py @@ -51,6 +51,7 @@ class Locale(object): past = None future = None + and_word = None month_names = [] month_abbreviations = [] @@ -78,6 +79,27 @@ def describe(self, timeframe, delta=0, only_distance=False): return humanized + def describe_multi(self, timeframes, only_distance=False): + """ Describes a delta within multiple timeframes in plain language. + + :param timeframes: a list of string, quantity pairs each representing a timeframe and delta. + :param only_distance: return only distance eg: "2 hours 11 seconds" without "in" or "ago" keywords + """ + + humanized = "" + for index in range(len(timeframes)): + timeframe, delta = timeframes[index] + humanized += self._format_timeframe(timeframe, delta) + if index == len(timeframes) - 2 and self.and_word: + humanized += " " + self.and_word + " " + elif index < len(timeframes) - 1: + humanized += " " + + if not only_distance: + humanized = self._format_relative(humanized, timeframe, delta) + + return humanized + def day_name(self, day): """ Returns the day name for a specified day of the week. @@ -200,6 +222,7 @@ class EnglishLocale(Locale): past = "{0} ago" future = "in {0}" + and_word = "and" timeframes = { "now": "just now", @@ -295,6 +318,7 @@ class ItalianLocale(Locale): names = ["it", "it_it"] past = "{0} fa" future = "tra {0}" + and_word = "e" timeframes = { "now": "adesso", @@ -364,6 +388,7 @@ class SpanishLocale(Locale): names = ["es", "es_es"] past = "hace {0}" future = "en {0}" + and_word = "y" timeframes = { "now": "ahora", @@ -437,6 +462,7 @@ class FrenchLocale(Locale): names = ["fr", "fr_fr"] past = "il y a {0}" future = "dans {0}" + and_word = "et" timeframes = { "now": "maintenant", @@ -514,6 +540,7 @@ class GreekLocale(Locale): past = "{0} πριν" future = "σε {0}" + and_word = "και" timeframes = { "now": "τώρα", @@ -639,6 +666,7 @@ class SwedishLocale(Locale): past = "för {0} sen" future = "om {0}" + and_word = "och" timeframes = { "now": "just nu", @@ -1527,6 +1555,7 @@ class DeutschBaseLocale(Locale): past = "vor {0}" future = "in {0}" + and_word = "und" timeframes = { "now": "gerade eben", @@ -1778,6 +1807,7 @@ class PortugueseLocale(Locale): past = "há {0}" future = "em {0}" + and_word = "e" timeframes = { "now": "agora", @@ -2495,6 +2525,7 @@ class DanishLocale(Locale): past = "for {0} siden" future = "efter {0}" + and_word = "og" timeframes = { "now": "lige nu", @@ -2802,6 +2833,7 @@ class SlovakLocale(Locale): past = "Pred {0}" future = "O {0}" + and_word = "a" month_names = [ "", @@ -3101,6 +3133,7 @@ class CatalanLocale(Locale): names = ["ca", "ca_es", "ca_ad", "ca_fr", "ca_it"] past = "Fa {0}" future = "En {0}" + and_word = "i" timeframes = { "now": "Ara mateix", @@ -3685,6 +3718,7 @@ class RomanianLocale(Locale): past = "{0} în urmă" future = "peste {0}" + and_word = "și" timeframes = { "now": "acum", @@ -3750,6 +3784,7 @@ class SlovenianLocale(Locale): past = "pred {0}" future = "čez {0}" + and_word = "in" timeframes = { "now": "zdaj", @@ -3820,6 +3855,7 @@ class IndonesianLocale(Locale): past = "{0} yang lalu" future = "dalam {0}" + and_word = "dan" timeframes = { "now": "baru saja", @@ -3957,6 +3993,7 @@ class EstonianLocale(Locale): past = "{0} tagasi" future = "{0} pärast" + and_word = "ja" timeframes = { "now": {"past": "just nüüd", "future": "just nüüd"}, diff --git a/setup.cfg b/setup.cfg index 08cae3ddf..2ab246006 100644 --- a/setup.cfg +++ b/setup.cfg @@ -25,7 +25,7 @@ ignore = E203,E501,W503 line_length = 88 multi_line_output = 3 include_trailing_comma = true -known_third_party = chai,dateparser,dateutil,mock,pytz,setuptools,simplejson +known_third_party = chai,dateparser,dateutil,mock,pytz,setuptools,simplejson,six [bdist_wheel] universal = 1 diff --git a/tests/arrow_tests.py b/tests/arrow_tests.py index 4b26e6195..22587cb13 100644 --- a/tests/arrow_tests.py +++ b/tests/arrow_tests.py @@ -1435,6 +1435,12 @@ def test_granularity(self): self.assertEqual( later105.humanize(self.now, granularity="month"), "in 0 months" ) + self.assertEqual( + self.now.humanize(later105, granularity=["month"]), "0 months ago" + ) + self.assertEqual( + later105.humanize(self.now, granularity=["month"]), "in 0 months" + ) later106 = self.now.shift(seconds=3 * 10 ** 6) self.assertEqual(self.now.humanize(later106, granularity="day"), "34 days ago") @@ -1479,9 +1485,63 @@ def test_granularity(self): ), "3 years", ) + with self.assertRaises(AttributeError): self.now.humanize(later108, granularity="years") + def test_multiple_granularity(self): + self.assertEqual(self.now.humanize(granularity="second"), "just now") + self.assertEqual(self.now.humanize(granularity=["second"]), "just now") + self.assertEqual( + self.now.humanize(granularity=["year", "month", "day", "hour", "second"]), + "in 0 years 0 months 0 days 0 hours and seconds", + ) + + later4000 = self.now.shift(seconds=4000) + self.assertEqual( + later4000.humanize(self.now, granularity=["hour", "minute"]), + "in an hour and 6 minutes", + ) + self.assertEqual( + self.now.humanize(later4000, granularity=["hour", "minute"]), + "an hour and 6 minutes ago", + ) + self.assertEqual( + later4000.humanize( + self.now, granularity=["hour", "minute"], only_distance=True + ), + "an hour and 6 minutes", + ) + self.assertEqual( + later4000.humanize(self.now, granularity=["day", "hour", "minute"]), + "in 0 days an hour and 6 minutes", + ) + self.assertEqual( + self.now.humanize(later4000, granularity=["day", "hour", "minute"]), + "0 days an hour and 6 minutes ago", + ) + + later105 = self.now.shift(seconds=10 ** 5) + self.assertEqual( + self.now.humanize(later105, granularity=["hour", "day", "minute"]), + "a day 3 hours and 46 minutes ago", + ) + with self.assertRaises(AttributeError): + self.now.humanize(later105, granularity=["error", "second"]) + + later108onlydistance = self.now.shift(seconds=10 ** 8) + self.assertEqual( + self.now.humanize(later108onlydistance, granularity=["year"]), "3 years ago" + ) + self.assertEqual( + self.now.humanize(later108onlydistance, granularity=["month", "week"]), + "37 months and 4 weeks ago", + ) + self.assertEqual( + self.now.humanize(later108onlydistance, granularity=["year", "second"]), + "3 years and seconds ago", + ) + def test_seconds(self): later = self.now.shift(seconds=10) From 3cca6fbdb361e2ca8be54438a2135fdff81b851a Mon Sep 17 00:00:00 2001 From: Howard Willard Date: Tue, 26 Nov 2019 10:29:03 -0500 Subject: [PATCH 2/4] Multiple granularities logic simplification --- arrow/arrow.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/arrow/arrow.py b/arrow/arrow.py index 2e5b219db..b19017d19 100644 --- a/arrow/arrow.py +++ b/arrow/arrow.py @@ -978,27 +978,27 @@ def humanize( timeframes = [] if "year" in granularity: years = sign * delta / self._SECS_PER_YEAR - delta -= sign * trunc(years) * self._SECS_PER_YEAR + delta = delta % self._SECS_PER_YEAR timeframes.append(["year", years]) if "month" in granularity: months = sign * delta / self._SECS_PER_MONTH - delta -= sign * trunc(months) * self._SECS_PER_MONTH + delta = delta % self._SECS_PER_MONTH timeframes.append(["month", months]) if "week" in granularity: weeks = sign * delta / self._SECS_PER_WEEK - delta -= sign * trunc(weeks) * self._SECS_PER_WEEK + delta = delta % self._SECS_PER_WEEK timeframes.append(["week", weeks]) if "day" in granularity: days = sign * delta / self._SECS_PER_DAY - delta -= sign * trunc(days) * self._SECS_PER_DAY + delta = delta % self._SECS_PER_DAY timeframes.append(["day", days]) if "hour" in granularity: hours = sign * delta / self._SECS_PER_HOUR - delta -= sign * trunc(hours) * self._SECS_PER_HOUR + delta = delta % self._SECS_PER_HOUR timeframes.append(["hour", hours]) if "minute" in granularity: minutes = sign * delta / self._SECS_PER_MINUTE - delta -= sign * trunc(minutes) * self._SECS_PER_MINUTE + delta = delta % self._SECS_PER_MINUTE timeframes.append(["minute", minutes]) if "second" in granularity: seconds = sign * delta From 7355768d54cb0168dd86bb86298a9a5a1bd6e351 Mon Sep 17 00:00:00 2001 From: Howard Willard Date: Tue, 26 Nov 2019 10:29:03 -0500 Subject: [PATCH 3/4] Multiple granularities logic simplification --- arrow/arrow.py | 25 ++++++++++++------------- docs/index.rst | 11 +++++++++++ setup.cfg | 2 +- 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/arrow/arrow.py b/arrow/arrow.py index 2e5b219db..483757272 100644 --- a/arrow/arrow.py +++ b/arrow/arrow.py @@ -13,7 +13,6 @@ from datetime import tzinfo as dt_tzinfo from math import trunc -import six from dateutil import tz as dateutil_tz from dateutil.relativedelta import relativedelta @@ -58,11 +57,11 @@ class Arrow(object): _ATTRS_PLURAL = ["{}s".format(a) for a in _ATTRS] _MONTHS_PER_QUARTER = 3 _SECS_PER_MINUTE = float(60) - _SECS_PER_HOUR = float(3600) # 60 * 60 - _SECS_PER_DAY = float(86400) # 60 * 60 * 24 - _SECS_PER_WEEK = float(604800) # 60 * 60 * 24 * 7 - _SECS_PER_MONTH = float(2635200) # 60 * 60 * 24 * 30.5 - _SECS_PER_YEAR = float(31557600) # 60 * 60 * 24 * 365.25 + _SECS_PER_HOUR = float(60 * 60) + _SECS_PER_DAY = float(60 * 60 * 24) + _SECS_PER_WEEK = float(60 * 60 * 24 * 7) + _SECS_PER_MONTH = float(60 * 60 * 24 * 30.5) + _SECS_PER_YEAR = float(60 * 60 * 24 * 365.25) def __init__( self, year, month, day, hour=0, minute=0, second=0, microsecond=0, tzinfo=None @@ -948,7 +947,7 @@ def humanize( years = sign * int(max(delta / 31536000, 2)) return locale.describe("years", years, only_distance=only_distance) - elif isinstance(granularity, six.string_types): + elif util.isstr(granularity): if granularity == "second": delta = sign * delta if abs(delta) < 2: @@ -978,27 +977,27 @@ def humanize( timeframes = [] if "year" in granularity: years = sign * delta / self._SECS_PER_YEAR - delta -= sign * trunc(years) * self._SECS_PER_YEAR + delta = delta % self._SECS_PER_YEAR timeframes.append(["year", years]) if "month" in granularity: months = sign * delta / self._SECS_PER_MONTH - delta -= sign * trunc(months) * self._SECS_PER_MONTH + delta = delta % self._SECS_PER_MONTH timeframes.append(["month", months]) if "week" in granularity: weeks = sign * delta / self._SECS_PER_WEEK - delta -= sign * trunc(weeks) * self._SECS_PER_WEEK + delta = delta % self._SECS_PER_WEEK timeframes.append(["week", weeks]) if "day" in granularity: days = sign * delta / self._SECS_PER_DAY - delta -= sign * trunc(days) * self._SECS_PER_DAY + delta = delta % self._SECS_PER_DAY timeframes.append(["day", days]) if "hour" in granularity: hours = sign * delta / self._SECS_PER_HOUR - delta -= sign * trunc(hours) * self._SECS_PER_HOUR + delta = delta % self._SECS_PER_HOUR timeframes.append(["hour", hours]) if "minute" in granularity: minutes = sign * delta / self._SECS_PER_MINUTE - delta -= sign * trunc(minutes) * self._SECS_PER_MINUTE + delta = delta % self._SECS_PER_MINUTE timeframes.append(["minute", minutes]) if "second" in granularity: seconds = sign * delta diff --git a/docs/index.rst b/docs/index.rst index 16ffb347d..af1e39ccd 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -208,6 +208,17 @@ Or another Arrow, or datetime: >>> future.humanize(present) 'in 2 hours' +Indicate a specific time granularity (or multiple): + +.. code-block:: python + + >>> present = arrow.utcnow() + >>> future = present.shift(minutes=66) + >>> future.humanize(present, granularity="minute") + 'in 66 minutes' + >>> future.humanize(present, granularity=["hour", "minute"]) + 'in an hour and 6 minutes' + Support for a growing number of locales (see ``locales.py`` for supported languages): .. code-block:: python diff --git a/setup.cfg b/setup.cfg index 2ab246006..08cae3ddf 100644 --- a/setup.cfg +++ b/setup.cfg @@ -25,7 +25,7 @@ ignore = E203,E501,W503 line_length = 88 multi_line_output = 3 include_trailing_comma = true -known_third_party = chai,dateparser,dateutil,mock,pytz,setuptools,simplejson,six +known_third_party = chai,dateparser,dateutil,mock,pytz,setuptools,simplejson [bdist_wheel] universal = 1 From a593b067235bdb981ed3706065ca19c500e25150 Mon Sep 17 00:00:00 2001 From: Howard Willard Date: Mon, 9 Dec 2019 13:04:46 -0500 Subject: [PATCH 4/4] Style fixes, update test, documentation additions --- arrow/arrow.py | 23 +++++++++++++++-------- docs/index.rst | 16 ++++++++++++++++ tests/arrow_tests.py | 17 ++++++++++++----- 3 files changed, 43 insertions(+), 13 deletions(-) diff --git a/arrow/arrow.py b/arrow/arrow.py index 483757272..cc9d04153 100644 --- a/arrow/arrow.py +++ b/arrow/arrow.py @@ -977,37 +977,44 @@ def humanize( timeframes = [] if "year" in granularity: years = sign * delta / self._SECS_PER_YEAR - delta = delta % self._SECS_PER_YEAR + delta %= self._SECS_PER_YEAR timeframes.append(["year", years]) + if "month" in granularity: months = sign * delta / self._SECS_PER_MONTH - delta = delta % self._SECS_PER_MONTH + delta %= self._SECS_PER_MONTH timeframes.append(["month", months]) + if "week" in granularity: weeks = sign * delta / self._SECS_PER_WEEK - delta = delta % self._SECS_PER_WEEK + delta %= self._SECS_PER_WEEK timeframes.append(["week", weeks]) + if "day" in granularity: days = sign * delta / self._SECS_PER_DAY - delta = delta % self._SECS_PER_DAY + delta %= self._SECS_PER_DAY timeframes.append(["day", days]) + if "hour" in granularity: hours = sign * delta / self._SECS_PER_HOUR - delta = delta % self._SECS_PER_HOUR + delta %= self._SECS_PER_HOUR timeframes.append(["hour", hours]) + if "minute" in granularity: minutes = sign * delta / self._SECS_PER_MINUTE - delta = delta % self._SECS_PER_MINUTE + delta %= self._SECS_PER_MINUTE timeframes.append(["minute", minutes]) + if "second" in granularity: seconds = sign * delta timeframes.append(["second", seconds]) + if len(timeframes) < len(granularity): raise AttributeError( "Invalid level of granularity. Please select between 'second', 'minute', 'hour', 'day', 'week', 'month' or 'year'" ) - for index in range(len(timeframes)): - gran, delta = timeframes[index] + + for index, (_, delta) in enumerate(timeframes): if trunc(abs(delta)) != 1: timeframes[index][0] += "s" return locale.describe_multi(timeframes, only_distance=only_distance) diff --git a/docs/index.rst b/docs/index.rst index af1e39ccd..d3371ebcb 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -208,6 +208,18 @@ Or another Arrow, or datetime: >>> future.humanize(present) 'in 2 hours' +Indicate time as relative or include only the distance + +.. code-block:: python + + >>> present = arrow.utcnow() + >>> future = present.shift(hours=2) + >>> future.humanize(present) + 'in 2 hours' + >>> future.humanize(present, only_distance=True) + '2 hours' + + Indicate a specific time granularity (or multiple): .. code-block:: python @@ -218,6 +230,10 @@ Indicate a specific time granularity (or multiple): 'in 66 minutes' >>> future.humanize(present, granularity=["hour", "minute"]) 'in an hour and 6 minutes' + >>> present.humanize(future, granularity=["hour", "minute"]) + 'an hour and 6 minutes ago' + >>> future.humanize(present, only_distance=True, granularity=["hour", "minute"]) + 'an hour and 6 minutes' Support for a growing number of locales (see ``locales.py`` for supported languages): diff --git a/tests/arrow_tests.py b/tests/arrow_tests.py index 22587cb13..5ab01f4e5 100644 --- a/tests/arrow_tests.py +++ b/tests/arrow_tests.py @@ -1531,15 +1531,22 @@ def test_multiple_granularity(self): later108onlydistance = self.now.shift(seconds=10 ** 8) self.assertEqual( - self.now.humanize(later108onlydistance, granularity=["year"]), "3 years ago" + self.now.humanize( + later108onlydistance, only_distance=True, granularity=["year"] + ), + "3 years", ) self.assertEqual( - self.now.humanize(later108onlydistance, granularity=["month", "week"]), - "37 months and 4 weeks ago", + self.now.humanize( + later108onlydistance, only_distance=True, granularity=["month", "week"] + ), + "37 months and 4 weeks", ) self.assertEqual( - self.now.humanize(later108onlydistance, granularity=["year", "second"]), - "3 years and seconds ago", + self.now.humanize( + later108onlydistance, only_distance=True, granularity=["year", "second"] + ), + "3 years and seconds", ) def test_seconds(self):