From 6b280b19ed5c564e824e55a1ec9bb13120c0193d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 5 Aug 2021 16:13:49 -0700 Subject: Time: remove RFC1123 support It's not used anywhere and hasn't been for a very long time. --- tests/bot/utils/test_time.py | 7 ------- 1 file changed, 7 deletions(-) (limited to 'tests') diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index a3dcbfc0a..9c52fed27 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -43,13 +43,6 @@ class TimeTests(unittest.TestCase): time.humanize_delta(relativedelta(days=2, hours=2), 'hours', max_units) self.assertEqual(str(error.exception), 'max_units must be positive') - def test_parse_rfc1123(self): - """Testing parse_rfc1123.""" - self.assertEqual( - time.parse_rfc1123('Sun, 15 Sep 2019 12:00:00 GMT'), - datetime(2019, 9, 15, 12, 0, 0, tzinfo=timezone.utc) - ) - def test_format_infraction(self): """Testing format_infraction.""" self.assertEqual(time.format_infraction('2019-12-12T00:01:00Z'), '') -- cgit v1.2.3 From 93742d718dcb4aee72ef5d20ca570b6200f07d2d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 5 Aug 2021 19:14:08 -0700 Subject: Time: rename format_infraction_with_duration It's not necessarily tied to infractions anymore. --- bot/exts/moderation/infraction/_scheduler.py | 4 ++-- bot/exts/moderation/infraction/management.py | 2 +- bot/exts/moderation/stream.py | 2 +- bot/utils/time.py | 18 +++++++++--------- tests/bot/utils/test_time.py | 18 +++++++++--------- 5 files changed, 22 insertions(+), 22 deletions(-) (limited to 'tests') diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 57aa2d9b6..9d4d58e2e 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -136,7 +136,7 @@ class InfractionScheduler: infr_type = infraction["type"] icon = _utils.INFRACTION_ICONS[infr_type][0] reason = infraction["reason"] - expiry = time.format_infraction_with_duration(infraction["expires_at"]) + expiry = time.format_with_duration(infraction["expires_at"]) id_ = infraction['id'] if user_reason is None: @@ -387,7 +387,7 @@ class InfractionScheduler: log.info(f"Marking infraction #{id_} as inactive (expired).") expiry = dateutil.parser.isoparse(expiry) if expiry else None - created = time.format_infraction_with_duration(inserted_at, expiry) + created = time.format_with_duration(inserted_at, expiry) log_content = None log_text = { diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index fb5af9eaa..dd994a2d2 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -150,7 +150,7 @@ class ModManagement(commands.Cog): confirm_messages.append("marked as permanent") elif duration is not None: request_data['expires_at'] = duration.isoformat() - expiry = time.format_infraction_with_duration(request_data['expires_at']) + expiry = time.format_with_duration(request_data['expires_at']) confirm_messages.append(f"set to expire on {expiry}") else: confirm_messages.append("expiry unchanged") diff --git a/bot/exts/moderation/stream.py b/bot/exts/moderation/stream.py index 5a7b12295..bc9d35714 100644 --- a/bot/exts/moderation/stream.py +++ b/bot/exts/moderation/stream.py @@ -133,7 +133,7 @@ class Stream(commands.Cog): await ctx.send(f"{Emojis.check_mark} {member.mention} can now stream until {time.discord_timestamp(duration)}.") # Convert here for nicer logging - revoke_time = time.format_infraction_with_duration(str(duration)) + revoke_time = time.format_with_duration(str(duration)) log.debug(f"Successfully gave {member} ({member.id}) permission to stream until {revoke_time}.") @commands.command(aliases=("pstream",)) diff --git a/bot/utils/time.py b/bot/utils/time.py index 60720031a..13dfc6fb7 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -169,26 +169,26 @@ def format_infraction(timestamp: str) -> str: return discord_timestamp(dateutil.parser.isoparse(timestamp)) -def format_infraction_with_duration( - date_to: Optional[str], - date_from: Optional[datetime.datetime] = None, +def format_with_duration( + timestamp: Optional[str], + other_timestamp: Optional[datetime.datetime] = None, max_units: int = 2, ) -> Optional[str]: """ - Return `date_to` formatted as a discord timestamp with the timestamp duration since `date_from`. + Return `timestamp` formatted as a discord timestamp with the timestamp duration since `other_timestamp`. `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. """ - if not date_to: + if not timestamp: return None - date_to_formatted = format_infraction(date_to) + date_to_formatted = format_infraction(timestamp) - date_from = date_from or datetime.datetime.now(datetime.timezone.utc) - date_to = dateutil.parser.isoparse(date_to).replace(microsecond=0) + other_timestamp = other_timestamp or datetime.datetime.now(datetime.timezone.utc) + timestamp = dateutil.parser.isoparse(timestamp).replace(microsecond=0) - delta = abs(relativedelta(date_to, date_from)) + delta = abs(relativedelta(timestamp, other_timestamp)) duration = humanize_delta(delta, max_units=max_units) duration_formatted = f" ({duration})" if duration else "" diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index 9c52fed27..02b5f8c17 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -47,8 +47,8 @@ class TimeTests(unittest.TestCase): """Testing format_infraction.""" self.assertEqual(time.format_infraction('2019-12-12T00:01:00Z'), '') - def test_format_infraction_with_duration_none_expiry(self): - """format_infraction_with_duration should work for None expiry.""" + def test_format_with_duration_none_expiry(self): + """format_with_duration should work for None expiry.""" test_cases = ( (None, None, None, None), @@ -60,10 +60,10 @@ class TimeTests(unittest.TestCase): for expiry, date_from, max_units, expected in test_cases: with self.subTest(expiry=expiry, date_from=date_from, max_units=max_units, expected=expected): - self.assertEqual(time.format_infraction_with_duration(expiry, date_from, max_units), expected) + self.assertEqual(time.format_with_duration(expiry, date_from, max_units), expected) - def test_format_infraction_with_duration_custom_units(self): - """format_infraction_with_duration should work for custom max_units.""" + def test_format_with_duration_custom_units(self): + """format_with_duration should work for custom max_units.""" test_cases = ( ('3000-12-12T00:01:00Z', datetime(3000, 12, 11, 12, 5, 5, tzinfo=timezone.utc), 6, ' (11 hours, 55 minutes and 55 seconds)'), @@ -73,10 +73,10 @@ class TimeTests(unittest.TestCase): for expiry, date_from, max_units, expected in test_cases: with self.subTest(expiry=expiry, date_from=date_from, max_units=max_units, expected=expected): - self.assertEqual(time.format_infraction_with_duration(expiry, date_from, max_units), expected) + self.assertEqual(time.format_with_duration(expiry, date_from, max_units), expected) - def test_format_infraction_with_duration_normal_usage(self): - """format_infraction_with_duration should work for normal usage, across various durations.""" + def test_format_with_duration_normal_usage(self): + """format_with_duration should work for normal usage, across various durations.""" utc = timezone.utc test_cases = ( ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5, tzinfo=utc), 2, @@ -98,7 +98,7 @@ class TimeTests(unittest.TestCase): for expiry, date_from, max_units, expected in test_cases: with self.subTest(expiry=expiry, date_from=date_from, max_units=max_units, expected=expected): - self.assertEqual(time.format_infraction_with_duration(expiry, date_from, max_units), expected) + self.assertEqual(time.format_with_duration(expiry, date_from, max_units), expected) def test_until_expiration_with_duration_none_expiry(self): """until_expiration should work for None expiry.""" -- cgit v1.2.3 From ea7fc62ddc8d08b6acdb00ac2d9a024fee8ad634 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 5 Aug 2021 19:26:26 -0700 Subject: Time: support more timestamp formats as arguments Remove the burden of conversion from the caller to clean up and simplify the call sites. Handle timestamp conversions internally with arrow.get. Remove format_infraction and get_time_delta because they're now obsolete. Replace the former with discord_timestamp and the latter with format_relative. --- bot/exts/moderation/infraction/_scheduler.py | 7 +- bot/exts/moderation/infraction/management.py | 4 +- bot/exts/moderation/infraction/superstarify.py | 4 +- bot/exts/moderation/stream.py | 2 +- bot/exts/moderation/watchchannels/_watchchannel.py | 4 +- bot/exts/recruitment/talentpool/_cog.py | 8 +- bot/exts/recruitment/talentpool/_review.py | 4 +- bot/exts/utils/reminders.py | 5 +- bot/utils/time.py | 94 +++++++++++----------- tests/bot/utils/test_time.py | 4 - 10 files changed, 63 insertions(+), 73 deletions(-) (limited to 'tests') diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 9d4d58e2e..47b639421 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -381,20 +381,15 @@ class InfractionScheduler: actor = infraction["actor"] type_ = infraction["type"] id_ = infraction["id"] - inserted_at = infraction["inserted_at"] - expiry = infraction["expires_at"] log.info(f"Marking infraction #{id_} as inactive (expired).") - expiry = dateutil.parser.isoparse(expiry) if expiry else None - created = time.format_with_duration(inserted_at, expiry) - log_content = None log_text = { "Member": f"<@{user_id}>", "Actor": f"<@{actor}>", "Reason": infraction["reason"], - "Created": created, + "Created": time.format_with_duration(infraction["inserted_at"], infraction["expires_at"]), } try: diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index dd994a2d2..23c6e8b92 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -150,7 +150,7 @@ class ModManagement(commands.Cog): confirm_messages.append("marked as permanent") elif duration is not None: request_data['expires_at'] = duration.isoformat() - expiry = time.format_with_duration(request_data['expires_at']) + expiry = time.format_with_duration(duration) confirm_messages.append(f"set to expire on {expiry}") else: confirm_messages.append("expiry unchanged") @@ -351,7 +351,7 @@ class ModManagement(commands.Cog): active = infraction["active"] user = infraction["user"] expires_at = infraction["expires_at"] - created = time.format_infraction(infraction["inserted_at"]) + created = time.discord_timestamp(infraction["inserted_at"]) dm_sent = infraction["dm_sent"] # Format the user string. diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py index 2e272dbb0..a037ca1be 100644 --- a/bot/exts/moderation/infraction/superstarify.py +++ b/bot/exts/moderation/infraction/superstarify.py @@ -73,7 +73,7 @@ class Superstarify(InfractionScheduler, Cog): notified = await _utils.notify_infraction( user=after, infr_type="Superstarify", - expires_at=time.format_infraction(infraction["expires_at"]), + expires_at=time.discord_timestamp(infraction["expires_at"]), reason=( "You have tried to change your nickname on the **Python Discord** server " f"from **{before.display_name}** to **{after.display_name}**, but as you " @@ -150,7 +150,7 @@ class Superstarify(InfractionScheduler, Cog): id_ = infraction["id"] forced_nick = self.get_nick(id_, member.id) - expiry_str = time.format_infraction(infraction["expires_at"]) + expiry_str = time.discord_timestamp(infraction["expires_at"]) # Apply the infraction async def action() -> None: diff --git a/bot/exts/moderation/stream.py b/bot/exts/moderation/stream.py index bc9d35714..4dccc8a7e 100644 --- a/bot/exts/moderation/stream.py +++ b/bot/exts/moderation/stream.py @@ -133,7 +133,7 @@ class Stream(commands.Cog): await ctx.send(f"{Emojis.check_mark} {member.mention} can now stream until {time.discord_timestamp(duration)}.") # Convert here for nicer logging - revoke_time = time.format_with_duration(str(duration)) + revoke_time = time.format_with_duration(duration) log.debug(f"Successfully gave {member} ({member.id}) permission to stream until {revoke_time}.") @commands.command(aliases=("pstream",)) diff --git a/bot/exts/moderation/watchchannels/_watchchannel.py b/bot/exts/moderation/watchchannels/_watchchannel.py index 106483527..ee9b6ba45 100644 --- a/bot/exts/moderation/watchchannels/_watchchannel.py +++ b/bot/exts/moderation/watchchannels/_watchchannel.py @@ -285,7 +285,7 @@ class WatchChannel(metaclass=CogABCMeta): actor = actor.display_name if actor else self.watched_users[user_id]['actor'] inserted_at = self.watched_users[user_id]['inserted_at'] - time_delta = time.get_time_delta(inserted_at) + time_delta = time.format_relative(inserted_at) reason = self.watched_users[user_id]['reason'] @@ -359,7 +359,7 @@ class WatchChannel(metaclass=CogABCMeta): if member: line += f" ({member.name}#{member.discriminator})" inserted_at = user_data['inserted_at'] - line += f", added {time.get_time_delta(inserted_at)}" + line += f", added {time.format_relative(inserted_at)}" if not member: # Cross off users who left the server. line = f"~~{line}~~" list_data["info"][user_id] = line diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 80274eaea..bbc135454 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -180,7 +180,7 @@ class TalentPool(Cog, name="Talentpool"): if member: line += f" ({member.name}#{member.discriminator})" inserted_at = user_data['inserted_at'] - line += f", added {time.get_time_delta(inserted_at)}" + line += f", added {time.format_relative(inserted_at)}" if not member: # Cross off users who left the server. line = f"~~{line}~~" if user_data['reviewed']: @@ -561,7 +561,7 @@ class TalentPool(Cog, name="Talentpool"): actor = await get_or_fetch_member(guild, actor_id) reason = site_entry["reason"] or "*None*" - created = time.format_infraction(site_entry["inserted_at"]) + created = time.discord_timestamp(site_entry["inserted_at"]) entries.append( f"Actor: {actor.mention if actor else actor_id}\nCreated: {created}\nReason: {reason}" ) @@ -570,7 +570,7 @@ class TalentPool(Cog, name="Talentpool"): active = nomination_object["active"] - start_date = time.format_infraction(nomination_object["inserted_at"]) + start_date = time.discord_timestamp(nomination_object["inserted_at"]) if active: lines = textwrap.dedent( f""" @@ -584,7 +584,7 @@ class TalentPool(Cog, name="Talentpool"): """ ) else: - end_date = time.format_infraction(nomination_object["ended_at"]) + end_date = time.discord_timestamp(nomination_object["ended_at"]) lines = textwrap.dedent( f""" =============== diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index 474f669c6..b4d177622 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -321,7 +321,7 @@ class Reviewer: infractions += ", with the last infraction issued " # Infractions were ordered by time since insertion descending. - infractions += time.get_time_delta(infraction_list[0]['inserted_at']) + infractions += time.format_relative(infraction_list[0]['inserted_at']) return f"They have {infractions}." @@ -365,7 +365,7 @@ class Reviewer: nomination_times = f"{num_entries} times" if num_entries > 1 else "once" rejection_times = f"{len(history)} times" if len(history) > 1 else "once" - end_time = time.format_relative(isoparse(history[0]['ended_at'])) + end_time = time.format_relative(history[0]['ended_at']) review = ( f"They were nominated **{nomination_times}** before" diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index bfa294809..289d00356 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -168,7 +168,7 @@ class Reminders(Cog): self.schedule_reminder(reminder) @lock_arg(LOCK_NAMESPACE, "reminder", itemgetter("id"), raise_error=True) - async def send_reminder(self, reminder: dict, expected_time: datetime = None) -> None: + async def send_reminder(self, reminder: dict, expected_time: t.Optional[time.Timestamp] = None) -> None: """Send the reminder.""" is_valid, user, channel = self.ensure_valid_reminder(reminder) if not is_valid: @@ -347,8 +347,7 @@ class Reminders(Cog): for content, remind_at, id_, mentions in reminders: # Parse and humanize the time, make it pretty :D - remind_datetime = isoparse(remind_at) - expiry = time.format_relative(remind_datetime) + expiry = time.format_relative(remind_at) mentions = ", ".join([ # Both Role and User objects have the `name` attribute diff --git a/bot/utils/time.py b/bot/utils/time.py index 13dfc6fb7..e927a5e63 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -1,10 +1,10 @@ import datetime import re from enum import Enum +from time import struct_time from typing import Optional, Union import arrow -import dateutil.parser from dateutil.relativedelta import relativedelta DISCORD_TIMESTAMP_REGEX = re.compile(r"") @@ -19,8 +19,18 @@ _DURATION_REGEX = re.compile( r"((?P\d+?) ?(seconds|second|S|s))?" ) - -ValidTimestamp = Union[int, datetime.datetime, datetime.date] +# All supported types for the single-argument overload of arrow.get(). tzinfo is excluded because +# it's too implicit of a way for the caller to specify that they want the current time. +Timestamp = Union[ + arrow.Arrow, + datetime.datetime, + datetime.date, + struct_time, + int, # POSIX timestamp + float, # POSIX timestamp + str, # ISO 8601-formatted string + tuple[int, int, int], # ISO calendar tuple +] class TimestampFormats(Enum): @@ -60,15 +70,14 @@ def _stringify_time_unit(value: int, unit: str) -> str: return f"{value} {unit}" -def discord_timestamp(timestamp: ValidTimestamp, format: TimestampFormats = TimestampFormats.DATE_TIME) -> str: - """Create and format a Discord flavored markdown timestamp.""" - # Convert each possible timestamp class to an integer. - if isinstance(timestamp, datetime.datetime): - timestamp = (timestamp - arrow.get(0)).total_seconds() - elif isinstance(timestamp, datetime.date): - timestamp = (timestamp - arrow.get(0)).total_seconds() +def discord_timestamp(timestamp: Timestamp, format: TimestampFormats = TimestampFormats.DATE_TIME) -> str: + """ + Format a timestamp as a Discord-flavored Markdown timestamp. - return f"" + `timestamp` can be any type supported by the single-arg `arrow.get()`, except for a `tzinfo`. + """ + timestamp = int(arrow.get(timestamp).timestamp()) + return f"" def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: int = 6) -> str: @@ -115,14 +124,6 @@ def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: return humanized -def get_time_delta(time_string: str) -> str: - """Returns the time in human-readable time delta format.""" - date_time = dateutil.parser.isoparse(time_string) - time_delta = format_relative(date_time) - - return time_delta - - def parse_duration_string(duration: str) -> Optional[relativedelta]: """ Converts a `duration` string to a relativedelta object. @@ -154,64 +155,63 @@ def relativedelta_to_timedelta(delta: relativedelta) -> datetime.timedelta: return utcnow + delta - utcnow -def format_relative(timestamp: ValidTimestamp) -> str: +def format_relative(timestamp: Timestamp) -> str: """ Format `timestamp` as a relative Discord timestamp. A relative timestamp describes how much time has elapsed since `timestamp` or how much time - remains until `timestamp` is reached. See `time.discord_timestamp`. + remains until `timestamp` is reached. + + `timestamp` can be any type supported by the single-arg `arrow.get()`, except for a `tzinfo`. """ return discord_timestamp(timestamp, TimestampFormats.RELATIVE) -def format_infraction(timestamp: str) -> str: - """Format an infraction timestamp to a discord timestamp.""" - return discord_timestamp(dateutil.parser.isoparse(timestamp)) - - def format_with_duration( - timestamp: Optional[str], - other_timestamp: Optional[datetime.datetime] = None, + timestamp: Optional[Timestamp], + other_timestamp: Optional[Timestamp] = None, max_units: int = 2, ) -> Optional[str]: """ 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. + `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. + + Return None if `timestamp` is falsy. """ if not timestamp: return None - date_to_formatted = format_infraction(timestamp) - - other_timestamp = other_timestamp or datetime.datetime.now(datetime.timezone.utc) - timestamp = dateutil.parser.isoparse(timestamp).replace(microsecond=0) + timestamp = arrow.get(timestamp) + if not other_timestamp: + other_timestamp = arrow.utcnow() + else: + other_timestamp = arrow.get(other_timestamp) - delta = abs(relativedelta(timestamp, other_timestamp)) + formatted_timestamp = discord_timestamp(timestamp) + delta = abs(relativedelta(timestamp.datetime, other_timestamp.datetime)) duration = humanize_delta(delta, max_units=max_units) - duration_formatted = f" ({duration})" if duration else "" - return f"{date_to_formatted}{duration_formatted}" + return f"{formatted_timestamp} ({duration})" -def until_expiration( - expiry: Optional[str] -) -> Optional[str]: +def until_expiration(expiry: Optional[Timestamp]) -> Optional[str]: """ - Get the remaining time until infraction's expiration, in a discord timestamp. + Get the remaining time until an infraction's expiration as a Discord timestamp. - Returns a human-readable version of the remaining duration between arrow.utcnow() and an expiry. - Similar to format_relative, except that this function doesn't error on a null input - and return null if the expiry is in the paste + `expiry` can be any type supported by the single-arg `arrow.get()`, except for a `tzinfo`. + + Return None if `expiry` is falsy or is in the past. """ if not expiry: return None - now = arrow.utcnow() - since = dateutil.parser.isoparse(expiry).replace(microsecond=0) - - if since < now: + expiry = arrow.get(expiry) + if expiry < arrow.utcnow(): return None - return format_relative(since) + return format_relative(expiry) diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index 02b5f8c17..027e2052e 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -43,10 +43,6 @@ class TimeTests(unittest.TestCase): time.humanize_delta(relativedelta(days=2, hours=2), 'hours', max_units) self.assertEqual(str(error.exception), 'max_units must be positive') - def test_format_infraction(self): - """Testing format_infraction.""" - self.assertEqual(time.format_infraction('2019-12-12T00:01:00Z'), '') - def test_format_with_duration_none_expiry(self): """format_with_duration should work for None expiry.""" test_cases = ( -- cgit v1.2.3 From 2004477e12c72e4739ea1b1f192fb2c12eac69d0 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 6 Aug 2021 14:20:52 -0700 Subject: 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. --- bot/exts/moderation/defcon.py | 4 +- bot/exts/moderation/infraction/management.py | 6 +- bot/exts/moderation/modlog.py | 2 +- bot/utils/time.py | 110 ++++++++++++++++++++++----- tests/bot/utils/test_time.py | 13 ++-- 5 files changed, 105 insertions(+), 30 deletions(-) (limited to 'tests') 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"" -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.""" -- cgit v1.2.3 From 2209b9f1b95cbe2366ea2b316046ddd35ff6d3a9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 7 Aug 2021 10:48:57 -0700 Subject: Fix create_user_embed tests Mock User.created_at and User.joined_at because `arrow.get()` doesn't work with Mock objects. The old implementation of `time.discord_timestamp` accepted mocks because it just did `int()` on any type it didn't explicitly check for. --- tests/bot/exts/info/test_information.py | 9 +++++++++ 1 file changed, 9 insertions(+) (limited to 'tests') diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index 30e5258fb..d896b7652 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -1,6 +1,7 @@ import textwrap import unittest import unittest.mock +from datetime import datetime import discord @@ -288,6 +289,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): user.nick = None user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") user.colour = 0 + user.created_at = user.joined_at = datetime.utcnow() embed = await self.cog.create_user_embed(ctx, user, False) @@ -309,6 +311,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): user.nick = "Cat lover" user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") user.colour = 0 + user.created_at = user.joined_at = datetime.utcnow() embed = await self.cog.create_user_embed(ctx, user, False) @@ -329,6 +332,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): # A `MockMember` has the @Everyone role by default; we add the Admins to that. user = helpers.MockMember(roles=[admins_role], colour=100) + user.created_at = user.joined_at = datetime.utcnow() embed = await self.cog.create_user_embed(ctx, user, False) @@ -355,6 +359,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): nomination_counts.return_value = ("Nominations", "nomination info") user = helpers.MockMember(id=314, roles=[moderators_role], colour=100) + user.created_at = user.joined_at = datetime.utcfromtimestamp(1) embed = await self.cog.create_user_embed(ctx, user, False) infraction_counts.assert_called_once_with(user) @@ -394,6 +399,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): user_messages.return_value = ("Messages", "user message counts") user = helpers.MockMember(id=314, roles=[moderators_role], colour=100) + user.created_at = user.joined_at = datetime.utcfromtimestamp(1) embed = await self.cog.create_user_embed(ctx, user, False) infraction_counts.assert_called_once_with(user) @@ -440,6 +446,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): moderators_role = helpers.MockRole(name='Moderators') user = helpers.MockMember(id=314, roles=[moderators_role], colour=100) + user.created_at = user.joined_at = datetime.utcnow() embed = await self.cog.create_user_embed(ctx, user, False) self.assertEqual(embed.colour, discord.Colour(100)) @@ -457,6 +464,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): ctx = helpers.MockContext() user = helpers.MockMember(id=217, colour=discord.Colour.default()) + user.created_at = user.joined_at = datetime.utcnow() embed = await self.cog.create_user_embed(ctx, user, False) self.assertEqual(embed.colour, discord.Colour.og_blurple()) @@ -474,6 +482,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): ctx = helpers.MockContext() user = helpers.MockMember(id=217, colour=0) + user.created_at = user.joined_at = datetime.utcnow() user.display_avatar.url = "avatar url" embed = await self.cog.create_user_embed(ctx, user, False) -- cgit v1.2.3 From 1af466753975b70effd5e600d0afc8b21f272dd0 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 7 Aug 2021 10:57:24 -0700 Subject: Time: return strings from until_expiration instead of ambiguous None None was returned for two separate cases: permanent infractions and expired infractions. This resulted in an ambiguity. --- bot/exts/moderation/infraction/management.py | 6 +++--- bot/utils/time.py | 8 ++++---- tests/bot/utils/test_time.py | 5 ++--- 3 files changed, 9 insertions(+), 10 deletions(-) (limited to 'tests') diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 0dfd2d759..dda3fadae 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -179,8 +179,8 @@ class ModManagement(commands.Cog): self.infractions_cog.schedule_expiration(new_infraction) log_text += f""" - Previous expiry: {time.until_expiration(infraction['expires_at']) or "Permanent"} - New expiry: {time.until_expiration(new_infraction['expires_at']) or "Permanent"} + Previous expiry: {time.until_expiration(infraction['expires_at'])} + New expiry: {time.until_expiration(new_infraction['expires_at'])} """.rstrip() changes = ' & '.join(confirm_messages) @@ -362,7 +362,7 @@ class ModManagement(commands.Cog): user_str = f"<@{user['id']}> ({name}#{user['discriminator']:04})" if active: - remaining = time.until_expiration(expires_at) or "Expired" + remaining = time.until_expiration(expires_at) else: remaining = "Inactive" diff --git a/bot/utils/time.py b/bot/utils/time.py index 8ba49a455..4b2fbae2c 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -303,19 +303,19 @@ def format_with_duration( return f"{formatted_timestamp} ({duration})" -def until_expiration(expiry: Optional[Timestamp]) -> Optional[str]: +def until_expiration(expiry: Optional[Timestamp]) -> str: """ Get the remaining time until an infraction's expiration as a Discord timestamp. `expiry` can be any type supported by the single-arg `arrow.get()`, except for a `tzinfo`. - Return None if `expiry` is falsy or is in the past. + Return "Permanent" if `expiry` is falsy. Return "Expired" if `expiry` is in the past. """ if not expiry: - return None + return "Permanent" expiry = arrow.get(expiry) if expiry < arrow.utcnow(): - return None + return "Expired" return format_relative(expiry) diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index e235f9b70..120d65176 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -100,8 +100,8 @@ class TimeTests(unittest.TestCase): self.assertEqual(time.format_with_duration(expiry, date_from, max_units), expected) def test_until_expiration_with_duration_none_expiry(self): - """until_expiration should work for None expiry.""" - self.assertEqual(time.until_expiration(None), None) + """until_expiration should return "Permanent" is expiry is None.""" + self.assertEqual(time.until_expiration(None), "Permanent") def test_until_expiration_with_duration_custom_units(self): """until_expiration should work for custom max_units.""" @@ -122,7 +122,6 @@ class TimeTests(unittest.TestCase): ('3000-12-12T00:00:00Z', ''), ('3000-11-23T20:09:00Z', ''), ('3000-11-23T20:09:00Z', ''), - (None, None), ) for expiry, expected in test_cases: -- cgit v1.2.3