aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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),
)