Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for multiple granularities in humanize #722

Merged
merged 5 commits into from Dec 9, 2019
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
66 changes: 58 additions & 8 deletions arrow/arrow.py
Expand Up @@ -56,6 +56,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(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
Expand Down Expand Up @@ -844,7 +850,8 @@ def humanize(
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: (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::

Expand Down Expand Up @@ -877,6 +884,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)
Expand Down Expand Up @@ -937,23 +947,23 @@ def humanize(
years = sign * int(max(delta / 31536000, 2))
return locale.describe("years", years, only_distance=only_distance)

else:
elif util.isstr(granularity):
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'"
Expand All @@ -962,6 +972,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:
hwillard98 marked this conversation as resolved.
Show resolved Hide resolved
years = sign * delta / self._SECS_PER_YEAR
delta = delta % self._SECS_PER_YEAR
hwillard98 marked this conversation as resolved.
Show resolved Hide resolved
timeframes.append(["year", years])
if "month" in granularity:
months = sign * delta / 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 = 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
timeframes.append(["day", days])
if "hour" in granularity:
hours = sign * delta / 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 = 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)):
hwillard98 marked this conversation as resolved.
Show resolved Hide resolved
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(
Expand Down
36 changes: 36 additions & 0 deletions arrow/locales.py
Expand Up @@ -51,6 +51,7 @@ class Locale(object):

past = None
future = None
and_word = None

month_names = []
month_abbreviations = []
Expand Down Expand Up @@ -78,6 +79,26 @@ 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, (timeframe, delta) in enumerate(timeframes):
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.

Expand Down Expand Up @@ -200,6 +221,7 @@ class EnglishLocale(Locale):

past = "{0} ago"
future = "in {0}"
and_word = "and"

timeframes = {
"now": "just now",
Expand Down Expand Up @@ -295,6 +317,7 @@ class ItalianLocale(Locale):
names = ["it", "it_it"]
past = "{0} fa"
future = "tra {0}"
and_word = "e"

timeframes = {
"now": "adesso",
Expand Down Expand Up @@ -364,6 +387,7 @@ class SpanishLocale(Locale):
names = ["es", "es_es"]
past = "hace {0}"
future = "en {0}"
and_word = "y"

timeframes = {
"now": "ahora",
Expand Down Expand Up @@ -437,6 +461,7 @@ class FrenchLocale(Locale):
names = ["fr", "fr_fr"]
past = "il y a {0}"
future = "dans {0}"
and_word = "et"

timeframes = {
"now": "maintenant",
Expand Down Expand Up @@ -514,6 +539,7 @@ class GreekLocale(Locale):

past = "{0} πριν"
future = "σε {0}"
and_word = "και"

timeframes = {
"now": "τώρα",
Expand Down Expand Up @@ -639,6 +665,7 @@ class SwedishLocale(Locale):

past = "för {0} sen"
future = "om {0}"
and_word = "och"

timeframes = {
"now": "just nu",
Expand Down Expand Up @@ -1527,6 +1554,7 @@ class DeutschBaseLocale(Locale):

past = "vor {0}"
future = "in {0}"
and_word = "und"

timeframes = {
"now": "gerade eben",
Expand Down Expand Up @@ -1778,6 +1806,7 @@ class PortugueseLocale(Locale):

past = "há {0}"
future = "em {0}"
and_word = "e"

timeframes = {
"now": "agora",
Expand Down Expand Up @@ -2495,6 +2524,7 @@ class DanishLocale(Locale):

past = "for {0} siden"
future = "efter {0}"
and_word = "og"

timeframes = {
"now": "lige nu",
Expand Down Expand Up @@ -2802,6 +2832,7 @@ class SlovakLocale(Locale):

past = "Pred {0}"
future = "O {0}"
and_word = "a"

month_names = [
"",
Expand Down Expand Up @@ -3101,6 +3132,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",
Expand Down Expand Up @@ -3685,6 +3717,7 @@ class RomanianLocale(Locale):

past = "{0} în urmă"
future = "peste {0}"
and_word = "și"

timeframes = {
"now": "acum",
Expand Down Expand Up @@ -3750,6 +3783,7 @@ class SlovenianLocale(Locale):

past = "pred {0}"
future = "čez {0}"
and_word = "in"

timeframes = {
"now": "zdaj",
Expand Down Expand Up @@ -3820,6 +3854,7 @@ class IndonesianLocale(Locale):

past = "{0} yang lalu"
future = "dalam {0}"
and_word = "dan"

timeframes = {
"now": "baru saja",
Expand Down Expand Up @@ -3957,6 +3992,7 @@ class EstonianLocale(Locale):

past = "{0} tagasi"
future = "{0} pärast"
and_word = "ja"

timeframes = {
"now": {"past": "just nüüd", "future": "just nüüd"},
Expand Down
11 changes: 11 additions & 0 deletions docs/index.rst
Expand Up @@ -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'
hwillard98 marked this conversation as resolved.
Show resolved Hide resolved

hwillard98 marked this conversation as resolved.
Show resolved Hide resolved
Support for a growing number of locales (see ``locales.py`` for supported languages):

.. code-block:: python
Expand Down
60 changes: 60 additions & 0 deletions tests/arrow_tests.py
Expand Up @@ -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")
Expand Down Expand Up @@ -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",
)
hwillard98 marked this conversation as resolved.
Show resolved Hide resolved

def test_seconds(self):

later = self.now.shift(seconds=10)
Expand Down