Repository: sopel-irc/sopel

Issue:
tools.time feature: "Raw" mode for seconds_to_human()
It's cute that `tools.time.seconds_to_human()` takes care of the future/past grammar for you (adding "in&nbsp;" to the front or "&nbsp;ago" to the back, depending on the delta's sign). But it makes using that function really annoying in some cases, when it should be the perfect tool.

For the use cases I'm looking at, the most useful thing would be a "raw" mode that returns the intermediate list of units, rather than the fully-formatted string. In implementing this, we could actually simplify the code around here and eliminate the `split(', ')` below it: https://github.com/sopel-irc/sopel/blob/3959137b8e91ab392d7dccecec5cebd5765b8f0d/sopel/tools/time.py#L243-L250

Related: #1798, #1799. Not all of these will necessarily be implemented. Right now they're all just ideas, and picking the right one or two could make the API flexible and intuitive enough that we don't need to bake every little feature into it.


Discussion:
This actually should be low-hanging fruit, and I'll self-assign it for the last two weeks of Hacktoberfest to (hopefully) motivate myself to take care of it.

Resolving patch:
diff --git a/sopel/tools/time.py b/sopel/tools/time.py
--- a/sopel/tools/time.py
+++ b/sopel/tools/time.py
@@ -7,6 +7,15 @@
 import pytz
 
 
+# various time units measured in seconds; approximated for months and years
+SECONDS = 1
+MINUTES = 60 * SECONDS
+HOURS = 60 * MINUTES
+DAYS = 24 * HOURS
+MONTHS = int(30.5 * DAYS)
+YEARS = 365 * DAYS
+
+
 def validate_timezone(zone):
     """Return an IETF timezone from the given IETF zone or common abbreviation.
 
@@ -199,23 +208,121 @@ def format_time(db=None, config=None, zone=None, nick=None, channel=None,
         return time.astimezone(zone).strftime(tformat)
 
 
-def seconds_to_human(secs):
+def seconds_to_split(seconds):
+    """Split an amount of ``seconds`` into years, months, days, etc.
+
+    :param int seconds: amount of time in seconds
+    :return: the time split into a tuple of years, months, days, hours,
+             minutes, and seconds
+    :rtype: :class:`tuple`
+
+    Examples::
+
+        >>> seconds_to_split(7800)
+        (0, 0, 0, 2, 10, 0)
+        >>> seconds_to_split(143659)
+        (0, 0, 1, 15, 54, 19)
+
+    """
+    years, seconds_left = divmod(int(seconds), YEARS)
+    months, seconds_left = divmod(seconds_left, MONTHS)
+    days, seconds_left = divmod(seconds_left, DAYS)
+    hours, seconds_left = divmod(seconds_left, HOURS)
+    minutes, seconds_left = divmod(seconds_left, MINUTES)
+
+    return years, months, days, hours, minutes, seconds_left
+
+
+def get_time_unit(years=0, months=0, days=0, hours=0, minutes=0, seconds=0):
+    """Map a time in (y, m, d, h, min, s) to its labels.
+
+    :param int years: number of years
+    :param int months: number of months
+    :param int days: number of days
+    :param int hours: number of hours
+    :param int minutes: number of minutes
+    :param int seconds: number of seconds
+    :return: a tuple of 2-value tuples, each for a time amount and its label
+    :rtype: :class:`tuple`
+
+    This helper function get a time split in years, months, days, hours,
+    minutes, and seconds to return a tuple with the correct label for each
+    unit. The label is pluralized and account for zéro, one, and more than one
+    value per unit::
+
+        >>> get_time_unit(days=1, hours=15, minutes=54, seconds=19)
+        (
+            (0, 'years'),
+            (0, 'months'),
+            (1, 'day'),
+            (15, 'hours'),
+            (54, 'minutes'),
+            (19, 'seconds'),
+        )
+
+    This function can be used with :func:`seconds_to_split`::
+
+        >>> get_time_unit(*seconds_to_split(143659))
+        # ... same result as the example above
+
+    .. note::
+
+        This function always returns a tuple with **all** time units, even when
+        their amount is 0 (which is their default value).
+
+    """
+    years_text = "year{}".format("s" if years != 1 else "")
+    months_text = "month{}".format("s" if months != 1 else "")
+    days_text = "day{}".format("s" if days != 1 else "")
+    hours_text = "hour{}".format("s" if hours != 1 else "")
+    minutes_text = "minute{}".format("s" if minutes != 1 else "")
+    seconds_text = "second{}".format("s" if seconds != 1 else "")
+
+    return (
+        (years, years_text),
+        (months, months_text),
+        (days, days_text),
+        (hours, hours_text),
+        (minutes, minutes_text),
+        (seconds, seconds_text),
+    )
+
+
+def seconds_to_human(secs, granularity=2):
     """Format :class:`~datetime.timedelta` as a human-readable relative time.
 
     :param secs: time difference to format
     :type secs: :class:`~datetime.timedelta` or integer
