aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Boris Muratov <[email protected]>2021-10-22 21:52:09 +0300
committerGravatar GitHub <[email protected]>2021-10-22 18:52:09 +0000
commit913b1d5644b57fd900474b8d9bc271e24ea729de (patch)
tree551ca34af549fecb97fd0e592a8edf6c66cae71a
parentMerge pull request #1904 from python-discord/modlog/explicit-thread-name (diff)
Move to timezone aware datetimes (#1895)
* Move to timezone aware datetimes With the shift of the discord.py library to timezone aware datetimes, this commit changes datetimes throughout the bot to be in the UTC timezone accordingly. This has several advantages: - There's no need to discard the TZ every time the datetime of a Discord object is fetched. - Using TZ aware datetimes reduces the likelihood of silently adding bugs into the codebase (can't compare an aware datetime with a naive one). - Our DB already stores datetimes in UTC, but we've been discarding the TZ so far whenever we read from it. Specific places in the codebase continue using naive datetimes, mainly for UI purposes (for examples embed footers use naive datetimes to display local time). * Improve ISODateTime converter documentation Co-authored-by: Kieran Siek <[email protected]>
-rw-r--r--bot/converters.py19
-rw-r--r--bot/exts/filters/antispam.py15
-rw-r--r--bot/exts/filters/filtering.py19
-rw-r--r--bot/exts/fun/off_topic_names.py7
-rw-r--r--bot/exts/moderation/defcon.py8
-rw-r--r--bot/exts/moderation/infraction/_scheduler.py12
-rw-r--r--bot/exts/moderation/infraction/management.py2
-rw-r--r--bot/exts/moderation/modlog.py8
-rw-r--r--bot/exts/moderation/modpings.py5
-rw-r--r--bot/exts/moderation/voice_gate.py6
-rw-r--r--bot/exts/recruitment/talentpool/_review.py7
-rw-r--r--bot/exts/utils/internal.py6
-rw-r--r--bot/exts/utils/ping.py5
-rw-r--r--bot/exts/utils/reminders.py10
-rw-r--r--bot/monkey_patches.py7
-rw-r--r--bot/utils/checks.py3
-rw-r--r--bot/utils/time.py19
-rw-r--r--tests/bot/test_converters.py50
-rw-r--r--tests/bot/utils/test_time.py27
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),
)