diff options
| -rw-r--r-- | bot/converters.py | 9 | ||||
| -rw-r--r-- | bot/exts/info/information.py | 10 | ||||
| -rw-r--r-- | bot/exts/moderation/defcon.py | 31 | ||||
| -rw-r--r-- | bot/exts/moderation/infraction/_scheduler.py | 9 | ||||
| -rw-r--r-- | bot/exts/moderation/infraction/management.py | 22 | ||||
| -rw-r--r-- | bot/exts/moderation/infraction/superstarify.py | 6 | ||||
| -rw-r--r-- | bot/exts/moderation/modlog.py | 6 | ||||
| -rw-r--r-- | bot/exts/moderation/modpings.py | 7 | ||||
| -rw-r--r-- | bot/exts/moderation/slowmode.py | 3 | ||||
| -rw-r--r-- | bot/exts/moderation/stream.py | 7 | ||||
| -rw-r--r-- | bot/exts/moderation/watchchannels/_watchchannel.py | 7 | ||||
| -rw-r--r-- | bot/exts/recruitment/talentpool/_cog.py | 13 | ||||
| -rw-r--r-- | bot/exts/recruitment/talentpool/_review.py | 8 | ||||
| -rw-r--r-- | bot/exts/utils/reminders.py | 13 | ||||
| -rw-r--r-- | bot/exts/utils/utils.py | 5 | ||||
| -rw-r--r-- | bot/utils/scheduling.py | 4 | ||||
| -rw-r--r-- | bot/utils/time.py | 274 | ||||
| -rw-r--r-- | tests/bot/exts/info/test_information.py | 9 | ||||
| -rw-r--r-- | tests/bot/utils/test_time.py | 47 | 
19 files changed, 283 insertions, 207 deletions
| diff --git a/bot/converters.py b/bot/converters.py index cd33f5ed0..3522a32aa 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -6,7 +6,6 @@ from datetime import datetime, timezone  from ssl import CertificateError  import dateutil.parser -import dateutil.tz  import discord  from aiohttp import ClientConnectorError  from botcore.regex import DISCORD_INVITE @@ -21,8 +20,8 @@ from bot.errors import InvalidInfraction  from bot.exts.info.doc import _inventory_parser  from bot.exts.info.tags import TagIdentifier  from bot.log import get_logger +from bot.utils import time  from bot.utils.extensions import EXTENSIONS, unqualify -from bot.utils.time import parse_duration_string  if t.TYPE_CHECKING:      from bot.exts.info.source import SourceType @@ -338,7 +337,7 @@ class DurationDelta(Converter):          The units need to be provided in descending order of magnitude.          """ -        if not (delta := parse_duration_string(duration)): +        if not (delta := time.parse_duration_string(duration)):              raise BadArgument(f"`{duration}` is not a valid duration string.")          return delta @@ -454,9 +453,9 @@ class ISODateTime(Converter):              raise BadArgument(f"`{datetime_string}` is not a valid ISO-8601 datetime string")          if dt.tzinfo: -            dt = dt.astimezone(dateutil.tz.UTC) +            dt = dt.astimezone(timezone.utc)          else:  # Without a timezone, assume it represents UTC. -            dt = dt.replace(tzinfo=dateutil.tz.UTC) +            dt = dt.replace(tzinfo=timezone.utc)          return dt diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 1f95c460f..29a00ec5d 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -18,10 +18,10 @@ from bot.decorators import in_whitelist  from bot.errors import NonExistentRoleError  from bot.log import get_logger  from bot.pagination import LinePaginator +from bot.utils import time  from bot.utils.channel import is_mod_channel, is_staff_channel  from bot.utils.checks import cooldown_with_role_bypass, has_no_roles_check, in_whitelist_check  from bot.utils.members import get_or_fetch_member -from bot.utils.time import TimestampFormats, discord_timestamp, humanize_delta  log = get_logger(__name__) @@ -83,7 +83,7 @@ class Information(Cog):          defcon_info = ""          if cog := self.bot.get_cog("Defcon"): -            threshold = humanize_delta(cog.threshold) if cog.threshold else "-" +            threshold = time.humanize_delta(cog.threshold) if cog.threshold else "-"              defcon_info = f"Defcon threshold: {threshold}\n"          verification = f"Verification level: {ctx.guild.verification_level.name}\n" @@ -173,7 +173,7 @@ class Information(Cog):          """Returns an embed full of server information."""          embed = Embed(colour=Colour.og_blurple(), title="Server Information") -        created = discord_timestamp(ctx.guild.created_at, TimestampFormats.RELATIVE) +        created = time.format_relative(ctx.guild.created_at)          num_roles = len(ctx.guild.roles) - 1  # Exclude @everyone          # Server Features are only useful in certain channels @@ -249,7 +249,7 @@ class Information(Cog):          """Creates an embed containing information on the `user`."""          on_server = bool(await get_or_fetch_member(ctx.guild, user.id)) -        created = discord_timestamp(user.created_at, TimestampFormats.RELATIVE) +        created = time.format_relative(user.created_at)          name = str(user)          if on_server and user.nick: @@ -272,7 +272,7 @@ class Information(Cog):          if on_server:              if user.joined_at: -                joined = discord_timestamp(user.joined_at, TimestampFormats.RELATIVE) +                joined = time.format_relative(user.joined_at)              else:                  joined = "Unable to get join date" diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index 14db37367..178be734d 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -17,12 +17,9 @@ from bot.constants import Channels, Colours, Emojis, Event, Icons, MODERATION_RO  from bot.converters import DurationDelta, Expiry  from bot.exts.moderation.modlog import ModLog  from bot.log import get_logger -from bot.utils import scheduling +from bot.utils import scheduling, time  from bot.utils.messages import format_user  from bot.utils.scheduling import Scheduler -from bot.utils.time import ( -    TimestampFormats, discord_timestamp, humanize_delta, parse_duration_string, relativedelta_to_timedelta -)  log = get_logger(__name__) @@ -88,7 +85,7 @@ class Defcon(Cog):          try:              settings = await self.defcon_settings.to_dict() -            self.threshold = parse_duration_string(settings["threshold"]) if settings.get("threshold") else None +            self.threshold = time.parse_duration_string(settings["threshold"]) if settings.get("threshold") else None              self.expiry = datetime.fromisoformat(settings["expiry"]) if settings.get("expiry") else None          except RedisError:              log.exception("Unable to get DEFCON settings!") @@ -102,7 +99,7 @@ class Defcon(Cog):                  self.scheduler.schedule_at(self.expiry, 0, self._remove_threshold())              self._update_notifier() -            log.info(f"DEFCON synchronized: {humanize_delta(self.threshold) if self.threshold else '-'}") +            log.info(f"DEFCON synchronized: {time.humanize_delta(self.threshold) if self.threshold else '-'}")          self._update_channel_topic() @@ -112,7 +109,7 @@ class Defcon(Cog):          if self.threshold:              now = arrow.utcnow() -            if now - member.created_at < relativedelta_to_timedelta(self.threshold): +            if now - member.created_at < time.relativedelta_to_timedelta(self.threshold):                  log.info(f"Rejecting user {member}: Account is too new")                  message_sent = False @@ -151,11 +148,12 @@ class Defcon(Cog):      @has_any_role(*MODERATION_ROLES)      async def status(self, ctx: Context) -> None:          """Check the current status of DEFCON mode.""" +        expiry = time.format_relative(self.expiry) if self.expiry else "-"          embed = Embed(              colour=Colour.og_blurple(), title="DEFCON Status",              description=f""" -                **Threshold:** {humanize_delta(self.threshold) if self.threshold else "-"} -                **Expires:** {discord_timestamp(self.expiry, TimestampFormats.RELATIVE) if self.expiry else "-"} +                **Threshold:** {time.humanize_delta(self.threshold) if self.threshold else "-"} +                **Expires:** {expiry}                  **Verification level:** {ctx.guild.verification_level.name}                  """          ) @@ -213,7 +211,8 @@ class Defcon(Cog):      def _update_channel_topic(self) -> None:          """Update the #defcon channel topic with the current DEFCON status.""" -        new_topic = f"{BASE_CHANNEL_TOPIC}\n(Threshold: {humanize_delta(self.threshold) if self.threshold else '-'})" +        threshold = time.humanize_delta(self.threshold) if self.threshold else '-' +        new_topic = f"{BASE_CHANNEL_TOPIC}\n(Threshold: {threshold})"          self.mod_log.ignore(Event.guild_channel_update, Channels.defcon)          scheduling.create_task(self.channel.edit(topic=new_topic)) @@ -255,12 +254,12 @@ class Defcon(Cog):          expiry_message = ""          if expiry: -            activity_duration = relativedelta(expiry, arrow.utcnow().datetime) -            expiry_message = f" for the next {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 = ( -                f"updated; accounts must be {humanize_delta(self.threshold)} " +                f"updated; accounts must be {time.humanize_delta(self.threshold)} "                  f"old to join the server{expiry_message}"              )          else: @@ -290,7 +289,7 @@ class Defcon(Cog):      def _log_threshold_stat(self, threshold: relativedelta) -> None:          """Adds the threshold to the bot stats in days.""" -        threshold_days = relativedelta_to_timedelta(threshold).total_seconds() / SECONDS_IN_DAY +        threshold_days = time.relativedelta_to_timedelta(threshold).total_seconds() / SECONDS_IN_DAY          self.bot.stats.gauge("defcon.threshold", threshold_days)      async def _send_defcon_log(self, action: Action, actor: User) -> None: @@ -298,7 +297,7 @@ class Defcon(Cog):          info = action.value          log_msg: str = (              f"**Staffer:** {actor.mention} {actor} (`{actor.id}`)\n" -            f"{info.template.format(threshold=(humanize_delta(self.threshold) if self.threshold else '-'))}" +            f"{info.template.format(threshold=(time.humanize_delta(self.threshold) if self.threshold else '-'))}"          )          status_msg = f"DEFCON {action.name.lower()}" @@ -317,7 +316,7 @@ class Defcon(Cog):      @tasks.loop(hours=1)      async def defcon_notifier(self) -> None:          """Routinely notify moderators that DEFCON is active.""" -        await self.channel.send(f"Defcon is on and is set to {humanize_delta(self.threshold)}.") +        await self.channel.send(f"Defcon is on and is set to {time.humanize_delta(self.threshold)}.")      def cog_unload(self) -> None:          """Cancel the notifer and threshold removal tasks when the cog unloads.""" diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 57aa2d9b6..47b639421 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: @@ -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_infraction_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 9649ff852..dda3fadae 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -1,10 +1,7 @@  import textwrap  import typing as t -from datetime import datetime, timezone -import dateutil.parser  import discord -from dateutil.relativedelta import relativedelta  from discord.ext import commands  from discord.ext.commands import Context  from discord.utils import escape_markdown @@ -20,7 +17,6 @@ from bot.pagination import LinePaginator  from bot.utils import messages, time  from bot.utils.channel import is_mod_channel  from bot.utils.members import get_or_fetch_member -from bot.utils.time import humanize_delta, until_expiration  log = get_logger(__name__) @@ -151,7 +147,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(duration)              confirm_messages.append(f"set to expire on {expiry}")          else:              confirm_messages.append("expiry unchanged") @@ -183,8 +179,8 @@ class ModManagement(commands.Cog):                  self.infractions_cog.schedule_expiration(new_infraction)              log_text += f""" -                Previous expiry: {until_expiration(infraction['expires_at']) or "Permanent"} -                New expiry: {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) @@ -352,7 +348,8 @@ class ModManagement(commands.Cog):          active = infraction["active"]          user = infraction["user"]          expires_at = infraction["expires_at"] -        created = time.format_infraction(infraction["inserted_at"]) +        inserted_at = infraction["inserted_at"] +        created = time.discord_timestamp(inserted_at)          dm_sent = infraction["dm_sent"]          # Format the user string. @@ -365,19 +362,14 @@ 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"          if expires_at is None:              duration = "*Permanent*"          else: -            date_from = datetime.fromtimestamp( -                float(time.DISCORD_TIMESTAMP_REGEX.match(created).group(1)), -                timezone.utc -            ) -            date_to = dateutil.parser.isoparse(expires_at) -            duration = humanize_delta(relativedelta(date_to, date_from)) +            duration = time.humanize_delta(inserted_at, expires_at)          # Format `dm_sent`          if dm_sent is None: diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py index 08c92b8f3..a037ca1be 100644 --- a/bot/exts/moderation/infraction/superstarify.py +++ b/bot/exts/moderation/infraction/superstarify.py @@ -14,9 +14,9 @@ from bot.converters import Duration, Expiry  from bot.exts.moderation.infraction import _utils  from bot.exts.moderation.infraction._scheduler import InfractionScheduler  from bot.log import get_logger +from bot.utils import time  from bot.utils.members import get_or_fetch_member  from bot.utils.messages import format_user -from bot.utils.time import format_infraction  log = get_logger(__name__)  NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#nickname-policy" @@ -73,7 +73,7 @@ class Superstarify(InfractionScheduler, Cog):          notified = await _utils.notify_infraction(              user=after,              infr_type="Superstarify", -            expires_at=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 = 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/modlog.py b/bot/exts/moderation/modlog.py index fc9204998..2c01a4a21 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -16,8 +16,8 @@ from discord.utils import escape_markdown  from bot.bot import Bot  from bot.constants import Categories, Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, Roles, URLs  from bot.log import get_logger +from bot.utils import time  from bot.utils.messages import format_user -from bot.utils.time import humanize_delta  log = get_logger(__name__) @@ -407,7 +407,7 @@ class ModLog(Cog, name="ModLog"):          now = datetime.now(timezone.utc)          difference = abs(relativedelta(now, member.created_at)) -        message = format_user(member) + "\n\n**Account age:** " + humanize_delta(difference) +        message = format_user(member) + "\n\n**Account age:** " + time.humanize_delta(difference)          if difference.days < 1 and difference.months < 1 and difference.years < 1:  # New user account!              message = f"{Emojis.new} {message}" @@ -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 = 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/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py index 20a8c39d7..b5cd29b12 100644 --- a/bot/exts/moderation/modpings.py +++ b/bot/exts/moderation/modpings.py @@ -11,9 +11,8 @@ from bot.bot import Bot  from bot.constants import Colours, Emojis, Guild, Icons, MODERATION_ROLES, Roles  from bot.converters import Expiry  from bot.log import get_logger -from bot.utils import scheduling +from bot.utils import scheduling, time  from bot.utils.scheduling import Scheduler -from bot.utils.time import TimestampFormats, discord_timestamp  log = get_logger(__name__) @@ -233,8 +232,8 @@ class ModPings(Cog):          await ctx.send(              f"{Emojis.ok_hand} {ctx.author.mention} Scheduled mod pings from " -            f"{discord_timestamp(start, TimestampFormats.TIME)} to " -            f"{discord_timestamp(end, TimestampFormats.TIME)}!" +            f"{time.discord_timestamp(start, time.TimestampFormats.TIME)} to " +            f"{time.discord_timestamp(end, time.TimestampFormats.TIME)}!"          )      @schedule_modpings.command(name='delete', aliases=('del', 'd')) diff --git a/bot/exts/moderation/slowmode.py b/bot/exts/moderation/slowmode.py index da04d1e98..b6a771441 100644 --- a/bot/exts/moderation/slowmode.py +++ b/bot/exts/moderation/slowmode.py @@ -39,8 +39,7 @@ class Slowmode(Cog):          if channel is None:              channel = ctx.channel -        delay = relativedelta(seconds=channel.slowmode_delay) -        humanized_delay = time.humanize_delta(delay) +        humanized_delay = time.humanize_delta(seconds=channel.slowmode_delay)          await ctx.send(f'The slowmode delay for {channel.mention} is {humanized_delay}.') diff --git a/bot/exts/moderation/stream.py b/bot/exts/moderation/stream.py index 99bbd8721..4dccc8a7e 100644 --- a/bot/exts/moderation/stream.py +++ b/bot/exts/moderation/stream.py @@ -14,9 +14,8 @@ from bot.constants import (  from bot.converters import Expiry  from bot.log import get_logger  from bot.pagination import LinePaginator -from bot.utils import scheduling +from bot.utils import scheduling, time  from bot.utils.members import get_or_fetch_member -from bot.utils.time import discord_timestamp, format_infraction_with_duration  log = get_logger(__name__) @@ -131,10 +130,10 @@ class Stream(commands.Cog):          await member.add_roles(discord.Object(Roles.video), reason="Temporary streaming access granted") -        await ctx.send(f"{Emojis.check_mark} {member.mention} can now stream until {discord_timestamp(duration)}.") +        await ctx.send(f"{Emojis.check_mark} {member.mention} can now stream until {time.discord_timestamp(duration)}.")          # Convert here for nicer logging -        revoke_time = format_infraction_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 34d445912..ee9b6ba45 100644 --- a/bot/exts/moderation/watchchannels/_watchchannel.py +++ b/bot/exts/moderation/watchchannels/_watchchannel.py @@ -18,9 +18,8 @@ from bot.exts.filters.webhook_remover import WEBHOOK_URL_RE  from bot.exts.moderation.modlog import ModLog  from bot.log import CustomLogger, get_logger  from bot.pagination import LinePaginator -from bot.utils import CogABCMeta, messages, scheduling +from bot.utils import CogABCMeta, messages, scheduling, time  from bot.utils.members import get_or_fetch_member -from bot.utils.time import get_time_delta  log = get_logger(__name__) @@ -286,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 = get_time_delta(inserted_at) +        time_delta = time.format_relative(inserted_at)          reason = self.watched_users[user_id]['reason'] @@ -360,7 +359,7 @@ class WatchChannel(metaclass=CogABCMeta):              if member:                  line += f" ({member.name}#{member.discriminator})"              inserted_at = user_data['inserted_at'] -            line += f", added {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 8fa0be5b1..0554bf37a 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -17,7 +17,6 @@ from bot.log import get_logger  from bot.pagination import LinePaginator  from bot.utils import scheduling, time  from bot.utils.members import get_or_fetch_member -from bot.utils.time import get_time_delta  AUTOREVIEW_ENABLED_KEY = "autoreview_enabled"  REASON_MAX_CHARS = 1000 @@ -181,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 {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']: @@ -260,7 +259,7 @@ class TalentPool(Cog, name="Talentpool"):              return          if len(reason) > REASON_MAX_CHARS: -            await ctx.send(f":x: Maxiumum allowed characters for the reason is {REASON_MAX_CHARS}.") +            await ctx.send(f":x: Maximum allowed characters for the reason is {REASON_MAX_CHARS}.")              return          # Manual request with `raise_for_status` as False because we want the actual response @@ -445,7 +444,7 @@ class TalentPool(Cog, name="Talentpool"):      async def edit_end_reason_command(self, ctx: Context, nomination_id: int, *, reason: str) -> None:          """Edits the unnominate reason for the nomination with the given `id`."""          if len(reason) > REASON_MAX_CHARS: -            await ctx.send(f":x: Maxiumum allowed characters for the end reason is {REASON_MAX_CHARS}.") +            await ctx.send(f":x: Maximum allowed characters for the end reason is {REASON_MAX_CHARS}.")              return          try: @@ -562,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}"              ) @@ -571,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""" @@ -585,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 0e7194892..b4d177622 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -17,10 +17,10 @@ from bot.api import ResponseCodeError  from bot.bot import Bot  from bot.constants import Channels, Colours, Emojis, Guild, Roles  from bot.log import get_logger +from bot.utils import time  from bot.utils.members import get_or_fetch_member  from bot.utils.messages import count_unique_users_reaction, pin_no_system_message  from bot.utils.scheduling import Scheduler -from bot.utils.time import get_time_delta, time_since  if typing.TYPE_CHECKING:      from bot.exts.recruitment.talentpool._cog import TalentPool @@ -273,7 +273,7 @@ class Reviewer:                  last_channel = user_activity["top_channel_activity"][-1]                  channels += f", and {last_channel[1]} in {last_channel[0]}" -        joined_at_formatted = time_since(member.joined_at) +        joined_at_formatted = time.format_relative(member.joined_at)          review = (              f"{member.name} joined the server **{joined_at_formatted}**"              f" and has **{messages} messages**{channels}." @@ -321,7 +321,7 @@ class Reviewer:              infractions += ", with the last infraction issued "          # Infractions were ordered by time since insertion descending. -        infractions += 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_since(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 90677b2dd..289d00356 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -13,13 +13,12 @@ from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, Role  from bot.converters import Duration, UnambiguousUser  from bot.log import get_logger  from bot.pagination import LinePaginator -from bot.utils import scheduling +from bot.utils import scheduling, time  from bot.utils.checks import has_any_role_check, has_no_roles_check  from bot.utils.lock import lock_arg  from bot.utils.members import get_or_fetch_member  from bot.utils.messages import send_denial  from bot.utils.scheduling import Scheduler -from bot.utils.time import TimestampFormats, discord_timestamp  log = get_logger(__name__) @@ -169,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: @@ -310,7 +309,8 @@ class Reminders(Cog):              }          ) -        mention_string = f"Your reminder will arrive on {discord_timestamp(expiration, TimestampFormats.DAY_TIME)}" +        formatted_time = time.discord_timestamp(expiration, time.TimestampFormats.DAY_TIME) +        mention_string = f"Your reminder will arrive on {formatted_time}"          if mentions:              mention_string += f" and will mention {len(mentions)} other(s)" @@ -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) -            time = discord_timestamp(remind_datetime, TimestampFormats.RELATIVE) +            expiry = time.format_relative(remind_at)              mentions = ", ".join([                  # Both Role and User objects have the `name` attribute @@ -357,7 +356,7 @@ class Reminders(Cog):              mention_string = f"\n**Mentions:** {mentions}" if mentions else ""              text = textwrap.dedent(f""" -            **Reminder #{id_}:** *expires {time}* (ID: {id_}){mention_string} +            **Reminder #{id_}:** *expires {expiry}* (ID: {id_}){mention_string}              {content}              """).strip() diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index f76eea516..2a074788e 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -13,8 +13,7 @@ from bot.converters import Snowflake  from bot.decorators import in_whitelist  from bot.log import get_logger  from bot.pagination import LinePaginator -from bot.utils import messages -from bot.utils.time import time_since +from bot.utils import messages, time  log = get_logger(__name__) @@ -173,7 +172,7 @@ class Utils(Cog):          lines = []          for snowflake in snowflakes:              created_at = snowflake_time(snowflake) -            lines.append(f"**{snowflake}**\nCreated at {created_at} ({time_since(created_at)}).") +            lines.append(f"**{snowflake}**\nCreated at {created_at} ({time.format_relative(created_at)}).")          await LinePaginator.paginate(              lines, diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 7b4c8e2de..23acacf74 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -5,6 +5,8 @@ import typing as t  from datetime import datetime  from functools import partial +from arrow import Arrow +  from bot.log import get_logger @@ -58,7 +60,7 @@ class Scheduler:          self._scheduled_tasks[task_id] = task          self._log.debug(f"Scheduled task #{task_id} {id(task)}.") -    def schedule_at(self, time: datetime, task_id: t.Hashable, coroutine: t.Coroutine) -> None: +    def schedule_at(self, time: t.Union[datetime, Arrow], task_id: t.Hashable, coroutine: t.Coroutine) -> None:          """          Schedule `coroutine` to be executed at the given `time`. diff --git a/bot/utils/time.py b/bot/utils/time.py index eaa9b72e9..a0379c3ef 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -1,15 +1,12 @@  import datetime  import re  from enum import Enum -from typing import Optional, Union +from time import struct_time +from typing import Literal, Optional, Union, overload  import arrow -import dateutil.parser  from dateutil.relativedelta import relativedelta -RFC1123_FORMAT = "%a, %d %b %Y %H:%M:%S GMT" -DISCORD_TIMESTAMP_REGEX = re.compile(r"<t:(\d+):f>") -  _DURATION_REGEX = re.compile(      r"((?P<years>\d+?) ?(years|year|Y|y) ?)?"      r"((?P<months>\d+?) ?(months|month|m) ?)?" @@ -20,8 +17,19 @@ _DURATION_REGEX = re.compile(      r"((?P<seconds>\d+?) ?(seconds|second|S|s))?"  ) - -ValidTimestamp = Union[int, datetime.datetime, datetime.date, datetime.timedelta, relativedelta] +# 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 +] +_Precision = Literal["years", "months", "days", "hours", "minutes", "seconds"]  class TimestampFormats(Enum): @@ -42,7 +50,7 @@ class TimestampFormats(Enum):  def _stringify_time_unit(value: int, unit: str) -> str:      """ -    Returns a string to represent a value and time unit, ensuring that it uses the right plural form of the unit. +    Return a string to represent a value and time unit, ensuring the unit's correct plural form is used.      >>> _stringify_time_unit(1, "seconds")      "1 second" @@ -61,33 +69,140 @@ 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.""" -    if format not in TimestampFormats: -        raise ValueError(f"Format can only be one of {', '.join(TimestampFormats.args)}, not {format}.") +def discord_timestamp(timestamp: Timestamp, format: TimestampFormats = TimestampFormats.DATE_TIME) -> str: +    """ +    Format a timestamp as a Discord-flavored Markdown timestamp. + +    `timestamp` can be any type supported by the single-arg `arrow.get()`, except for a `tzinfo`. +    """ +    timestamp = int(arrow.get(timestamp).timestamp()) +    return f"<t:{timestamp}:{format.value}>" + + +# region humanize_delta overloads +@overload +def humanize_delta( +    arg1: Union[relativedelta, Timestamp], +    /, +    *, +    precision: _Precision = "seconds", +    max_units: int = 6, +    absolute: bool = True, +) -> str: +    ... + + +@overload +def humanize_delta( +    end: Timestamp, +    start: Timestamp, +    /, +    *, +    precision: _Precision = "seconds", +    max_units: int = 6, +    absolute: bool = True, +) -> str: +    ... + + +@overload +def humanize_delta( +    *, +    years: int = 0, +    months: int = 0, +    weeks: float = 0, +    days: float = 0, +    hours: float = 0, +    minutes: float = 0, +    seconds: float = 0, +    precision: _Precision = "seconds", +    max_units: int = 6, +    absolute: bool = True, +) -> str: +    ... +# endregion + + +def humanize_delta( +    *args, +    precision: _Precision = "seconds", +    max_units: int = 6, +    absolute: bool = True, +    **kwargs, +) -> str: +    """ +    Return a human-readable version of a time duration. + +    `precision` is the smallest unit of time to include (e.g. "seconds", "minutes"). -    # 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() -    elif isinstance(timestamp, datetime.timedelta): -        timestamp = timestamp.total_seconds() -    elif isinstance(timestamp, relativedelta): -        timestamp = timestamp.seconds +    `max_units` is the maximum number of units of time to include. +    Count units from largest to smallest (e.g. count days before months). -    return f"<t:{int(timestamp)}:{format.value}>" +    Use the absolute value of the duration if `absolute` is True. +    Usage: -def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: int = 6) -> str: -    """ -    Returns a human-readable version of the relativedelta. +    Keyword arguments specifying values for time units, to construct a `relativedelta` and humanize +    the duration represented by it: + +    >>> humanize_delta(days=2, hours=16, seconds=23) +    '2 days, 16 hours and 23 seconds' + +    **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') -    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). +    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 args and kwargs: +        raise ValueError("Unsupported combination of positional and keyword arguments.") + +    if len(args) == 0: +        delta = relativedelta(**kwargs) +    elif len(args) == 1 and isinstance(args[0], relativedelta): +        delta = args[0] +    elif 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), @@ -98,7 +213,7 @@ def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units:          ("seconds", delta.seconds),      ) -    # Add the time units that are >0, but stop at accuracy or max_units. +    # Add the time units that are >0, but stop at precision or max_units.      time_strings = []      unit_count = 0      for unit, value in units: @@ -109,7 +224,7 @@ def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units:          if unit == precision or unit_count >= max_units:              break -    # Add the 'and' between the last two units, if necessary +    # Add the 'and' between the last two units, if necessary.      if len(time_strings) > 1:          time_strings[-1] = f"{time_strings[-2]} and {time_strings[-1]}"          del time_strings[-2] @@ -123,19 +238,12 @@ 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 = time_since(date_time) - -    return time_delta - -  def parse_duration_string(duration: str) -> Optional[relativedelta]:      """ -    Converts a `duration` string to a relativedelta object. +    Convert a `duration` string to a relativedelta object. + +    The following symbols are supported for each unit of time: -    The function supports the following symbols for each unit of time:      - years: `Y`, `y`, `year`, `years`      - months: `m`, `month`, `months`      - weeks: `w`, `W`, `week`, `weeks` @@ -143,8 +251,9 @@ def parse_duration_string(duration: str) -> Optional[relativedelta]:      - hours: `H`, `h`, `hour`, `hours`      - minutes: `M`, `minute`, `minutes`      - seconds: `S`, `s`, `second`, `seconds` +      The units need to be provided in descending order of magnitude. -    If the string does represent a durationdelta object, it will return None. +    Return None if the `duration` string cannot be parsed according to the symbols above.      """      match = _DURATION_REGEX.fullmatch(duration)      if not match: @@ -157,76 +266,63 @@ def parse_duration_string(duration: str) -> Optional[relativedelta]:  def relativedelta_to_timedelta(delta: relativedelta) -> datetime.timedelta: -    """Converts a relativedelta object to a timedelta object.""" +    """Convert a relativedelta object to a timedelta object."""      utcnow = arrow.utcnow()      return utcnow + delta - utcnow -def time_since(past_datetime: datetime.datetime) -> str: -    """Takes a datetime and returns a discord timestamp that describes how long ago that datetime was.""" -    return discord_timestamp(past_datetime, TimestampFormats.RELATIVE) - - -def parse_rfc1123(stamp: str) -> datetime.datetime: -    """Parse RFC1123 time string into datetime.""" -    return datetime.datetime.strptime(stamp, RFC1123_FORMAT).replace(tzinfo=datetime.timezone.utc) +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. -def format_infraction(timestamp: str) -> str: -    """Format an infraction timestamp to a discord timestamp.""" -    return discord_timestamp(dateutil.parser.isoparse(timestamp)) +    `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_with_duration( -    date_to: Optional[str], -    date_from: Optional[datetime.datetime] = None, +def format_with_duration( +    timestamp: Optional[Timestamp], +    other_timestamp: Optional[Timestamp] = None,      max_units: int = 2, -    absolute: bool = True  ) -> 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`. + +    `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 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. -    If `absolute` is True, the absolute value of the duration delta is used. This prevents negative -    values in the case that `date_to` is in the past relative to `date_from`. +    Return None if `timestamp` is None.      """ -    if not date_to: +    if timestamp is None:          return None -    date_to_formatted = format_infraction(date_to) - -    date_from = date_from or datetime.datetime.now(datetime.timezone.utc) -    date_to = dateutil.parser.isoparse(date_to).replace(microsecond=0) +    if other_timestamp is None: +        other_timestamp = arrow.utcnow() -    delta = relativedelta(date_to, date_from) -    if absolute: -        delta = abs(delta) +    formatted_timestamp = discord_timestamp(timestamp) +    duration = humanize_delta(timestamp, other_timestamp, max_units=max_units) -    duration = humanize_delta(delta, max_units=max_units) -    duration_formatted = f" ({duration})" if duration else "" +    return f"{formatted_timestamp} ({duration})" -    return f"{date_to_formatted}{duration_formatted}" - -def until_expiration( -    expiry: Optional[str] -) -> Optional[str]: +def until_expiration(expiry: Optional[Timestamp]) -> 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 time_since, except that this function doesn't error on a null input -    and return null if the expiry is in the paste -    """ -    if not expiry: -        return None +    `expiry` can be any type supported by the single-arg `arrow.get()`, except for a `tzinfo`. -    now = arrow.utcnow() -    since = dateutil.parser.isoparse(expiry).replace(microsecond=0) +    Return "Permanent" if `expiry` is None. Return "Expired" if `expiry` is in the past. +    """ +    if expiry is None: +        return "Permanent" -    if since < now: -        return None +    expiry = arrow.get(expiry) +    if expiry < arrow.utcnow(): +        return "Expired" -    return discord_timestamp(since, TimestampFormats.RELATIVE) +    return format_relative(expiry) 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) diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index a3dcbfc0a..120d65176 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,22 +43,11 @@ 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') - -    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'), '<t:1576108860:f>') +                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_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), @@ -67,10 +59,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,               '<t:32533488060:f> (11 hours, 55 minutes and 55 seconds)'), @@ -80,10 +72,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, @@ -105,11 +97,11 @@ 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.""" -        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.""" @@ -130,7 +122,6 @@ class TimeTests(unittest.TestCase):              ('3000-12-12T00:00:00Z', '<t:32533488000:R>'),              ('3000-11-23T20:09:00Z', '<t:32531918940:R>'),              ('3000-11-23T20:09:00Z', '<t:32531918940:R>'), -            (None, None),          )          for expiry, expected in test_cases: | 
