aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar MarkKoz <[email protected]>2021-08-06 14:20:52 -0700
committerGravatar MarkKoz <[email protected]>2021-08-06 14:20:52 -0700
commit2004477e12c72e4739ea1b1f192fb2c12eac69d0 (patch)
tree6915762ac193d7b9d40421ef49bd026f3c074d30
parentTime: remove DISCORD_TIMESTAMP_REGEX (diff)
Time: add overload to pass 2 timestamps to humanize_delta
Remove the need for the caller to create a `relativedelta` from 2 timestamps before calling `humanize_delta`. This is especially convenient for cases where the original inputs aren't `datetime`s since `relativedelta` only accepts those.
-rw-r--r--bot/exts/moderation/defcon.py4
-rw-r--r--bot/exts/moderation/infraction/management.py6
-rw-r--r--bot/exts/moderation/modlog.py2
-rw-r--r--bot/utils/time.py110
-rw-r--r--tests/bot/utils/test_time.py13
5 files changed, 105 insertions, 30 deletions
diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py
index 263e8136e..178be734d 100644
--- a/bot/exts/moderation/defcon.py
+++ b/bot/exts/moderation/defcon.py
@@ -254,8 +254,8 @@ class Defcon(Cog):
expiry_message = ""
if expiry:
- activity_duration = relativedelta(expiry, arrow.utcnow().datetime)
- expiry_message = f" for the next {time.humanize_delta(activity_duration, max_units=2)}"
+ formatted_expiry = time.humanize_delta(expiry, max_units=2)
+ expiry_message = f" for the next {formatted_expiry}"
if self.threshold:
channel_message = (
diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py
index fa1ebdadc..0dfd2d759 100644
--- a/bot/exts/moderation/infraction/management.py
+++ b/bot/exts/moderation/infraction/management.py
@@ -1,9 +1,7 @@
import textwrap
import typing as t
-import arrow
import discord
-from dateutil.relativedelta import relativedelta
from discord.ext import commands
from discord.ext.commands import Context
from discord.utils import escape_markdown
@@ -371,9 +369,7 @@ class ModManagement(commands.Cog):
if expires_at is None:
duration = "*Permanent*"
else:
- start = arrow.get(inserted_at).datetime
- end = arrow.get(expires_at).datetime
- duration = time.humanize_delta(relativedelta(start, end))
+ duration = time.humanize_delta(inserted_at, expires_at)
# Format `dm_sent`
if dm_sent is None:
diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py
index d5e209d81..2c01a4a21 100644
--- a/bot/exts/moderation/modlog.py
+++ b/bot/exts/moderation/modlog.py
@@ -713,7 +713,7 @@ class ModLog(Cog, name="ModLog"):
# datetime as the baseline and create a human-readable delta between this edit event
# and the last time the message was edited
timestamp = msg_before.edited_at
- delta = time.humanize_delta(relativedelta(msg_after.edited_at, msg_before.edited_at))
+ delta = time.humanize_delta(msg_after.edited_at, msg_before.edited_at)
footer = f"Last edited {delta} ago"
else:
# Message was not previously edited, use the created_at datetime as the baseline, no
diff --git a/bot/utils/time.py b/bot/utils/time.py
index 21d26db7d..7e314a870 100644
--- a/bot/utils/time.py
+++ b/bot/utils/time.py
@@ -2,7 +2,7 @@ import datetime
import re
from enum import Enum
from time import struct_time
-from typing import Optional, Union
+from typing import Optional, Union, overload
import arrow
from dateutil.relativedelta import relativedelta
@@ -78,15 +78,99 @@ def discord_timestamp(timestamp: Timestamp, format: TimestampFormats = Timestamp
return f"<t:{timestamp}:{format.value}>"
-def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: int = 6) -> str:
+@overload
+def humanize_delta(
+ arg1: Union[relativedelta, Timestamp],
+ /,
+ *,
+ precision: str = "seconds",
+ max_units: int = 6,
+ absolute: bool = True,
+) -> str:
+ ...
+
+
+@overload
+def humanize_delta(
+ end: Timestamp,
+ start: Timestamp,
+ /,
+ *,
+ precision: str = "seconds",
+ max_units: int = 6,
+ absolute: bool = True,
+) -> str:
+ ...
+
+
+def humanize_delta(
+ *args,
+ precision: str = "seconds",
+ max_units: int = 6,
+ absolute: bool = True,
+) -> str:
"""
- Returns a human-readable version of the relativedelta.
+ Return a human-readable version of a time duration.
- precision specifies the smallest unit of time to include (e.g. "seconds", "minutes").
- max_units specifies the maximum number of units of time to include (e.g. 1 may include days but not hours).
+ `precision` is the smallest unit of time to include (e.g. "seconds", "minutes").
+
+ `max_units` is the maximum number of units of time to include.
+ Count units from largest to smallest (e.g. count days before months).
+
+ Use the absolute value of the duration if `absolute` is True.
+
+ Usage:
+
+ **One** `relativedelta` object, to humanize the duration represented by it:
+
+ >>> humanize_delta(relativedelta(years=12, months=6))
+ '12 years and 6 months'
+
+ Note that `leapdays` and absolute info (singular names) will be ignored during humanization.
+
+ **One** timestamp of a type supported by the single-arg `arrow.get()`, except for `tzinfo`,
+ to humanize the duration between it and the current time:
+
+ >>> humanize_delta('2021-08-06T12:43:01Z', absolute=True) # now = 2021-08-06T12:33:33Z
+ '9 minutes and 28 seconds'
+
+ >>> humanize_delta('2021-08-06T12:43:01Z', absolute=False) # now = 2021-08-06T12:33:33Z
+ '-9 minutes and -28 seconds'
+
+ **Two** timestamps, each of a type supported by the single-arg `arrow.get()`, except for
+ `tzinfo`, to humanize the duration between them:
+
+ >>> humanize_delta(datetime.datetime(2020, 1, 1), '2021-01-01T12:00:00Z', absolute=False)
+ '1 year and 12 hours'
+
+ >>> humanize_delta('2021-01-01T12:00:00Z', datetime.datetime(2020, 1, 1), absolute=False)
+ '-1 years and -12 hours'
+
+ Note that order of the arguments can result in a different output even if `absolute` is True:
+
+ >>> x = datetime.datetime(3000, 11, 1)
+ >>> y = datetime.datetime(3000, 9, 2)
+ >>> humanize_delta(y, x, absolute=True), humanize_delta(x, y, absolute=True)
+ ('1 month and 30 days', '1 month and 29 days')
+
+ This is due to the nature of `relativedelta`; it does not represent a fixed period of time.
+ Instead, it's relative to the `datetime` to which it's added to get the other `datetime`.
+ In the example, the difference arises because all months don't have the same number of days.
"""
+ if len(args) == 1 and isinstance(args[0], relativedelta):
+ delta = args[0]
+ elif 1 <= len(args) <= 2:
+ end = arrow.get(args[0])
+ start = arrow.get(args[1]) if len(args) == 2 else arrow.utcnow()
+
+ delta = relativedelta(end.datetime, start.datetime)
+ if absolute:
+ delta = abs(delta)
+ else:
+ raise ValueError(f"Received {len(args)} positional arguments, but expected 1 or 2.")
+
if max_units <= 0:
- raise ValueError("max_units must be positive")
+ raise ValueError("max_units must be positive.")
units = (
("years", delta.years),
@@ -174,25 +258,17 @@ def format_with_duration(
Return `timestamp` formatted as a discord timestamp with the timestamp duration since `other_timestamp`.
`timestamp` and `other_timestamp` can be any type supported by the single-arg `arrow.get()`,
- except for a `tzinfo`. Use the current time if `other_timestamp` is falsy or unspecified.
+ except for a `tzinfo`. Use the current time if `other_timestamp` is None or unspecified.
- `max_units` specifies the maximum number of units of time to include in the duration. For
- example, a value of 1 may include days but not hours.
+ `max_units` is forwarded to `time.humanize_delta`. See its documentation for more information.
Return None if `timestamp` is falsy.
"""
if not timestamp:
return None
- timestamp = arrow.get(timestamp)
- if not other_timestamp:
- other_timestamp = arrow.utcnow()
- else:
- other_timestamp = arrow.get(other_timestamp)
-
formatted_timestamp = discord_timestamp(timestamp)
- delta = abs(relativedelta(timestamp.datetime, other_timestamp.datetime))
- duration = humanize_delta(delta, max_units=max_units)
+ duration = humanize_delta(timestamp, other_timestamp, max_units=max_units)
return f"{formatted_timestamp} ({duration})"
diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py
index 027e2052e..e235f9b70 100644
--- a/tests/bot/utils/test_time.py
+++ b/tests/bot/utils/test_time.py
@@ -13,13 +13,15 @@ class TimeTests(unittest.TestCase):
"""humanize_delta should be able to handle unknown units, and will not abort."""
# Does not abort for unknown units, as the unit name is checked
# against the attribute of the relativedelta instance.
- self.assertEqual(time.humanize_delta(relativedelta(days=2, hours=2), 'elephants', 2), '2 days and 2 hours')
+ actual = time.humanize_delta(relativedelta(days=2, hours=2), precision='elephants', max_units=2)
+ self.assertEqual(actual, '2 days and 2 hours')
def test_humanize_delta_handle_high_units(self):
"""humanize_delta should be able to handle very high units."""
# Very high maximum units, but it only ever iterates over
# each value the relativedelta might have.
- self.assertEqual(time.humanize_delta(relativedelta(days=2, hours=2), 'hours', 20), '2 days and 2 hours')
+ actual = time.humanize_delta(relativedelta(days=2, hours=2), precision='hours', max_units=20)
+ self.assertEqual(actual, '2 days and 2 hours')
def test_humanize_delta_should_normal_usage(self):
"""Testing humanize delta."""
@@ -32,7 +34,8 @@ class TimeTests(unittest.TestCase):
for delta, precision, max_units, expected in test_cases:
with self.subTest(delta=delta, precision=precision, max_units=max_units, expected=expected):
- self.assertEqual(time.humanize_delta(delta, precision, max_units), expected)
+ actual = time.humanize_delta(delta, precision=precision, max_units=max_units)
+ self.assertEqual(actual, expected)
def test_humanize_delta_raises_for_invalid_max_units(self):
"""humanize_delta should raises ValueError('max_units must be positive') for invalid max_units."""
@@ -40,8 +43,8 @@ class TimeTests(unittest.TestCase):
for max_units in test_cases:
with self.subTest(max_units=max_units), self.assertRaises(ValueError) as error:
- time.humanize_delta(relativedelta(days=2, hours=2), 'hours', max_units)
- self.assertEqual(str(error.exception), 'max_units must be positive')
+ time.humanize_delta(relativedelta(days=2, hours=2), precision='hours', max_units=max_units)
+ self.assertEqual(str(error.exception), 'max_units must be positive.')
def test_format_with_duration_none_expiry(self):
"""format_with_duration should work for None expiry."""