diff options
| -rw-r--r-- | bot/converters.py | 19 | ||||
| -rw-r--r-- | bot/exts/filters/antispam.py | 15 | ||||
| -rw-r--r-- | bot/exts/filters/filtering.py | 19 | ||||
| -rw-r--r-- | bot/exts/fun/off_topic_names.py | 7 | ||||
| -rw-r--r-- | bot/exts/moderation/defcon.py | 8 | ||||
| -rw-r--r-- | bot/exts/moderation/infraction/_scheduler.py | 12 | ||||
| -rw-r--r-- | bot/exts/moderation/infraction/management.py | 2 | ||||
| -rw-r--r-- | bot/exts/moderation/modlog.py | 8 | ||||
| -rw-r--r-- | bot/exts/moderation/modpings.py | 5 | ||||
| -rw-r--r-- | bot/exts/moderation/voice_gate.py | 6 | ||||
| -rw-r--r-- | bot/exts/recruitment/talentpool/_review.py | 7 | ||||
| -rw-r--r-- | bot/exts/utils/internal.py | 6 | ||||
| -rw-r--r-- | bot/exts/utils/ping.py | 5 | ||||
| -rw-r--r-- | bot/exts/utils/reminders.py | 10 | ||||
| -rw-r--r-- | bot/monkey_patches.py | 7 | ||||
| -rw-r--r-- | bot/utils/checks.py | 3 | ||||
| -rw-r--r-- | bot/utils/time.py | 19 | ||||
| -rw-r--r-- | tests/bot/test_converters.py | 50 | ||||
| -rw-r--r-- | tests/bot/utils/test_time.py | 27 | 
19 files changed, 122 insertions, 113 deletions
| diff --git a/bot/converters.py b/bot/converters.py index dd02f6ae6..f50acb9c6 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -2,7 +2,7 @@ from __future__ import annotations  import re  import typing as t -from datetime import datetime +from datetime import datetime, timezone  from ssl import CertificateError  import dateutil.parser @@ -11,7 +11,7 @@ import discord  from aiohttp import ClientConnectorError  from dateutil.relativedelta import relativedelta  from discord.ext.commands import BadArgument, Bot, Context, Converter, IDConverter, MemberConverter, UserConverter -from discord.utils import DISCORD_EPOCH, escape_markdown, snowflake_time +from discord.utils import escape_markdown, snowflake_time  from bot import exts  from bot.api import ResponseCodeError @@ -28,7 +28,7 @@ if t.TYPE_CHECKING:  log = get_logger(__name__) -DISCORD_EPOCH_DT = datetime.utcfromtimestamp(DISCORD_EPOCH / 1000) +DISCORD_EPOCH_DT = snowflake_time(0)  RE_USER_MENTION = re.compile(r"<@!?([0-9]+)>$") @@ -273,14 +273,14 @@ class Snowflake(IDConverter):          snowflake = int(arg)          try: -            time = snowflake_time(snowflake).replace(tzinfo=None) +            time = snowflake_time(snowflake)          except (OverflowError, OSError) as e:              # Not sure if this can ever even happen, but let's be safe.              raise BadArgument(f"{error}: {e}")          if time < DISCORD_EPOCH_DT:              raise BadArgument(f"{error}: timestamp is before the Discord epoch.") -        elif (datetime.utcnow() - time).days < -1: +        elif (datetime.now(timezone.utc) - time).days < -1:              raise BadArgument(f"{error}: timestamp is too far into the future.")          return snowflake @@ -387,7 +387,7 @@ class Duration(DurationDelta):          The converter supports the same symbols for each unit of time as its parent class.          """          delta = await super().convert(ctx, duration) -        now = datetime.utcnow() +        now = datetime.now(timezone.utc)          try:              return now + delta @@ -443,8 +443,8 @@ class ISODateTime(Converter):          The converter is flexible in the formats it accepts, as it uses the `isoparse` method of          `dateutil.parser`. In general, it accepts datetime strings that start with a date,          optionally followed by a time. Specifying a timezone offset in the datetime string is -        supported, but the `datetime` object will be converted to UTC and will be returned without -        `tzinfo` as a timezone-unaware `datetime` object. +        supported, but the `datetime` object will be converted to UTC. If no timezone is specified, the datetime will +        be assumed to be in UTC already. In all cases, the returned object will have the UTC timezone.          See: https://dateutil.readthedocs.io/en/stable/parser.html#dateutil.parser.isoparse @@ -470,7 +470,8 @@ class ISODateTime(Converter):          if dt.tzinfo:              dt = dt.astimezone(dateutil.tz.UTC) -            dt = dt.replace(tzinfo=None) +        else:  # Without a timezone, assume it represents UTC. +            dt = dt.replace(tzinfo=dateutil.tz.UTC)          return dt diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index 37ac70508..ddfd11231 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -2,11 +2,12 @@ import asyncio  from collections import defaultdict  from collections.abc import Mapping  from dataclasses import dataclass, field -from datetime import datetime, timedelta +from datetime import timedelta  from itertools import takewhile  from operator import attrgetter, itemgetter  from typing import Dict, Iterable, List, Set +import arrow  from discord import Colour, Member, Message, NotFound, Object, TextChannel  from discord.ext.commands import Cog @@ -177,21 +178,17 @@ class AntiSpam(Cog):          self.cache.append(message) -        earliest_relevant_at = datetime.utcnow() - timedelta(seconds=self.max_interval) -        relevant_messages = list( -            takewhile(lambda msg: msg.created_at.replace(tzinfo=None) > earliest_relevant_at, self.cache) -        ) +        earliest_relevant_at = arrow.utcnow() - timedelta(seconds=self.max_interval) +        relevant_messages = list(takewhile(lambda msg: msg.created_at > earliest_relevant_at, self.cache))          for rule_name in AntiSpamConfig.rules:              rule_config = AntiSpamConfig.rules[rule_name]              rule_function = RULE_FUNCTION_MAPPING[rule_name]              # Create a list of messages that were sent in the interval that the rule cares about. -            latest_interesting_stamp = datetime.utcnow() - timedelta(seconds=rule_config['interval']) +            latest_interesting_stamp = arrow.utcnow() - timedelta(seconds=rule_config['interval'])              messages_for_rule = list( -                takewhile( -                    lambda msg: msg.created_at.replace(tzinfo=None) > latest_interesting_stamp, relevant_messages -                ) +                takewhile(lambda msg: msg.created_at > latest_interesting_stamp, relevant_messages)              )              result = await rule_function(message, messages_for_rule, rule_config) diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index a151db1f0..6df78f550 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -1,9 +1,10 @@  import asyncio  import re -from datetime import datetime, timedelta +from datetime import timedelta  from typing import Any, Dict, List, Mapping, NamedTuple, Optional, Tuple, Union -import dateutil +import arrow +import dateutil.parser  import discord.errors  import regex  from async_rediscache import RedisCache @@ -192,8 +193,8 @@ class Filtering(Cog):      async def check_send_alert(self, member: Member) -> bool:          """When there is less than 3 days after last alert, return `False`, otherwise `True`."""          if last_alert := await self.name_alerts.get(member.id): -            last_alert = datetime.utcfromtimestamp(last_alert) -            if datetime.utcnow() - timedelta(days=DAYS_BETWEEN_ALERTS) < last_alert: +            last_alert = arrow.get(last_alert) +            if arrow.utcnow() - timedelta(days=DAYS_BETWEEN_ALERTS) < last_alert:                  log.trace(f"Last alert was too recent for {member}'s nickname.")                  return False @@ -227,7 +228,7 @@ class Filtering(Cog):              )              # Update time when alert sent -            await self.name_alerts.set(member.id, datetime.utcnow().timestamp()) +            await self.name_alerts.set(member.id, arrow.utcnow().timestamp())      async def filter_eval(self, result: str, msg: Message) -> bool:          """ @@ -603,7 +604,7 @@ class Filtering(Cog):      def schedule_msg_delete(self, msg: dict) -> None:          """Delete an offensive message once its deletion date is reached.""" -        delete_at = dateutil.parser.isoparse(msg['delete_date']).replace(tzinfo=None) +        delete_at = dateutil.parser.isoparse(msg['delete_date'])          self.scheduler.schedule_at(delete_at, msg['id'], self.delete_offensive_msg(msg))      async def reschedule_offensive_msg_deletion(self) -> None: @@ -611,17 +612,17 @@ class Filtering(Cog):          await self.bot.wait_until_ready()          response = await self.bot.api_client.get('bot/offensive-messages',) -        now = datetime.utcnow() +        now = arrow.utcnow()          for msg in response: -            delete_at = dateutil.parser.isoparse(msg['delete_date']).replace(tzinfo=None) +            delete_at = dateutil.parser.isoparse(msg['delete_date'])              if delete_at < now:                  await self.delete_offensive_msg(msg)              else:                  self.schedule_msg_delete(msg) -    async def delete_offensive_msg(self, msg: Mapping[str, str]) -> None: +    async def delete_offensive_msg(self, msg: Mapping[str, int]) -> None:          """Delete an offensive message, and then delete it from the db."""          try:              channel = self.bot.get_channel(msg['channel_id']) diff --git a/bot/exts/fun/off_topic_names.py b/bot/exts/fun/off_topic_names.py index 427667c66..7df1d172d 100644 --- a/bot/exts/fun/off_topic_names.py +++ b/bot/exts/fun/off_topic_names.py @@ -1,6 +1,7 @@  import difflib -from datetime import datetime, timedelta +from datetime import timedelta +import arrow  from discord import Colour, Embed  from discord.ext.commands import Cog, Context, group, has_any_role  from discord.utils import sleep_until @@ -22,9 +23,9 @@ async def update_names(bot: Bot) -> None:      while True:          # Since we truncate the compute timedelta to seconds, we add one second to ensure          # we go past midnight in the `seconds_to_sleep` set below. -        today_at_midnight = datetime.utcnow().replace(microsecond=0, second=0, minute=0, hour=0) +        today_at_midnight = arrow.utcnow().replace(microsecond=0, second=0, minute=0, hour=0)          next_midnight = today_at_midnight + timedelta(days=1) -        await sleep_until(next_midnight) +        await sleep_until(next_midnight.datetime)          try:              channel_0_name, channel_1_name, channel_2_name = await bot.api_client.get( diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index 80ba10112..822a87b61 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -4,6 +4,7 @@ from datetime import datetime  from enum import Enum  from typing import Optional, Union +import arrow  from aioredis import RedisError  from async_rediscache import RedisCache  from dateutil.relativedelta import relativedelta @@ -109,9 +110,9 @@ class Defcon(Cog):      async def on_member_join(self, member: Member) -> None:          """Check newly joining users to see if they meet the account age threshold."""          if self.threshold: -            now = datetime.utcnow() +            now = arrow.utcnow() -            if now - member.created_at.replace(tzinfo=None) < relativedelta_to_timedelta(self.threshold): +            if now - member.created_at < relativedelta_to_timedelta(self.threshold):                  log.info(f"Rejecting user {member}: Account is too new")                  message_sent = False @@ -254,7 +255,8 @@ class Defcon(Cog):          expiry_message = ""          if expiry: -            expiry_message = f" for the next {humanize_delta(relativedelta(expiry, datetime.utcnow()), max_units=2)}" +            activity_duration = relativedelta(expiry, arrow.utcnow().datetime) +            expiry_message = f" for the next {humanize_delta(activity_duration, max_units=2)}"          if self.threshold:              channel_message = ( diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index d4e96b10b..74a987808 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -1,9 +1,9 @@  import textwrap  import typing as t  from abc import abstractmethod -from datetime import datetime  from gettext import ngettext +import arrow  import dateutil.parser  import discord  from discord.ext.commands import Context @@ -67,7 +67,7 @@ class InfractionScheduler:          # We make sure to fire this          if to_schedule:              next_reschedule_point = max( -                dateutil.parser.isoparse(infr["expires_at"]).replace(tzinfo=None) for infr in to_schedule +                dateutil.parser.isoparse(infr["expires_at"]) for infr in to_schedule              )              log.trace("Will reschedule remaining infractions at %s", next_reschedule_point) @@ -83,8 +83,8 @@ class InfractionScheduler:          """Reapply an infraction if it's still active or deactivate it if less than 60 sec left."""          if infraction["expires_at"] is not None:              # Calculate the time remaining, in seconds, for the mute. -            expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None) -            delta = (expiry - datetime.utcnow()).total_seconds() +            expiry = dateutil.parser.isoparse(infraction["expires_at"]) +            delta = (expiry - arrow.utcnow()).total_seconds()          else:              # If the infraction is permanent, it is not possible to get the time remaining.              delta = None @@ -382,7 +382,7 @@ class InfractionScheduler:          log.info(f"Marking infraction #{id_} as inactive (expired).") -        expiry = dateutil.parser.isoparse(expiry).replace(tzinfo=None) if expiry else None +        expiry = dateutil.parser.isoparse(expiry) if expiry else None          created = time.format_infraction_with_duration(inserted_at, expiry)          log_content = None @@ -503,5 +503,5 @@ class InfractionScheduler:          At the time of expiration, the infraction is marked as inactive on the website and the          expiration task is cancelled.          """ -        expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None) +        expiry = dateutil.parser.isoparse(infraction["expires_at"])          self.scheduler.schedule_at(expiry, infraction["id"], self.deactivate_infraction(infraction)) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index b1c8b64dc..96c818c47 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -315,7 +315,7 @@ class ModManagement(commands.Cog):              duration = "*Permanent*"          else:              date_from = datetime.fromtimestamp(float(time.DISCORD_TIMESTAMP_REGEX.match(created).group(1))) -            date_to = dateutil.parser.isoparse(expires_at).replace(tzinfo=None) +            date_to = dateutil.parser.isoparse(expires_at)              duration = humanize_delta(relativedelta(date_to, date_from))          lines = textwrap.dedent(f""" diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index c6752d0f9..6fcf43d8a 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -2,7 +2,7 @@ import asyncio  import difflib  import itertools  import typing as t -from datetime import datetime +from datetime import datetime, timezone  from itertools import zip_longest  import discord @@ -58,7 +58,7 @@ class ModLog(Cog, name="ModLog"):              'bot/deleted-messages',              json={                  'actor': actor_id, -                'creation': datetime.utcnow().isoformat(), +                'creation': datetime.now(timezone.utc).isoformat(),                  'deletedmessage_set': [                      {                          'id': message.id, @@ -404,8 +404,8 @@ class ModLog(Cog, name="ModLog"):          if member.guild.id != GuildConstant.id:              return -        now = datetime.utcnow() -        difference = abs(relativedelta(now, member.created_at.replace(tzinfo=None))) +        now = datetime.now(timezone.utc) +        difference = abs(relativedelta(now, member.created_at))          message = format_user(member) + "\n\n**Account age:** " + humanize_delta(difference) diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py index a7ccb8162..f67d8f662 100644 --- a/bot/exts/moderation/modpings.py +++ b/bot/exts/moderation/modpings.py @@ -1,5 +1,6 @@  import datetime +import arrow  from async_rediscache import RedisCache  from dateutil.parser import isoparse  from discord import Embed, Member @@ -57,7 +58,7 @@ class ModPings(Cog):              if mod.id not in pings_off:                  await self.reapply_role(mod)              else: -                expiry = isoparse(pings_off[mod.id]).replace(tzinfo=None) +                expiry = isoparse(pings_off[mod.id])                  self._role_scheduler.schedule_at(expiry, mod.id, self.reapply_role(mod))      async def reapply_role(self, mod: Member) -> None: @@ -92,7 +93,7 @@ class ModPings(Cog):          The duration cannot be longer than 30 days.          """ -        delta = duration - datetime.datetime.utcnow() +        delta = duration - arrow.utcnow()          if delta > datetime.timedelta(days=30):              await ctx.send(":x: Cannot remove the role for longer than 30 days.")              return diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 8fdc7c76b..31799ec73 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -1,7 +1,8 @@  import asyncio  from contextlib import suppress -from datetime import datetime, timedelta +from datetime import timedelta +import arrow  import discord  from async_rediscache import RedisCache  from discord import Colour, Member, VoiceState @@ -166,8 +167,7 @@ class VoiceGate(Cog):          checks = {              "joined_at": ( -                ctx.author.joined_at.replace(tzinfo=None) > datetime.utcnow() -                - timedelta(days=GateConf.minimum_days_member) +                ctx.author.joined_at > arrow.utcnow() - timedelta(days=GateConf.minimum_days_member)              ),              "total_messages": data["total_messages"] < GateConf.minimum_messages,              "voice_banned": data["voice_banned"], diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index dcf73c2cb..d880c524c 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -8,6 +8,7 @@ from collections import Counter  from datetime import datetime, timedelta  from typing import List, Optional, Union +import arrow  from dateutil.parser import isoparse  from discord import Embed, Emoji, Member, Message, NoMoreItems, PartialMessage, TextChannel  from discord.ext.commands import Context @@ -68,11 +69,11 @@ class Reviewer:          log.trace(f"Scheduling review of user with ID {user_id}")          user_data = self._pool.cache.get(user_id) -        inserted_at = isoparse(user_data['inserted_at']).replace(tzinfo=None) +        inserted_at = isoparse(user_data['inserted_at'])          review_at = inserted_at + timedelta(days=MAX_DAYS_IN_POOL)          # If it's over a day overdue, it's probably an old nomination and shouldn't be automatically reviewed. -        if datetime.utcnow() - review_at < timedelta(days=1): +        if arrow.utcnow() - review_at < timedelta(days=1):              self._review_scheduler.schedule_at(review_at, user_id, self.post_review(user_id, update_database=True))      async def post_review(self, user_id: int, update_database: bool) -> None: @@ -347,7 +348,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']).replace(tzinfo=None)) +        end_time = time_since(isoparse(history[0]['ended_at']))          review = (              f"They were nominated **{nomination_times}** before" diff --git a/bot/exts/utils/internal.py b/bot/exts/utils/internal.py index 96664929b..165b5917d 100644 --- a/bot/exts/utils/internal.py +++ b/bot/exts/utils/internal.py @@ -5,10 +5,10 @@ import re  import textwrap  import traceback  from collections import Counter -from datetime import datetime  from io import StringIO  from typing import Any, Optional, Tuple +import arrow  import discord  from discord.ext.commands import Cog, Context, group, has_any_role, is_owner @@ -29,7 +29,7 @@ class Internal(Cog):          self.ln = 0          self.stdout = StringIO() -        self.socket_since = datetime.utcnow() +        self.socket_since = arrow.utcnow()          self.socket_event_total = 0          self.socket_events = Counter() @@ -236,7 +236,7 @@ async def func():  # (None,) -> Any      @has_any_role(Roles.admins, Roles.owners, Roles.core_developers)      async def socketstats(self, ctx: Context) -> None:          """Fetch information on the socket events received from Discord.""" -        running_s = (datetime.utcnow() - self.socket_since).total_seconds() +        running_s = (arrow.utcnow() - self.socket_since).total_seconds()          per_s = self.socket_event_total / running_s diff --git a/bot/exts/utils/ping.py b/bot/exts/utils/ping.py index 43d371d87..9fb5b7b8f 100644 --- a/bot/exts/utils/ping.py +++ b/bot/exts/utils/ping.py @@ -1,5 +1,4 @@ -from datetime import datetime - +import arrow  from aiohttp import client_exceptions  from discord import Embed  from discord.ext import commands @@ -32,7 +31,7 @@ class Latency(commands.Cog):          """          # datetime.datetime objects do not have the "milliseconds" attribute.          # It must be converted to seconds before converting to milliseconds. -        bot_ping = (datetime.utcnow() - ctx.message.created_at.replace(tzinfo=None)).total_seconds() * 1000 +        bot_ping = (arrow.utcnow() - ctx.message.created_at).total_seconds() * 1000          if bot_ping <= 0:              bot_ping = "Your clock is out of sync, could not calculate ping."          else: diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 3cb9307a9..3dbcc4513 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -1,7 +1,7 @@  import random  import textwrap  import typing as t -from datetime import datetime +from datetime import datetime, timezone  from operator import itemgetter  import discord @@ -52,14 +52,14 @@ class Reminders(Cog):              params={'active': 'true'}          ) -        now = datetime.utcnow() +        now = datetime.now(timezone.utc)          for reminder in response:              is_valid, *_ = self.ensure_valid_reminder(reminder)              if not is_valid:                  continue -            remind_at = isoparse(reminder['expiration']).replace(tzinfo=None) +            remind_at = isoparse(reminder['expiration'])              # If the reminder is already overdue ...              if remind_at < now: @@ -144,7 +144,7 @@ class Reminders(Cog):      def schedule_reminder(self, reminder: dict) -> None:          """A coroutine which sends the reminder once the time is reached, and cancels the running task.""" -        reminder_datetime = isoparse(reminder['expiration']).replace(tzinfo=None) +        reminder_datetime = isoparse(reminder['expiration'])          self.scheduler.schedule_at(reminder_datetime, reminder["id"], self.send_reminder(reminder))      async def _edit_reminder(self, reminder_id: int, payload: dict) -> dict: @@ -333,7 +333,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).replace(tzinfo=None) +            remind_datetime = isoparse(remind_at)              time = discord_timestamp(remind_datetime, TimestampFormats.RELATIVE)              mentions = ", ".join([ diff --git a/bot/monkey_patches.py b/bot/monkey_patches.py index e56a19da2..23482f7c3 100644 --- a/bot/monkey_patches.py +++ b/bot/monkey_patches.py @@ -1,5 +1,6 @@ -from datetime import datetime, timedelta +from datetime import timedelta +import arrow  from discord import Forbidden, http  from discord.ext import commands @@ -38,13 +39,13 @@ def patch_typing() -> None:      async def honeybadger_type(self, channel_id: int) -> None:  # noqa: ANN001          nonlocal last_403 -        if last_403 and (datetime.utcnow() - last_403) < timedelta(minutes=5): +        if last_403 and (arrow.utcnow() - last_403) < timedelta(minutes=5):              log.warning("Not sending typing event, we got a 403 less than 5 minutes ago.")              return          try:              await original(self, channel_id)          except Forbidden: -            last_403 = datetime.utcnow() +            last_403 = arrow.utcnow()              log.warning("Got a 403 from typing event!")              pass diff --git a/bot/utils/checks.py b/bot/utils/checks.py index e7f2cfbda..188285684 100644 --- a/bot/utils/checks.py +++ b/bot/utils/checks.py @@ -1,4 +1,3 @@ -import datetime  from typing import Callable, Container, Iterable, Optional, Union  from discord.ext.commands import ( @@ -137,7 +136,7 @@ def cooldown_with_role_bypass(rate: int, per: float, type: BucketType = BucketTy              return          # cooldown logic, taken from discord.py internals -        current = ctx.message.created_at.replace(tzinfo=datetime.timezone.utc).timestamp() +        current = ctx.message.created_at.timestamp()          bucket = buckets.get_bucket(ctx.message)          retry_after = bucket.update_rate_limit(current)          if retry_after: diff --git a/bot/utils/time.py b/bot/utils/time.py index 8cf7d623b..eaa9b72e9 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -3,6 +3,7 @@ import re  from enum import Enum  from typing import Optional, Union +import arrow  import dateutil.parser  from dateutil.relativedelta import relativedelta @@ -67,9 +68,9 @@ def discord_timestamp(timestamp: ValidTimestamp, format: TimestampFormats = Time      # Convert each possible timestamp class to an integer.      if isinstance(timestamp, datetime.datetime): -        timestamp = (timestamp.replace(tzinfo=None) - datetime.datetime.utcfromtimestamp(0)).total_seconds() +        timestamp = (timestamp - arrow.get(0)).total_seconds()      elif isinstance(timestamp, datetime.date): -        timestamp = (timestamp - datetime.date.fromtimestamp(0)).total_seconds() +        timestamp = (timestamp - arrow.get(0)).total_seconds()      elif isinstance(timestamp, datetime.timedelta):          timestamp = timestamp.total_seconds()      elif isinstance(timestamp, relativedelta): @@ -124,7 +125,7 @@ def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units:  def get_time_delta(time_string: str) -> str:      """Returns the time in human-readable time delta format.""" -    date_time = dateutil.parser.isoparse(time_string).replace(tzinfo=None) +    date_time = dateutil.parser.isoparse(time_string)      time_delta = time_since(date_time)      return time_delta @@ -157,7 +158,7 @@ def parse_duration_string(duration: str) -> Optional[relativedelta]:  def relativedelta_to_timedelta(delta: relativedelta) -> datetime.timedelta:      """Converts a relativedelta object to a timedelta object.""" -    utcnow = datetime.datetime.utcnow() +    utcnow = arrow.utcnow()      return utcnow + delta - utcnow @@ -196,8 +197,8 @@ def format_infraction_with_duration(      date_to_formatted = format_infraction(date_to) -    date_from = date_from or datetime.datetime.utcnow() -    date_to = dateutil.parser.isoparse(date_to).replace(tzinfo=None, microsecond=0) +    date_from = date_from or datetime.datetime.now(datetime.timezone.utc) +    date_to = dateutil.parser.isoparse(date_to).replace(microsecond=0)      delta = relativedelta(date_to, date_from)      if absolute: @@ -215,15 +216,15 @@ def until_expiration(      """      Get the remaining time until infraction's expiration, in a discord timestamp. -    Returns a human-readable version of the remaining duration between datetime.utcnow() and an expiry. +    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 -    now = datetime.datetime.utcnow() -    since = dateutil.parser.isoparse(expiry).replace(tzinfo=None, microsecond=0) +    now = arrow.utcnow() +    since = dateutil.parser.isoparse(expiry).replace(microsecond=0)      if since < now:          return None diff --git a/tests/bot/test_converters.py b/tests/bot/test_converters.py index ef6c8e19e..988b3857b 100644 --- a/tests/bot/test_converters.py +++ b/tests/bot/test_converters.py @@ -1,6 +1,6 @@ -import datetime  import re  import unittest +from datetime import MAXYEAR, datetime, timezone  from unittest.mock import MagicMock, patch  from dateutil.relativedelta import relativedelta @@ -17,7 +17,7 @@ class ConverterTests(unittest.IsolatedAsyncioTestCase):          cls.context = MagicMock          cls.context.author = 'bob' -        cls.fixed_utc_now = datetime.datetime.fromisoformat('2019-01-01T00:00:00') +        cls.fixed_utc_now = datetime.fromisoformat('2019-01-01T00:00:00+00:00')      async def test_tag_name_converter_for_invalid(self):          """TagNameConverter should raise the correct exception for invalid tag names.""" @@ -111,7 +111,7 @@ class ConverterTests(unittest.IsolatedAsyncioTestCase):              expected_datetime = self.fixed_utc_now + relativedelta(**duration_dict)              with patch('bot.converters.datetime') as mock_datetime: -                mock_datetime.utcnow.return_value = self.fixed_utc_now +                mock_datetime.now.return_value = self.fixed_utc_now                  with self.subTest(duration=duration, duration_dict=duration_dict):                      converted_datetime = await converter.convert(self.context, duration) @@ -157,52 +157,53 @@ class ConverterTests(unittest.IsolatedAsyncioTestCase):      async def test_duration_converter_out_of_range(self, mock_datetime):          """Duration converter should raise BadArgument if datetime raises a ValueError."""          mock_datetime.__add__.side_effect = ValueError -        mock_datetime.utcnow.return_value = mock_datetime +        mock_datetime.now.return_value = mock_datetime -        duration = f"{datetime.MAXYEAR}y" +        duration = f"{MAXYEAR}y"          exception_message = f"`{duration}` results in a datetime outside the supported range."          with self.assertRaisesRegex(BadArgument, re.escape(exception_message)):              await Duration().convert(self.context, duration)      async def test_isodatetime_converter_for_valid(self):          """ISODateTime converter returns correct datetime for valid datetime string.""" +        utc = timezone.utc          test_values = (              # `YYYY-mm-ddTHH:MM:SSZ` | `YYYY-mm-dd HH:MM:SSZ` -            ('2019-09-02T02:03:05Z', datetime.datetime(2019, 9, 2, 2, 3, 5)), -            ('2019-09-02 02:03:05Z', datetime.datetime(2019, 9, 2, 2, 3, 5)), +            ('2019-09-02T02:03:05Z', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)), +            ('2019-09-02 02:03:05Z', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)),              # `YYYY-mm-ddTHH:MM:SS±HH:MM` | `YYYY-mm-dd HH:MM:SS±HH:MM` -            ('2019-09-02T03:18:05+01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), -            ('2019-09-02 03:18:05+01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), -            ('2019-09-02T00:48:05-01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), -            ('2019-09-02 00:48:05-01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), +            ('2019-09-02T03:18:05+01:15', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)), +            ('2019-09-02 03:18:05+01:15', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)), +            ('2019-09-02T00:48:05-01:15', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)), +            ('2019-09-02 00:48:05-01:15', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)),              # `YYYY-mm-ddTHH:MM:SS±HHMM` | `YYYY-mm-dd HH:MM:SS±HHMM` -            ('2019-09-02T03:18:05+0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), -            ('2019-09-02 03:18:05+0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), -            ('2019-09-02T00:48:05-0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), -            ('2019-09-02 00:48:05-0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), +            ('2019-09-02T03:18:05+0115', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)), +            ('2019-09-02 03:18:05+0115', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)), +            ('2019-09-02T00:48:05-0115', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)), +            ('2019-09-02 00:48:05-0115', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)),              # `YYYY-mm-ddTHH:MM:SS±HH` | `YYYY-mm-dd HH:MM:SS±HH` -            ('2019-09-02 03:03:05+01', datetime.datetime(2019, 9, 2, 2, 3, 5)), -            ('2019-09-02T01:03:05-01', datetime.datetime(2019, 9, 2, 2, 3, 5)), +            ('2019-09-02 03:03:05+01', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)), +            ('2019-09-02T01:03:05-01', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)),              # `YYYY-mm-ddTHH:MM:SS` | `YYYY-mm-dd HH:MM:SS` -            ('2019-09-02T02:03:05', datetime.datetime(2019, 9, 2, 2, 3, 5)), -            ('2019-09-02 02:03:05', datetime.datetime(2019, 9, 2, 2, 3, 5)), +            ('2019-09-02T02:03:05', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)), +            ('2019-09-02 02:03:05', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)),              # `YYYY-mm-ddTHH:MM` | `YYYY-mm-dd HH:MM` -            ('2019-11-12T09:15', datetime.datetime(2019, 11, 12, 9, 15)), -            ('2019-11-12 09:15', datetime.datetime(2019, 11, 12, 9, 15)), +            ('2019-11-12T09:15', datetime(2019, 11, 12, 9, 15, tzinfo=utc)), +            ('2019-11-12 09:15', datetime(2019, 11, 12, 9, 15, tzinfo=utc)),              # `YYYY-mm-dd` -            ('2019-04-01', datetime.datetime(2019, 4, 1)), +            ('2019-04-01', datetime(2019, 4, 1, tzinfo=utc)),              # `YYYY-mm` -            ('2019-02-01', datetime.datetime(2019, 2, 1)), +            ('2019-02-01', datetime(2019, 2, 1, tzinfo=utc)),              # `YYYY` -            ('2025', datetime.datetime(2025, 1, 1)), +            ('2025', datetime(2025, 1, 1, tzinfo=utc)),          )          converter = ISODateTime() @@ -210,7 +211,6 @@ class ConverterTests(unittest.IsolatedAsyncioTestCase):          for datetime_string, expected_dt in test_values:              with self.subTest(datetime_string=datetime_string, expected_dt=expected_dt):                  converted_dt = await converter.convert(self.context, datetime_string) -                self.assertIsNone(converted_dt.tzinfo)                  self.assertEqual(converted_dt, expected_dt)      async def test_isodatetime_converter_for_invalid(self): diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index 8edffd1c9..a3dcbfc0a 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -72,9 +72,9 @@ class TimeTests(unittest.TestCase):      def test_format_infraction_with_duration_custom_units(self):          """format_infraction_with_duration should work for custom max_units."""          test_cases = ( -            ('3000-12-12T00:01:00Z', datetime(3000, 12, 11, 12, 5, 5), 6, +            ('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)'), -            ('3000-11-23T20:09:00Z', datetime(3000, 4, 25, 20, 15), 20, +            ('3000-11-23T20:09:00Z', datetime(3000, 4, 25, 20, 15, tzinfo=timezone.utc), 20,               '<t:32531918940:f> (6 months, 28 days, 23 hours and 54 minutes)')          ) @@ -84,16 +84,21 @@ class TimeTests(unittest.TestCase):      def test_format_infraction_with_duration_normal_usage(self):          """format_infraction_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), 2, '<t:1576108860:f> (12 hours and 55 seconds)'), -            ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 1, '<t:1576108860:f> (12 hours)'), -            ('2019-12-12T00:00:00Z', datetime(2019, 12, 11, 23, 59), 2, '<t:1576108800:f> (1 minute)'), -            ('2019-11-23T20:09:00Z', datetime(2019, 11, 15, 20, 15), 2, '<t:1574539740:f> (7 days and 23 hours)'), -            ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 2, '<t:1574539740:f> (6 months and 28 days)'), -            ('2019-11-23T20:58:00Z', datetime(2019, 11, 23, 20, 53), 2, '<t:1574542680:f> (5 minutes)'), -            ('2019-11-24T00:00:00Z', datetime(2019, 11, 23, 23, 59, 0), 2, '<t:1574553600:f> (1 minute)'), -            ('2019-11-23T23:59:00Z', datetime(2017, 7, 21, 23, 0), 2, '<t:1574553540:f> (2 years and 4 months)'), -            ('2019-11-23T23:59:00Z', datetime(2019, 11, 23, 23, 49, 5), 2, +            ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5, tzinfo=utc), 2, +                '<t:1576108860:f> (12 hours and 55 seconds)'), +            ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5, tzinfo=utc), 1, '<t:1576108860:f> (12 hours)'), +            ('2019-12-12T00:00:00Z', datetime(2019, 12, 11, 23, 59, tzinfo=utc), 2, '<t:1576108800:f> (1 minute)'), +            ('2019-11-23T20:09:00Z', datetime(2019, 11, 15, 20, 15, tzinfo=utc), 2, +                '<t:1574539740:f> (7 days and 23 hours)'), +            ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15, tzinfo=utc), 2, +                '<t:1574539740:f> (6 months and 28 days)'), +            ('2019-11-23T20:58:00Z', datetime(2019, 11, 23, 20, 53, tzinfo=utc), 2, '<t:1574542680:f> (5 minutes)'), +            ('2019-11-24T00:00:00Z', datetime(2019, 11, 23, 23, 59, 0, tzinfo=utc), 2, '<t:1574553600:f> (1 minute)'), +            ('2019-11-23T23:59:00Z', datetime(2017, 7, 21, 23, 0, tzinfo=utc), 2, +             '<t:1574553540:f> (2 years and 4 months)'), +            ('2019-11-23T23:59:00Z', datetime(2019, 11, 23, 23, 49, 5, tzinfo=utc), 2,               '<t:1574553540:f> (9 minutes and 55 seconds)'),              (None, datetime(2019, 11, 23, 23, 49, 5), 2, None),          ) | 
