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