+    :param int granularity: number of time units to return (default to 2)
 
     Inspiration for function structure from:
     https://gist.github.com/Highstaker/280a09591df4a5fb1363b0bbaf858f0d
 
-    Example outputs are:
+    Examples::
+
+        >>> seconds_to_human(65707200)
+        '2 years, 1 month ago'
+        >>> seconds_to_human(-17100)  # negative amount
+        'in 4 hours, 45 minutes'
+        >>> seconds_to_human(-709200)
+        'in 8 days, 5 hours'
+        >>> seconds_to_human(39441600, 1)  # 1 year + 3 months
+        '1 year ago'
+
+    This function can be used with a :class:`~datetime.timedelta`::
+
+        >>> from datetime import timedelta
+        >>> seconds_to_human(timedelta(days=42, seconds=278))
+        '1 month, 11 days ago'
 
-    .. code-block:: text
+    The ``granularity`` argument controls how detailed the result is::
 
-        2 years, 1 month ago
-        in 4 hours, 45 minutes
-        in 8 days, 5 hours
-        1 year ago
+        >>> seconds_to_human(3672)  # 2 by default
+        '1 hour, 1 minute ago'
+        >>> seconds_to_human(3672, granularity=3)
+        '1 hour, 1 minute, 12 seconds ago'
+        >>> seconds_to_human(3672, granularity=1)
+        '1 hour ago'
 
     """
     if isinstance(secs, datetime.timedelta):
@@ -232,30 +339,11 @@ def seconds_to_human(secs):
         # zero is a special case that the algorithm below won't handle correctly (#1841)
         result = "0 seconds"
     else:
-        years = secs // 31536000
-        months = (secs - years * 31536000) // 2635200
-        days = (secs - years * 31536000 - months * 2635200) // 86400
-        hours = (secs - years * 31536000 - months * 2635200 - days * 86400) // 3600
-        minutes = (secs - years * 31536000 - months * 2635200 - days * 86400 - hours * 3600) // 60
-        seconds = secs - years * 31536000 - months * 2635200 - days * 86400 - hours * 3600 - minutes * 60
-
-        years_text = "year{}".format("s" if years != 1 else "")
-        months_text = "month{}".format("s" if months != 1 else "")
-        days_text = "day{}".format("s" if days != 1 else "")
-        hours_text = "hour{}".format("s" if hours != 1 else "")
-        minutes_text = "minute{}".format("s" if minutes != 1 else "")
-        seconds_text = "second{}".format("s" if seconds != 1 else "")
-
-        result = ", ".join(filter(lambda x: bool(x), [
-            "{0} {1}".format(years, years_text) if years else "",
-            "{0} {1}".format(months, months_text) if months else "",
-            "{0} {1}".format(days, days_text) if days else "",
-            "{0} {1}".format(hours, hours_text) if hours else "",
-            "{0} {1}".format(minutes, minutes_text) if minutes else "",
-            "{0} {1}".format(seconds, seconds_text) if seconds else ""
-        ]))
-        # Granularity
-        result = ", ".join(result.split(", ")[:2])
+        result = ", ".join([
+            "%s %s" % (value, unit)
+            for value, unit in get_time_unit(*seconds_to_split(secs))
+            if value
+        ][:granularity])
 
     if future is False:
         result += " ago"


Test patch:
diff --git a/test/tools/test_tools_time.py b/test/tools/test_tools_time.py
--- a/test/tools/test_tools_time.py
+++ b/test/tools/test_tools_time.py
@@ -47,13 +47,18 @@ def test_validate_format_none():
         time.validate_format(None)
 
 
-def test_time_timedelta_formatter():
+def test_seconds_to_human():
+    payload = 0
+    assert time.seconds_to_human(payload) == '0 seconds ago'
+
     payload = 10000
     assert time.seconds_to_human(payload) == '2 hours, 46 minutes ago'
 
     payload = -2938124
     assert time.seconds_to_human(payload) == 'in 1 month, 3 days'
 
+
+def test_seconds_to_human_timedelta():
     payload = datetime.timedelta(hours=4)
     assert time.seconds_to_human(payload) == '4 hours ago'
 
@@ -74,3 +79,72 @@ def test_time_timedelta_formatter():
 
     payload = datetime.timedelta(days=365, seconds=5)
     assert time.seconds_to_human(payload) == '1 year, 5 seconds ago'
+
+
+def test_seconds_to_human_granularity():
+    assert time.seconds_to_human(3672) == '1 hour, 1 minute ago'
+    assert time.seconds_to_human(3672, 3) == '1 hour, 1 minute, 12 seconds ago'
+    assert time.seconds_to_human(3672, 1) == '1 hour ago'
+
+    assert time.seconds_to_human(-3672) == 'in 1 hour, 1 minute'
+    assert time.seconds_to_human(-3672, 3) == 'in 1 hour, 1 minute, 12 seconds'
+    assert time.seconds_to_human(-3672, 1) == 'in 1 hour'
+
+
+def test_seconds_to_split():
+    assert time.seconds_to_split(364465915) == (11, 6, 20, 8, 31, 55)
+    assert time.seconds_to_split(15) == (0, 0, 0, 0, 0, 15)
+    assert time.seconds_to_split(120) == (0, 0, 0, 0, 2, 0)
+    assert time.seconds_to_split(7800) == (0, 0, 0, 2, 10, 0)
+    assert time.seconds_to_split(143659) == (0, 0, 1, 15, 54, 19)
+    assert time.seconds_to_split(128000) == (0, 0, 1, 11, 33, 20)
+    assert time.seconds_to_split(3000000) == (0, 1, 4, 5, 20, 0)
+
+
+def test_get_time_unit():
+    assert time.get_time_unit(days=1, hours=15, minutes=54, seconds=19) == (
+        (0, 'years'),
+        (0, 'months'),
+        (1, 'day'),
+        (15, 'hours'),
+        (54, 'minutes'),
+        (19, 'seconds'),
+    )
+    assert time.get_time_unit(years=1) == (
+        (1, 'year'),
+        (0, 'months'),
+        (0, 'days'),
+        (0, 'hours'),
+        (0, 'minutes'),
+        (0, 'seconds'),
+    )
+    assert time.get_time_unit(
+        years=1,
+        months=1,
+        days=1,
+        hours=1,
+        minutes=1,
+        seconds=1
+    ) == (
+        (1, 'year'),
+        (1, 'month'),
+        (1, 'day'),
+        (1, 'hour'),
+        (1, 'minute'),
+        (1, 'second'),
+    )
+    assert time.get_time_unit(
+        years=10,
+        months=10,
+        days=10,
+        hours=10,
+        minutes=10,
+        seconds=10
+    ) == (
+        (10, 'years'),
+        (10, 'months'),
+        (10, 'days'),
+        (10, 'hours'),
+        (10, 'minutes'),
+        (10, 'seconds'),
+    )