aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--bot/converters.py9
-rw-r--r--bot/exts/info/information.py10
-rw-r--r--bot/exts/moderation/defcon.py31
-rw-r--r--bot/exts/moderation/infraction/_scheduler.py9
-rw-r--r--bot/exts/moderation/infraction/management.py22
-rw-r--r--bot/exts/moderation/infraction/superstarify.py6
-rw-r--r--bot/exts/moderation/modlog.py6
-rw-r--r--bot/exts/moderation/modpings.py7
-rw-r--r--bot/exts/moderation/slowmode.py3
-rw-r--r--bot/exts/moderation/stream.py7
-rw-r--r--bot/exts/moderation/watchchannels/_watchchannel.py7
-rw-r--r--bot/exts/recruitment/talentpool/_cog.py13
-rw-r--r--bot/exts/recruitment/talentpool/_review.py8
-rw-r--r--bot/exts/utils/reminders.py13
-rw-r--r--bot/exts/utils/utils.py5
-rw-r--r--bot/utils/scheduling.py4
-rw-r--r--bot/utils/time.py274
-rw-r--r--tests/bot/exts/info/test_information.py9
-rw-r--r--tests/bot/utils/test_time.py47
19 files changed, 283 insertions, 207 deletions
diff --git a/bot/converters.py b/bot/converters.py
index cd33f5ed0..3522a32aa 100644
--- a/bot/converters.py
+++ b/bot/converters.py
@@ -6,7 +6,6 @@ from datetime import datetime, timezone
from ssl import CertificateError
import dateutil.parser
-import dateutil.tz
import discord
from aiohttp import ClientConnectorError
from botcore.regex import DISCORD_INVITE
@@ -21,8 +20,8 @@ from bot.errors import InvalidInfraction
from bot.exts.info.doc import _inventory_parser
from bot.exts.info.tags import TagIdentifier
from bot.log import get_logger
+from bot.utils import time
from bot.utils.extensions import EXTENSIONS, unqualify
-from bot.utils.time import parse_duration_string
if t.TYPE_CHECKING:
from bot.exts.info.source import SourceType
@@ -338,7 +337,7 @@ class DurationDelta(Converter):
The units need to be provided in descending order of magnitude.
"""
- if not (delta := parse_duration_string(duration)):
+ if not (delta := time.parse_duration_string(duration)):
raise BadArgument(f"`{duration}` is not a valid duration string.")
return delta
@@ -454,9 +453,9 @@ class ISODateTime(Converter):
raise BadArgument(f"`{datetime_string}` is not a valid ISO-8601 datetime string")
if dt.tzinfo:
- dt = dt.astimezone(dateutil.tz.UTC)
+ dt = dt.astimezone(timezone.utc)
else: # Without a timezone, assume it represents UTC.
- dt = dt.replace(tzinfo=dateutil.tz.UTC)
+ dt = dt.replace(tzinfo=timezone.utc)
return dt
diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py
index 1f95c460f..29a00ec5d 100644
--- a/bot/exts/info/information.py
+++ b/bot/exts/info/information.py
@@ -18,10 +18,10 @@ from bot.decorators import in_whitelist
from bot.errors import NonExistentRoleError
from bot.log import get_logger
from bot.pagination import LinePaginator
+from bot.utils import time
from bot.utils.channel import is_mod_channel, is_staff_channel
from bot.utils.checks import cooldown_with_role_bypass, has_no_roles_check, in_whitelist_check
from bot.utils.members import get_or_fetch_member
-from bot.utils.time import TimestampFormats, discord_timestamp, humanize_delta
log = get_logger(__name__)
@@ -83,7 +83,7 @@ class Information(Cog):
defcon_info = ""
if cog := self.bot.get_cog("Defcon"):
- threshold = humanize_delta(cog.threshold) if cog.threshold else "-"
+ threshold = time.humanize_delta(cog.threshold) if cog.threshold else "-"
defcon_info = f"Defcon threshold: {threshold}\n"
verification = f"Verification level: {ctx.guild.verification_level.name}\n"
@@ -173,7 +173,7 @@ class Information(Cog):
"""Returns an embed full of server information."""
embed = Embed(colour=Colour.og_blurple(), title="Server Information")
- created = discord_timestamp(ctx.guild.created_at, TimestampFormats.RELATIVE)
+ created = time.format_relative(ctx.guild.created_at)
num_roles = len(ctx.guild.roles) - 1 # Exclude @everyone
# Server Features are only useful in certain channels
@@ -249,7 +249,7 @@ class Information(Cog):
"""Creates an embed containing information on the `user`."""
on_server = bool(await get_or_fetch_member(ctx.guild, user.id))
- created = discord_timestamp(user.created_at, TimestampFormats.RELATIVE)
+ created = time.format_relative(user.created_at)
name = str(user)
if on_server and user.nick:
@@ -272,7 +272,7 @@ class Information(Cog):
if on_server:
if user.joined_at:
- joined = discord_timestamp(user.joined_at, TimestampFormats.RELATIVE)
+ joined = time.format_relative(user.joined_at)
else:
joined = "Unable to get join date"
diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py
index 14db37367..178be734d 100644
--- a/bot/exts/moderation/defcon.py
+++ b/bot/exts/moderation/defcon.py
@@ -17,12 +17,9 @@ from bot.constants import Channels, Colours, Emojis, Event, Icons, MODERATION_RO
from bot.converters import DurationDelta, Expiry
from bot.exts.moderation.modlog import ModLog
from bot.log import get_logger
-from bot.utils import scheduling
+from bot.utils import scheduling, time
from bot.utils.messages import format_user
from bot.utils.scheduling import Scheduler
-from bot.utils.time import (
- TimestampFormats, discord_timestamp, humanize_delta, parse_duration_string, relativedelta_to_timedelta
-)
log = get_logger(__name__)
@@ -88,7 +85,7 @@ class Defcon(Cog):
try:
settings = await self.defcon_settings.to_dict()
- self.threshold = parse_duration_string(settings["threshold"]) if settings.get("threshold") else None
+ self.threshold = time.parse_duration_string(settings["threshold"]) if settings.get("threshold") else None
self.expiry = datetime.fromisoformat(settings["expiry"]) if settings.get("expiry") else None
except RedisError:
log.exception("Unable to get DEFCON settings!")
@@ -102,7 +99,7 @@ class Defcon(Cog):
self.scheduler.schedule_at(self.expiry, 0, self._remove_threshold())
self._update_notifier()
- log.info(f"DEFCON synchronized: {humanize_delta(self.threshold) if self.threshold else '-'}")
+ log.info(f"DEFCON synchronized: {time.humanize_delta(self.threshold) if self.threshold else '-'}")
self._update_channel_topic()
@@ -112,7 +109,7 @@ class Defcon(Cog):
if self.threshold:
now = arrow.utcnow()
- if now - member.created_at < relativedelta_to_timedelta(self.threshold):
+ if now - member.created_at < time.relativedelta_to_timedelta(self.threshold):
log.info(f"Rejecting user {member}: Account is too new")
message_sent = False
@@ -151,11 +148,12 @@ class Defcon(Cog):
@has_any_role(*MODERATION_ROLES)
async def status(self, ctx: Context) -> None:
"""Check the current status of DEFCON mode."""
+ expiry = time.format_relative(self.expiry) if self.expiry else "-"
embed = Embed(
colour=Colour.og_blurple(), title="DEFCON Status",
description=f"""
- **Threshold:** {humanize_delta(self.threshold) if self.threshold else "-"}
- **Expires:** {discord_timestamp(self.expiry, TimestampFormats.RELATIVE) if self.expiry else "-"}
+ **Threshold:** {time.humanize_delta(self.threshold) if self.threshold else "-"}
+ **Expires:** {expiry}
**Verification level:** {ctx.guild.verification_level.name}
"""
)
@@ -213,7 +211,8 @@ class Defcon(Cog):
def _update_channel_topic(self) -> None:
"""Update the #defcon channel topic with the current DEFCON status."""
- new_topic = f"{BASE_CHANNEL_TOPIC}\n(Threshold: {humanize_delta(self.threshold) if self.threshold else '-'})"
+ threshold = time.humanize_delta(self.threshold) if self.threshold else '-'
+ new_topic = f"{BASE_CHANNEL_TOPIC}\n(Threshold: {threshold})"
self.mod_log.ignore(Event.guild_channel_update, Channels.defcon)
scheduling.create_task(self.channel.edit(topic=new_topic))
@@ -255,12 +254,12 @@ class Defcon(Cog):
expiry_message = ""
if expiry:
- activity_duration = relativedelta(expiry, arrow.utcnow().datetime)
- expiry_message = f" for the next {humanize_delta(activity_duration, max_units=2)}"
+ formatted_expiry = time.humanize_delta(expiry, max_units=2)
+ expiry_message = f" for the next {formatted_expiry}"
if self.threshold:
channel_message = (
- f"updated; accounts must be {humanize_delta(self.threshold)} "
+ f"updated; accounts must be {time.humanize_delta(self.threshold)} "
f"old to join the server{expiry_message}"
)
else:
@@ -290,7 +289,7 @@ class Defcon(Cog):
def _log_threshold_stat(self, threshold: relativedelta) -> None:
"""Adds the threshold to the bot stats in days."""
- threshold_days = relativedelta_to_timedelta(threshold).total_seconds() / SECONDS_IN_DAY
+ threshold_days = time.relativedelta_to_timedelta(threshold).total_seconds() / SECONDS_IN_DAY
self.bot.stats.gauge("defcon.threshold", threshold_days)
async def _send_defcon_log(self, action: Action, actor: User) -> None:
@@ -298,7 +297,7 @@ class Defcon(Cog):
info = action.value
log_msg: str = (
f"**Staffer:** {actor.mention} {actor} (`{actor.id}`)\n"
- f"{info.template.format(threshold=(humanize_delta(self.threshold) if self.threshold else '-'))}"
+ f"{info.template.format(threshold=(time.humanize_delta(self.threshold) if self.threshold else '-'))}"
)
status_msg = f"DEFCON {action.name.lower()}"
@@ -317,7 +316,7 @@ class Defcon(Cog):
@tasks.loop(hours=1)
async def defcon_notifier(self) -> None:
"""Routinely notify moderators that DEFCON is active."""
- await self.channel.send(f"Defcon is on and is set to {humanize_delta(self.threshold)}.")
+ await self.channel.send(f"Defcon is on and is set to {time.humanize_delta(self.threshold)}.")
def cog_unload(self) -> None:
"""Cancel the notifer and threshold removal tasks when the cog unloads."""
diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py
index 57aa2d9b6..47b639421 100644
--- a/bot/exts/moderation/infraction/_scheduler.py
+++ b/bot/exts/moderation/infraction/_scheduler.py
@@ -136,7 +136,7 @@ class InfractionScheduler:
infr_type = infraction["type"]
icon = _utils.INFRACTION_ICONS[infr_type][0]
reason = infraction["reason"]
- expiry = time.format_infraction_with_duration(infraction["expires_at"])
+ expiry = time.format_with_duration(infraction["expires_at"])
id_ = infraction['id']
if user_reason is None:
@@ -381,20 +381,15 @@ class InfractionScheduler:
actor = infraction["actor"]
type_ = infraction["type"]
id_ = infraction["id"]
- inserted_at = infraction["inserted_at"]
- expiry = infraction["expires_at"]
log.info(f"Marking infraction #{id_} as inactive (expired).")
- expiry = dateutil.parser.isoparse(expiry) if expiry else None
- created = time.format_infraction_with_duration(inserted_at, expiry)
-
log_content = None
log_text = {
"Member": f"<@{user_id}>",
"Actor": f"<@{actor}>",
"Reason": infraction["reason"],
- "Created": created,
+ "Created": time.format_with_duration(infraction["inserted_at"], infraction["expires_at"]),
}
try:
diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py
index 9649ff852..dda3fadae 100644
--- a/bot/exts/moderation/infraction/management.py
+++ b/bot/exts/moderation/infraction/management.py
@@ -1,10 +1,7 @@
import textwrap
import typing as t
-from datetime import datetime, timezone
-import dateutil.parser
import discord
-from dateutil.relativedelta import relativedelta
from discord.ext import commands
from discord.ext.commands import Context
from discord.utils import escape_markdown
@@ -20,7 +17,6 @@ from bot.pagination import LinePaginator
from bot.utils import messages, time
from bot.utils.channel import is_mod_channel
from bot.utils.members import get_or_fetch_member
-from bot.utils.time import humanize_delta, until_expiration
log = get_logger(__name__)
@@ -151,7 +147,7 @@ class ModManagement(commands.Cog):
confirm_messages.append("marked as permanent")
elif duration is not None:
request_data['expires_at'] = duration.isoformat()
- expiry = time.format_infraction_with_duration(request_data['expires_at'])
+ expiry = time.format_with_duration(duration)
confirm_messages.append(f"set to expire on {expiry}")
else:
confirm_messages.append("expiry unchanged")
@@ -183,8 +179,8 @@ class ModManagement(commands.Cog):
self.infractions_cog.schedule_expiration(new_infraction)
log_text += f"""
- Previous expiry: {until_expiration(infraction['expires_at']) or "Permanent"}
- New expiry: {until_expiration(new_infraction['expires_at']) or "Permanent"}
+ Previous expiry: {time.until_expiration(infraction['expires_at'])}
+ New expiry: {time.until_expiration(new_infraction['expires_at'])}
""".rstrip()
changes = ' & '.join(confirm_messages)
@@ -352,7 +348,8 @@ class ModManagement(commands.Cog):
active = infraction["active"]
user = infraction["user"]
expires_at = infraction["expires_at"]
- created = time.format_infraction(infraction["inserted_at"])
+ inserted_at = infraction["inserted_at"]
+ created = time.discord_timestamp(inserted_at)
dm_sent = infraction["dm_sent"]
# Format the user string.
@@ -365,19 +362,14 @@ class ModManagement(commands.Cog):
user_str = f"<@{user['id']}> ({name}#{user['discriminator']:04})"
if active:
- remaining = time.until_expiration(expires_at) or "Expired"
+ remaining = time.until_expiration(expires_at)
else:
remaining = "Inactive"
if expires_at is None:
duration = "*Permanent*"
else:
- date_from = datetime.fromtimestamp(
- float(time.DISCORD_TIMESTAMP_REGEX.match(created).group(1)),
- timezone.utc
- )
- date_to = dateutil.parser.isoparse(expires_at)
- duration = humanize_delta(relativedelta(date_to, date_from))
+ duration = time.humanize_delta(inserted_at, expires_at)
# Format `dm_sent`
if dm_sent is None:
diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py
index 08c92b8f3..a037ca1be 100644
--- a/bot/exts/moderation/infraction/superstarify.py
+++ b/bot/exts/moderation/infraction/superstarify.py
@@ -14,9 +14,9 @@ from bot.converters import Duration, Expiry
from bot.exts.moderation.infraction import _utils
from bot.exts.moderation.infraction._scheduler import InfractionScheduler
from bot.log import get_logger
+from bot.utils import time
from bot.utils.members import get_or_fetch_member
from bot.utils.messages import format_user
-from bot.utils.time import format_infraction
log = get_logger(__name__)
NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#nickname-policy"
@@ -73,7 +73,7 @@ class Superstarify(InfractionScheduler, Cog):
notified = await _utils.notify_infraction(
user=after,
infr_type="Superstarify",
- expires_at=format_infraction(infraction["expires_at"]),
+ expires_at=time.discord_timestamp(infraction["expires_at"]),
reason=(
"You have tried to change your nickname on the **Python Discord** server "
f"from **{before.display_name}** to **{after.display_name}**, but as you "
@@ -150,7 +150,7 @@ class Superstarify(InfractionScheduler, Cog):
id_ = infraction["id"]
forced_nick = self.get_nick(id_, member.id)
- expiry_str = format_infraction(infraction["expires_at"])
+ expiry_str = time.discord_timestamp(infraction["expires_at"])
# Apply the infraction
async def action() -> None:
diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py
index fc9204998..2c01a4a21 100644
--- a/bot/exts/moderation/modlog.py
+++ b/bot/exts/moderation/modlog.py
@@ -16,8 +16,8 @@ from discord.utils import escape_markdown
from bot.bot import Bot
from bot.constants import Categories, Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, Roles, URLs
from bot.log import get_logger
+from bot.utils import time
from bot.utils.messages import format_user
-from bot.utils.time import humanize_delta
log = get_logger(__name__)
@@ -407,7 +407,7 @@ class ModLog(Cog, name="ModLog"):
now = datetime.now(timezone.utc)
difference = abs(relativedelta(now, member.created_at))
- message = format_user(member) + "\n\n**Account age:** " + humanize_delta(difference)
+ message = format_user(member) + "\n\n**Account age:** " + time.humanize_delta(difference)
if difference.days < 1 and difference.months < 1 and difference.years < 1: # New user account!
message = f"{Emojis.new} {message}"
@@ -713,7 +713,7 @@ class ModLog(Cog, name="ModLog"):
# datetime as the baseline and create a human-readable delta between this edit event
# and the last time the message was edited
timestamp = msg_before.edited_at
- delta = humanize_delta(relativedelta(msg_after.edited_at, msg_before.edited_at))
+ delta = time.humanize_delta(msg_after.edited_at, msg_before.edited_at)
footer = f"Last edited {delta} ago"
else:
# Message was not previously edited, use the created_at datetime as the baseline, no
diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py
index 20a8c39d7..b5cd29b12 100644
--- a/bot/exts/moderation/modpings.py
+++ b/bot/exts/moderation/modpings.py
@@ -11,9 +11,8 @@ from bot.bot import Bot
from bot.constants import Colours, Emojis, Guild, Icons, MODERATION_ROLES, Roles
from bot.converters import Expiry
from bot.log import get_logger
-from bot.utils import scheduling
+from bot.utils import scheduling, time
from bot.utils.scheduling import Scheduler
-from bot.utils.time import TimestampFormats, discord_timestamp
log = get_logger(__name__)
@@ -233,8 +232,8 @@ class ModPings(Cog):
await ctx.send(
f"{Emojis.ok_hand} {ctx.author.mention} Scheduled mod pings from "
- f"{discord_timestamp(start, TimestampFormats.TIME)} to "
- f"{discord_timestamp(end, TimestampFormats.TIME)}!"
+ f"{time.discord_timestamp(start, time.TimestampFormats.TIME)} to "
+ f"{time.discord_timestamp(end, time.TimestampFormats.TIME)}!"
)
@schedule_modpings.command(name='delete', aliases=('del', 'd'))
diff --git a/bot/exts/moderation/slowmode.py b/bot/exts/moderation/slowmode.py
index da04d1e98..b6a771441 100644
--- a/bot/exts/moderation/slowmode.py
+++ b/bot/exts/moderation/slowmode.py
@@ -39,8 +39,7 @@ class Slowmode(Cog):
if channel is None:
channel = ctx.channel
- delay = relativedelta(seconds=channel.slowmode_delay)
- humanized_delay = time.humanize_delta(delay)
+ humanized_delay = time.humanize_delta(seconds=channel.slowmode_delay)
await ctx.send(f'The slowmode delay for {channel.mention} is {humanized_delay}.')
diff --git a/bot/exts/moderation/stream.py b/bot/exts/moderation/stream.py
index 99bbd8721..4dccc8a7e 100644
--- a/bot/exts/moderation/stream.py
+++ b/bot/exts/moderation/stream.py
@@ -14,9 +14,8 @@ from bot.constants import (
from bot.converters import Expiry
from bot.log import get_logger
from bot.pagination import LinePaginator
-from bot.utils import scheduling
+from bot.utils import scheduling, time
from bot.utils.members import get_or_fetch_member
-from bot.utils.time import discord_timestamp, format_infraction_with_duration
log = get_logger(__name__)
@@ -131,10 +130,10 @@ class Stream(commands.Cog):
await member.add_roles(discord.Object(Roles.video), reason="Temporary streaming access granted")
- await ctx.send(f"{Emojis.check_mark} {member.mention} can now stream until {discord_timestamp(duration)}.")
+ await ctx.send(f"{Emojis.check_mark} {member.mention} can now stream until {time.discord_timestamp(duration)}.")
# Convert here for nicer logging
- revoke_time = format_infraction_with_duration(str(duration))
+ revoke_time = time.format_with_duration(duration)
log.debug(f"Successfully gave {member} ({member.id}) permission to stream until {revoke_time}.")
@commands.command(aliases=("pstream",))
diff --git a/bot/exts/moderation/watchchannels/_watchchannel.py b/bot/exts/moderation/watchchannels/_watchchannel.py
index 34d445912..ee9b6ba45 100644
--- a/bot/exts/moderation/watchchannels/_watchchannel.py
+++ b/bot/exts/moderation/watchchannels/_watchchannel.py
@@ -18,9 +18,8 @@ from bot.exts.filters.webhook_remover import WEBHOOK_URL_RE
from bot.exts.moderation.modlog import ModLog
from bot.log import CustomLogger, get_logger
from bot.pagination import LinePaginator
-from bot.utils import CogABCMeta, messages, scheduling
+from bot.utils import CogABCMeta, messages, scheduling, time
from bot.utils.members import get_or_fetch_member
-from bot.utils.time import get_time_delta
log = get_logger(__name__)
@@ -286,7 +285,7 @@ class WatchChannel(metaclass=CogABCMeta):
actor = actor.display_name if actor else self.watched_users[user_id]['actor']
inserted_at = self.watched_users[user_id]['inserted_at']
- time_delta = get_time_delta(inserted_at)
+ time_delta = time.format_relative(inserted_at)
reason = self.watched_users[user_id]['reason']
@@ -360,7 +359,7 @@ class WatchChannel(metaclass=CogABCMeta):
if member:
line += f" ({member.name}#{member.discriminator})"
inserted_at = user_data['inserted_at']
- line += f", added {get_time_delta(inserted_at)}"
+ line += f", added {time.format_relative(inserted_at)}"
if not member: # Cross off users who left the server.
line = f"~~{line}~~"
list_data["info"][user_id] = line
diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py
index 8fa0be5b1..0554bf37a 100644
--- a/bot/exts/recruitment/talentpool/_cog.py
+++ b/bot/exts/recruitment/talentpool/_cog.py
@@ -17,7 +17,6 @@ from bot.log import get_logger
from bot.pagination import LinePaginator
from bot.utils import scheduling, time
from bot.utils.members import get_or_fetch_member
-from bot.utils.time import get_time_delta
AUTOREVIEW_ENABLED_KEY = "autoreview_enabled"
REASON_MAX_CHARS = 1000
@@ -181,7 +180,7 @@ class TalentPool(Cog, name="Talentpool"):
if member:
line += f" ({member.name}#{member.discriminator})"
inserted_at = user_data['inserted_at']
- line += f", added {get_time_delta(inserted_at)}"
+ line += f", added {time.format_relative(inserted_at)}"
if not member: # Cross off users who left the server.
line = f"~~{line}~~"
if user_data['reviewed']:
@@ -260,7 +259,7 @@ class TalentPool(Cog, name="Talentpool"):
return
if len(reason) > REASON_MAX_CHARS:
- await ctx.send(f":x: Maxiumum allowed characters for the reason is {REASON_MAX_CHARS}.")
+ await ctx.send(f":x: Maximum allowed characters for the reason is {REASON_MAX_CHARS}.")
return
# Manual request with `raise_for_status` as False because we want the actual response
@@ -445,7 +444,7 @@ class TalentPool(Cog, name="Talentpool"):
async def edit_end_reason_command(self, ctx: Context, nomination_id: int, *, reason: str) -> None:
"""Edits the unnominate reason for the nomination with the given `id`."""
if len(reason) > REASON_MAX_CHARS:
- await ctx.send(f":x: Maxiumum allowed characters for the end reason is {REASON_MAX_CHARS}.")
+ await ctx.send(f":x: Maximum allowed characters for the end reason is {REASON_MAX_CHARS}.")
return
try:
@@ -562,7 +561,7 @@ class TalentPool(Cog, name="Talentpool"):
actor = await get_or_fetch_member(guild, actor_id)
reason = site_entry["reason"] or "*None*"
- created = time.format_infraction(site_entry["inserted_at"])
+ created = time.discord_timestamp(site_entry["inserted_at"])
entries.append(
f"Actor: {actor.mention if actor else actor_id}\nCreated: {created}\nReason: {reason}"
)
@@ -571,7 +570,7 @@ class TalentPool(Cog, name="Talentpool"):
active = nomination_object["active"]
- start_date = time.format_infraction(nomination_object["inserted_at"])
+ start_date = time.discord_timestamp(nomination_object["inserted_at"])
if active:
lines = textwrap.dedent(
f"""
@@ -585,7 +584,7 @@ class TalentPool(Cog, name="Talentpool"):
"""
)
else:
- end_date = time.format_infraction(nomination_object["ended_at"])
+ end_date = time.discord_timestamp(nomination_object["ended_at"])
lines = textwrap.dedent(
f"""
===============
diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py
index 0e7194892..b4d177622 100644
--- a/bot/exts/recruitment/talentpool/_review.py
+++ b/bot/exts/recruitment/talentpool/_review.py
@@ -17,10 +17,10 @@ from bot.api import ResponseCodeError
from bot.bot import Bot
from bot.constants import Channels, Colours, Emojis, Guild, Roles
from bot.log import get_logger
+from bot.utils import time
from bot.utils.members import get_or_fetch_member
from bot.utils.messages import count_unique_users_reaction, pin_no_system_message
from bot.utils.scheduling import Scheduler
-from bot.utils.time import get_time_delta, time_since
if typing.TYPE_CHECKING:
from bot.exts.recruitment.talentpool._cog import TalentPool
@@ -273,7 +273,7 @@ class Reviewer:
last_channel = user_activity["top_channel_activity"][-1]
channels += f", and {last_channel[1]} in {last_channel[0]}"
- joined_at_formatted = time_since(member.joined_at)
+ joined_at_formatted = time.format_relative(member.joined_at)
review = (
f"{member.name} joined the server **{joined_at_formatted}**"
f" and has **{messages} messages**{channels}."
@@ -321,7 +321,7 @@ class Reviewer:
infractions += ", with the last infraction issued "
# Infractions were ordered by time since insertion descending.
- infractions += get_time_delta(infraction_list[0]['inserted_at'])
+ infractions += time.format_relative(infraction_list[0]['inserted_at'])
return f"They have {infractions}."
@@ -365,7 +365,7 @@ class Reviewer:
nomination_times = f"{num_entries} times" if num_entries > 1 else "once"
rejection_times = f"{len(history)} times" if len(history) > 1 else "once"
- end_time = time_since(isoparse(history[0]['ended_at']))
+ end_time = time.format_relative(history[0]['ended_at'])
review = (
f"They were nominated **{nomination_times}** before"
diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py
index 90677b2dd..289d00356 100644
--- a/bot/exts/utils/reminders.py
+++ b/bot/exts/utils/reminders.py
@@ -13,13 +13,12 @@ from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, Role
from bot.converters import Duration, UnambiguousUser
from bot.log import get_logger
from bot.pagination import LinePaginator
-from bot.utils import scheduling
+from bot.utils import scheduling, time
from bot.utils.checks import has_any_role_check, has_no_roles_check
from bot.utils.lock import lock_arg
from bot.utils.members import get_or_fetch_member
from bot.utils.messages import send_denial
from bot.utils.scheduling import Scheduler
-from bot.utils.time import TimestampFormats, discord_timestamp
log = get_logger(__name__)
@@ -169,7 +168,7 @@ class Reminders(Cog):
self.schedule_reminder(reminder)
@lock_arg(LOCK_NAMESPACE, "reminder", itemgetter("id"), raise_error=True)
- async def send_reminder(self, reminder: dict, expected_time: datetime = None) -> None:
+ async def send_reminder(self, reminder: dict, expected_time: t.Optional[time.Timestamp] = None) -> None:
"""Send the reminder."""
is_valid, user, channel = self.ensure_valid_reminder(reminder)
if not is_valid:
@@ -310,7 +309,8 @@ class Reminders(Cog):
}
)
- mention_string = f"Your reminder will arrive on {discord_timestamp(expiration, TimestampFormats.DAY_TIME)}"
+ formatted_time = time.discord_timestamp(expiration, time.TimestampFormats.DAY_TIME)
+ mention_string = f"Your reminder will arrive on {formatted_time}"
if mentions:
mention_string += f" and will mention {len(mentions)} other(s)"
@@ -347,8 +347,7 @@ class Reminders(Cog):
for content, remind_at, id_, mentions in reminders:
# Parse and humanize the time, make it pretty :D
- remind_datetime = isoparse(remind_at)
- time = discord_timestamp(remind_datetime, TimestampFormats.RELATIVE)
+ expiry = time.format_relative(remind_at)
mentions = ", ".join([
# Both Role and User objects have the `name` attribute
@@ -357,7 +356,7 @@ class Reminders(Cog):
mention_string = f"\n**Mentions:** {mentions}" if mentions else ""
text = textwrap.dedent(f"""
- **Reminder #{id_}:** *expires {time}* (ID: {id_}){mention_string}
+ **Reminder #{id_}:** *expires {expiry}* (ID: {id_}){mention_string}
{content}
""").strip()
diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py
index f76eea516..2a074788e 100644
--- a/bot/exts/utils/utils.py
+++ b/bot/exts/utils/utils.py
@@ -13,8 +13,7 @@ from bot.converters import Snowflake
from bot.decorators import in_whitelist
from bot.log import get_logger
from bot.pagination import LinePaginator
-from bot.utils import messages
-from bot.utils.time import time_since
+from bot.utils import messages, time
log = get_logger(__name__)
@@ -173,7 +172,7 @@ class Utils(Cog):
lines = []
for snowflake in snowflakes:
created_at = snowflake_time(snowflake)
- lines.append(f"**{snowflake}**\nCreated at {created_at} ({time_since(created_at)}).")
+ lines.append(f"**{snowflake}**\nCreated at {created_at} ({time.format_relative(created_at)}).")
await LinePaginator.paginate(
lines,
diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py
index 7b4c8e2de..23acacf74 100644
--- a/bot/utils/scheduling.py
+++ b/bot/utils/scheduling.py
@@ -5,6 +5,8 @@ import typing as t
from datetime import datetime
from functools import partial
+from arrow import Arrow
+
from bot.log import get_logger
@@ -58,7 +60,7 @@ class Scheduler:
self._scheduled_tasks[task_id] = task
self._log.debug(f"Scheduled task #{task_id} {id(task)}.")
- def schedule_at(self, time: datetime, task_id: t.Hashable, coroutine: t.Coroutine) -> None:
+ def schedule_at(self, time: t.Union[datetime, Arrow], task_id: t.Hashable, coroutine: t.Coroutine) -> None:
"""
Schedule `coroutine` to be executed at the given `time`.
diff --git a/bot/utils/time.py b/bot/utils/time.py
index eaa9b72e9..a0379c3ef 100644
--- a/bot/utils/time.py
+++ b/bot/utils/time.py
@@ -1,15 +1,12 @@
import datetime
import re
from enum import Enum
-from typing import Optional, Union
+from time import struct_time
+from typing import Literal, Optional, Union, overload
import arrow
-import dateutil.parser
from dateutil.relativedelta import relativedelta
-RFC1123_FORMAT = "%a, %d %b %Y %H:%M:%S GMT"
-DISCORD_TIMESTAMP_REGEX = re.compile(r"<t:(\d+):f>")
-
_DURATION_REGEX = re.compile(
r"((?P<years>\d+?) ?(years|year|Y|y) ?)?"
r"((?P<months>\d+?) ?(months|month|m) ?)?"
@@ -20,8 +17,19 @@ _DURATION_REGEX = re.compile(
r"((?P<seconds>\d+?) ?(seconds|second|S|s))?"
)
-
-ValidTimestamp = Union[int, datetime.datetime, datetime.date, datetime.timedelta, relativedelta]
+# All supported types for the single-argument overload of arrow.get(). tzinfo is excluded because
+# it's too implicit of a way for the caller to specify that they want the current time.
+Timestamp = Union[
+ arrow.Arrow,
+ datetime.datetime,
+ datetime.date,
+ struct_time,
+ int, # POSIX timestamp
+ float, # POSIX timestamp
+ str, # ISO 8601-formatted string
+ tuple[int, int, int], # ISO calendar tuple
+]
+_Precision = Literal["years", "months", "days", "hours", "minutes", "seconds"]
class TimestampFormats(Enum):
@@ -42,7 +50,7 @@ class TimestampFormats(Enum):
def _stringify_time_unit(value: int, unit: str) -> str:
"""
- Returns a string to represent a value and time unit, ensuring that it uses the right plural form of the unit.
+ Return a string to represent a value and time unit, ensuring the unit's correct plural form is used.
>>> _stringify_time_unit(1, "seconds")
"1 second"
@@ -61,33 +69,140 @@ def _stringify_time_unit(value: int, unit: str) -> str:
return f"{value} {unit}"
-def discord_timestamp(timestamp: ValidTimestamp, format: TimestampFormats = TimestampFormats.DATE_TIME) -> str:
- """Create and format a Discord flavored markdown timestamp."""
- if format not in TimestampFormats:
- raise ValueError(f"Format can only be one of {', '.join(TimestampFormats.args)}, not {format}.")
+def discord_timestamp(timestamp: Timestamp, format: TimestampFormats = TimestampFormats.DATE_TIME) -> str:
+ """
+ Format a timestamp as a Discord-flavored Markdown timestamp.
+
+ `timestamp` can be any type supported by the single-arg `arrow.get()`, except for a `tzinfo`.
+ """
+ timestamp = int(arrow.get(timestamp).timestamp())
+ return f"<t:{timestamp}:{format.value}>"
+
+
+# region humanize_delta overloads
+@overload
+def humanize_delta(
+ arg1: Union[relativedelta, Timestamp],
+ /,
+ *,
+ precision: _Precision = "seconds",
+ max_units: int = 6,
+ absolute: bool = True,
+) -> str:
+ ...
+
+
+@overload
+def humanize_delta(
+ end: Timestamp,
+ start: Timestamp,
+ /,
+ *,
+ precision: _Precision = "seconds",
+ max_units: int = 6,
+ absolute: bool = True,
+) -> str:
+ ...
+
+
+@overload
+def humanize_delta(
+ *,
+ years: int = 0,
+ months: int = 0,
+ weeks: float = 0,
+ days: float = 0,
+ hours: float = 0,
+ minutes: float = 0,
+ seconds: float = 0,
+ precision: _Precision = "seconds",
+ max_units: int = 6,
+ absolute: bool = True,
+) -> str:
+ ...
+# endregion
+
+
+def humanize_delta(
+ *args,
+ precision: _Precision = "seconds",
+ max_units: int = 6,
+ absolute: bool = True,
+ **kwargs,
+) -> str:
+ """
+ Return a human-readable version of a time duration.
+
+ `precision` is the smallest unit of time to include (e.g. "seconds", "minutes").
- # Convert each possible timestamp class to an integer.
- if isinstance(timestamp, datetime.datetime):
- timestamp = (timestamp - arrow.get(0)).total_seconds()
- elif isinstance(timestamp, datetime.date):
- timestamp = (timestamp - arrow.get(0)).total_seconds()
- elif isinstance(timestamp, datetime.timedelta):
- timestamp = timestamp.total_seconds()
- elif isinstance(timestamp, relativedelta):
- timestamp = timestamp.seconds
+ `max_units` is the maximum number of units of time to include.
+ Count units from largest to smallest (e.g. count days before months).
- return f"<t:{int(timestamp)}:{format.value}>"
+ Use the absolute value of the duration if `absolute` is True.
+ Usage:
-def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: int = 6) -> str:
- """
- Returns a human-readable version of the relativedelta.
+ Keyword arguments specifying values for time units, to construct a `relativedelta` and humanize
+ the duration represented by it:
+
+ >>> humanize_delta(days=2, hours=16, seconds=23)
+ '2 days, 16 hours and 23 seconds'
+
+ **One** `relativedelta` object, to humanize the duration represented by it:
+
+ >>> humanize_delta(relativedelta(years=12, months=6))
+ '12 years and 6 months'
+
+ Note that `leapdays` and absolute info (singular names) will be ignored during humanization.
+
+ **One** timestamp of a type supported by the single-arg `arrow.get()`, except for `tzinfo`,
+ to humanize the duration between it and the current time:
+
+ >>> humanize_delta('2021-08-06T12:43:01Z', absolute=True) # now = 2021-08-06T12:33:33Z
+ '9 minutes and 28 seconds'
+
+ >>> humanize_delta('2021-08-06T12:43:01Z', absolute=False) # now = 2021-08-06T12:33:33Z
+ '-9 minutes and -28 seconds'
+
+ **Two** timestamps, each of a type supported by the single-arg `arrow.get()`, except for
+ `tzinfo`, to humanize the duration between them:
+
+ >>> humanize_delta(datetime.datetime(2020, 1, 1), '2021-01-01T12:00:00Z', absolute=False)
+ '1 year and 12 hours'
+
+ >>> humanize_delta('2021-01-01T12:00:00Z', datetime.datetime(2020, 1, 1), absolute=False)
+ '-1 years and -12 hours'
+
+ Note that order of the arguments can result in a different output even if `absolute` is True:
+
+ >>> x = datetime.datetime(3000, 11, 1)
+ >>> y = datetime.datetime(3000, 9, 2)
+ >>> humanize_delta(y, x, absolute=True), humanize_delta(x, y, absolute=True)
+ ('1 month and 30 days', '1 month and 29 days')
- precision specifies the smallest unit of time to include (e.g. "seconds", "minutes").
- max_units specifies the maximum number of units of time to include (e.g. 1 may include days but not hours).
+ This is due to the nature of `relativedelta`; it does not represent a fixed period of time.
+ Instead, it's relative to the `datetime` to which it's added to get the other `datetime`.
+ In the example, the difference arises because all months don't have the same number of days.
"""
+ if args and kwargs:
+ raise ValueError("Unsupported combination of positional and keyword arguments.")
+
+ if len(args) == 0:
+ delta = relativedelta(**kwargs)
+ elif len(args) == 1 and isinstance(args[0], relativedelta):
+ delta = args[0]
+ elif len(args) <= 2:
+ end = arrow.get(args[0])
+ start = arrow.get(args[1]) if len(args) == 2 else arrow.utcnow()
+
+ delta = relativedelta(end.datetime, start.datetime)
+ if absolute:
+ delta = abs(delta)
+ else:
+ raise ValueError(f"Received {len(args)} positional arguments, but expected 1 or 2.")
+
if max_units <= 0:
- raise ValueError("max_units must be positive")
+ raise ValueError("max_units must be positive.")
units = (
("years", delta.years),
@@ -98,7 +213,7 @@ def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units:
("seconds", delta.seconds),
)
- # Add the time units that are >0, but stop at accuracy or max_units.
+ # Add the time units that are >0, but stop at precision or max_units.
time_strings = []
unit_count = 0
for unit, value in units:
@@ -109,7 +224,7 @@ def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units:
if unit == precision or unit_count >= max_units:
break
- # Add the 'and' between the last two units, if necessary
+ # Add the 'and' between the last two units, if necessary.
if len(time_strings) > 1:
time_strings[-1] = f"{time_strings[-2]} and {time_strings[-1]}"
del time_strings[-2]
@@ -123,19 +238,12 @@ def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units:
return humanized
-def get_time_delta(time_string: str) -> str:
- """Returns the time in human-readable time delta format."""
- date_time = dateutil.parser.isoparse(time_string)
- time_delta = time_since(date_time)
-
- return time_delta
-
-
def parse_duration_string(duration: str) -> Optional[relativedelta]:
"""
- Converts a `duration` string to a relativedelta object.
+ Convert a `duration` string to a relativedelta object.
+
+ The following symbols are supported for each unit of time:
- The function supports the following symbols for each unit of time:
- years: `Y`, `y`, `year`, `years`
- months: `m`, `month`, `months`
- weeks: `w`, `W`, `week`, `weeks`
@@ -143,8 +251,9 @@ def parse_duration_string(duration: str) -> Optional[relativedelta]:
- hours: `H`, `h`, `hour`, `hours`
- minutes: `M`, `minute`, `minutes`
- seconds: `S`, `s`, `second`, `seconds`
+
The units need to be provided in descending order of magnitude.
- If the string does represent a durationdelta object, it will return None.
+ Return None if the `duration` string cannot be parsed according to the symbols above.
"""
match = _DURATION_REGEX.fullmatch(duration)
if not match:
@@ -157,76 +266,63 @@ def parse_duration_string(duration: str) -> Optional[relativedelta]:
def relativedelta_to_timedelta(delta: relativedelta) -> datetime.timedelta:
- """Converts a relativedelta object to a timedelta object."""
+ """Convert a relativedelta object to a timedelta object."""
utcnow = arrow.utcnow()
return utcnow + delta - utcnow
-def time_since(past_datetime: datetime.datetime) -> str:
- """Takes a datetime and returns a discord timestamp that describes how long ago that datetime was."""
- return discord_timestamp(past_datetime, TimestampFormats.RELATIVE)
-
-
-def parse_rfc1123(stamp: str) -> datetime.datetime:
- """Parse RFC1123 time string into datetime."""
- return datetime.datetime.strptime(stamp, RFC1123_FORMAT).replace(tzinfo=datetime.timezone.utc)
+def format_relative(timestamp: Timestamp) -> str:
+ """
+ Format `timestamp` as a relative Discord timestamp.
+ A relative timestamp describes how much time has elapsed since `timestamp` or how much time
+ remains until `timestamp` is reached.
-def format_infraction(timestamp: str) -> str:
- """Format an infraction timestamp to a discord timestamp."""
- return discord_timestamp(dateutil.parser.isoparse(timestamp))
+ `timestamp` can be any type supported by the single-arg `arrow.get()`, except for a `tzinfo`.
+ """
+ return discord_timestamp(timestamp, TimestampFormats.RELATIVE)
-def format_infraction_with_duration(
- date_to: Optional[str],
- date_from: Optional[datetime.datetime] = None,
+def format_with_duration(
+ timestamp: Optional[Timestamp],
+ other_timestamp: Optional[Timestamp] = None,
max_units: int = 2,
- absolute: bool = True
) -> Optional[str]:
"""
- Return `date_to` formatted as a discord timestamp with the timestamp duration since `date_from`.
+ Return `timestamp` formatted as a discord timestamp with the timestamp duration since `other_timestamp`.
+
+ `timestamp` and `other_timestamp` can be any type supported by the single-arg `arrow.get()`,
+ except for a `tzinfo`. Use the current time if `other_timestamp` is None or unspecified.
- `max_units` specifies the maximum number of units of time to include in the duration. For
- example, a value of 1 may include days but not hours.
+ `max_units` is forwarded to `time.humanize_delta`. See its documentation for more information.
- If `absolute` is True, the absolute value of the duration delta is used. This prevents negative
- values in the case that `date_to` is in the past relative to `date_from`.
+ Return None if `timestamp` is None.
"""
- if not date_to:
+ if timestamp is None:
return None
- date_to_formatted = format_infraction(date_to)
-
- date_from = date_from or datetime.datetime.now(datetime.timezone.utc)
- date_to = dateutil.parser.isoparse(date_to).replace(microsecond=0)
+ if other_timestamp is None:
+ other_timestamp = arrow.utcnow()
- delta = relativedelta(date_to, date_from)
- if absolute:
- delta = abs(delta)
+ formatted_timestamp = discord_timestamp(timestamp)
+ duration = humanize_delta(timestamp, other_timestamp, max_units=max_units)
- duration = humanize_delta(delta, max_units=max_units)
- duration_formatted = f" ({duration})" if duration else ""
+ return f"{formatted_timestamp} ({duration})"
- return f"{date_to_formatted}{duration_formatted}"
-
-def until_expiration(
- expiry: Optional[str]
-) -> Optional[str]:
+def until_expiration(expiry: Optional[Timestamp]) -> str:
"""
- Get the remaining time until infraction's expiration, in a discord timestamp.
+ Get the remaining time until an infraction's expiration as a Discord timestamp.
- Returns a human-readable version of the remaining duration between arrow.utcnow() and an expiry.
- Similar to time_since, except that this function doesn't error on a null input
- and return null if the expiry is in the paste
- """
- if not expiry:
- return None
+ `expiry` can be any type supported by the single-arg `arrow.get()`, except for a `tzinfo`.
- now = arrow.utcnow()
- since = dateutil.parser.isoparse(expiry).replace(microsecond=0)
+ Return "Permanent" if `expiry` is None. Return "Expired" if `expiry` is in the past.
+ """
+ if expiry is None:
+ return "Permanent"
- if since < now:
- return None
+ expiry = arrow.get(expiry)
+ if expiry < arrow.utcnow():
+ return "Expired"
- return discord_timestamp(since, TimestampFormats.RELATIVE)
+ return format_relative(expiry)
diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py
index 30e5258fb..d896b7652 100644
--- a/tests/bot/exts/info/test_information.py
+++ b/tests/bot/exts/info/test_information.py
@@ -1,6 +1,7 @@
import textwrap
import unittest
import unittest.mock
+from datetime import datetime
import discord
@@ -288,6 +289,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):
user.nick = None
user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock")
user.colour = 0
+ user.created_at = user.joined_at = datetime.utcnow()
embed = await self.cog.create_user_embed(ctx, user, False)
@@ -309,6 +311,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):
user.nick = "Cat lover"
user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock")
user.colour = 0
+ user.created_at = user.joined_at = datetime.utcnow()
embed = await self.cog.create_user_embed(ctx, user, False)
@@ -329,6 +332,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):
# A `MockMember` has the @Everyone role by default; we add the Admins to that.
user = helpers.MockMember(roles=[admins_role], colour=100)
+ user.created_at = user.joined_at = datetime.utcnow()
embed = await self.cog.create_user_embed(ctx, user, False)
@@ -355,6 +359,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):
nomination_counts.return_value = ("Nominations", "nomination info")
user = helpers.MockMember(id=314, roles=[moderators_role], colour=100)
+ user.created_at = user.joined_at = datetime.utcfromtimestamp(1)
embed = await self.cog.create_user_embed(ctx, user, False)
infraction_counts.assert_called_once_with(user)
@@ -394,6 +399,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):
user_messages.return_value = ("Messages", "user message counts")
user = helpers.MockMember(id=314, roles=[moderators_role], colour=100)
+ user.created_at = user.joined_at = datetime.utcfromtimestamp(1)
embed = await self.cog.create_user_embed(ctx, user, False)
infraction_counts.assert_called_once_with(user)
@@ -440,6 +446,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):
moderators_role = helpers.MockRole(name='Moderators')
user = helpers.MockMember(id=314, roles=[moderators_role], colour=100)
+ user.created_at = user.joined_at = datetime.utcnow()
embed = await self.cog.create_user_embed(ctx, user, False)
self.assertEqual(embed.colour, discord.Colour(100))
@@ -457,6 +464,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):
ctx = helpers.MockContext()
user = helpers.MockMember(id=217, colour=discord.Colour.default())
+ user.created_at = user.joined_at = datetime.utcnow()
embed = await self.cog.create_user_embed(ctx, user, False)
self.assertEqual(embed.colour, discord.Colour.og_blurple())
@@ -474,6 +482,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):
ctx = helpers.MockContext()
user = helpers.MockMember(id=217, colour=0)
+ user.created_at = user.joined_at = datetime.utcnow()
user.display_avatar.url = "avatar url"
embed = await self.cog.create_user_embed(ctx, user, False)
diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py
index a3dcbfc0a..120d65176 100644
--- a/tests/bot/utils/test_time.py
+++ b/tests/bot/utils/test_time.py
@@ -13,13 +13,15 @@ class TimeTests(unittest.TestCase):
"""humanize_delta should be able to handle unknown units, and will not abort."""
# Does not abort for unknown units, as the unit name is checked
# against the attribute of the relativedelta instance.
- self.assertEqual(time.humanize_delta(relativedelta(days=2, hours=2), 'elephants', 2), '2 days and 2 hours')
+ actual = time.humanize_delta(relativedelta(days=2, hours=2), precision='elephants', max_units=2)
+ self.assertEqual(actual, '2 days and 2 hours')
def test_humanize_delta_handle_high_units(self):
"""humanize_delta should be able to handle very high units."""
# Very high maximum units, but it only ever iterates over
# each value the relativedelta might have.
- self.assertEqual(time.humanize_delta(relativedelta(days=2, hours=2), 'hours', 20), '2 days and 2 hours')
+ actual = time.humanize_delta(relativedelta(days=2, hours=2), precision='hours', max_units=20)
+ self.assertEqual(actual, '2 days and 2 hours')
def test_humanize_delta_should_normal_usage(self):
"""Testing humanize delta."""
@@ -32,7 +34,8 @@ class TimeTests(unittest.TestCase):
for delta, precision, max_units, expected in test_cases:
with self.subTest(delta=delta, precision=precision, max_units=max_units, expected=expected):
- self.assertEqual(time.humanize_delta(delta, precision, max_units), expected)
+ actual = time.humanize_delta(delta, precision=precision, max_units=max_units)
+ self.assertEqual(actual, expected)
def test_humanize_delta_raises_for_invalid_max_units(self):
"""humanize_delta should raises ValueError('max_units must be positive') for invalid max_units."""
@@ -40,22 +43,11 @@ class TimeTests(unittest.TestCase):
for max_units in test_cases:
with self.subTest(max_units=max_units), self.assertRaises(ValueError) as error:
- time.humanize_delta(relativedelta(days=2, hours=2), 'hours', max_units)
- self.assertEqual(str(error.exception), 'max_units must be positive')
-
- def test_parse_rfc1123(self):
- """Testing parse_rfc1123."""
- self.assertEqual(
- time.parse_rfc1123('Sun, 15 Sep 2019 12:00:00 GMT'),
- datetime(2019, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
- )
-
- def test_format_infraction(self):
- """Testing format_infraction."""
- self.assertEqual(time.format_infraction('2019-12-12T00:01:00Z'), '<t:1576108860:f>')
+ time.humanize_delta(relativedelta(days=2, hours=2), precision='hours', max_units=max_units)
+ self.assertEqual(str(error.exception), 'max_units must be positive.')
- def test_format_infraction_with_duration_none_expiry(self):
- """format_infraction_with_duration should work for None expiry."""
+ def test_format_with_duration_none_expiry(self):
+ """format_with_duration should work for None expiry."""
test_cases = (
(None, None, None, None),
@@ -67,10 +59,10 @@ class TimeTests(unittest.TestCase):
for expiry, date_from, max_units, expected in test_cases:
with self.subTest(expiry=expiry, date_from=date_from, max_units=max_units, expected=expected):
- self.assertEqual(time.format_infraction_with_duration(expiry, date_from, max_units), expected)
+ self.assertEqual(time.format_with_duration(expiry, date_from, max_units), expected)
- def test_format_infraction_with_duration_custom_units(self):
- """format_infraction_with_duration should work for custom max_units."""
+ def test_format_with_duration_custom_units(self):
+ """format_with_duration should work for custom max_units."""
test_cases = (
('3000-12-12T00:01:00Z', datetime(3000, 12, 11, 12, 5, 5, tzinfo=timezone.utc), 6,
'<t:32533488060:f> (11 hours, 55 minutes and 55 seconds)'),
@@ -80,10 +72,10 @@ class TimeTests(unittest.TestCase):
for expiry, date_from, max_units, expected in test_cases:
with self.subTest(expiry=expiry, date_from=date_from, max_units=max_units, expected=expected):
- self.assertEqual(time.format_infraction_with_duration(expiry, date_from, max_units), expected)
+ self.assertEqual(time.format_with_duration(expiry, date_from, max_units), expected)
- def test_format_infraction_with_duration_normal_usage(self):
- """format_infraction_with_duration should work for normal usage, across various durations."""
+ def test_format_with_duration_normal_usage(self):
+ """format_with_duration should work for normal usage, across various durations."""
utc = timezone.utc
test_cases = (
('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5, tzinfo=utc), 2,
@@ -105,11 +97,11 @@ class TimeTests(unittest.TestCase):
for expiry, date_from, max_units, expected in test_cases:
with self.subTest(expiry=expiry, date_from=date_from, max_units=max_units, expected=expected):
- self.assertEqual(time.format_infraction_with_duration(expiry, date_from, max_units), expected)
+ self.assertEqual(time.format_with_duration(expiry, date_from, max_units), expected)
def test_until_expiration_with_duration_none_expiry(self):
- """until_expiration should work for None expiry."""
- self.assertEqual(time.until_expiration(None), None)
+ """until_expiration should return "Permanent" is expiry is None."""
+ self.assertEqual(time.until_expiration(None), "Permanent")
def test_until_expiration_with_duration_custom_units(self):
"""until_expiration should work for custom max_units."""
@@ -130,7 +122,6 @@ class TimeTests(unittest.TestCase):
('3000-12-12T00:00:00Z', '<t:32533488000:R>'),
('3000-11-23T20:09:00Z', '<t:32531918940:R>'),
('3000-11-23T20:09:00Z', '<t:32531918940:R>'),
- (None, None),
)
for expiry, expected in test_cases: