diff options
Diffstat (limited to 'bot/utils')
-rw-r--r-- | bot/utils/__init__.py | 43 | ||||
-rw-r--r-- | bot/utils/checks.py | 35 | ||||
-rw-r--r-- | bot/utils/converters.py | 105 | ||||
-rw-r--r-- | bot/utils/decorators.py | 12 | ||||
-rw-r--r-- | bot/utils/exceptions.py | 15 | ||||
-rw-r--r-- | bot/utils/extensions.py | 10 | ||||
-rw-r--r-- | bot/utils/halloween/spookifications.py | 10 | ||||
-rw-r--r-- | bot/utils/helpers.py | 8 | ||||
-rw-r--r-- | bot/utils/messages.py | 19 | ||||
-rw-r--r-- | bot/utils/pagination.py | 49 | ||||
-rw-r--r-- | bot/utils/time.py | 84 |
11 files changed, 333 insertions, 57 deletions
diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 35ef0a7b..bef12d25 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -3,7 +3,7 @@ import contextlib import re import string from datetime import datetime -from typing import Iterable, List +from typing import Iterable, List, Optional import discord from discord.ext.commands import BadArgument, Context @@ -31,8 +31,13 @@ def resolve_current_month() -> Month: async def disambiguate( - ctx: Context, entries: List[str], *, timeout: float = 30, - entries_per_page: int = 20, empty: bool = False, embed: discord.Embed = None + ctx: Context, + entries: List[str], + *, + timeout: float = 30, + entries_per_page: int = 20, + empty: bool = False, + embed: Optional[discord.Embed] = None ) -> str: """ Has the user choose between multiple entries in case one could not be chosen automatically. @@ -43,25 +48,29 @@ async def disambiguate( or if the user makes an invalid choice. """ if len(entries) == 0: - raise BadArgument('No matches found.') + raise BadArgument("No matches found.") if len(entries) == 1: return entries[0] - choices = (f'{index}: {entry}' for index, entry in enumerate(entries, start=1)) + choices = (f"{index}: {entry}" for index, entry in enumerate(entries, start=1)) def check(message: discord.Message) -> bool: - return (message.content.isdigit() - and message.author == ctx.author - and message.channel == ctx.channel) + return ( + message.content.isdecimal() + and message.author == ctx.author + and message.channel == ctx.channel + ) try: if embed is None: embed = discord.Embed() - coro1 = ctx.bot.wait_for('message', check=check, timeout=timeout) - coro2 = LinePaginator.paginate(choices, ctx, embed=embed, max_lines=entries_per_page, - empty=empty, max_size=6000, timeout=9000) + coro1 = ctx.bot.wait_for("message", check=check, timeout=timeout) + coro2 = LinePaginator.paginate( + choices, ctx, embed=embed, max_lines=entries_per_page, + empty=empty, max_size=6000, timeout=9000 + ) # wait_for timeout will go to except instead of the wait_for thing as I expected futures = [asyncio.ensure_future(coro1), asyncio.ensure_future(coro2)] @@ -74,7 +83,7 @@ async def disambiguate( if result is None: for coro in pending: coro.cancel() - raise BadArgument('Canceled.') + raise BadArgument("Canceled.") # Pagination was not initiated, only one page if result.author == ctx.bot.user: @@ -85,19 +94,19 @@ async def disambiguate( for coro in pending: coro.cancel() except asyncio.TimeoutError: - raise BadArgument('Timed out.') + raise BadArgument("Timed out.") - # Guaranteed to not error because of isdigit() in check + # Guaranteed to not error because of isdecimal() in check index = int(result.content) try: return entries[index - 1] except IndexError: - raise BadArgument('Invalid choice.') + raise BadArgument("Invalid choice.") def replace_many( - sentence: str, replacements: dict, *, ignore_case: bool = False, match_case: bool = False + sentence: str, replacements: dict, *, ignore_case: bool = False, match_case: bool = False ) -> str: """ Replaces multiple substrings in a string given a mapping of strings. @@ -139,7 +148,7 @@ def replace_many( return replacement # Clean punctuation from word so string methods work - cleaned_word = word.translate(str.maketrans('', '', string.punctuation)) + cleaned_word = word.translate(str.maketrans("", "", string.punctuation)) if cleaned_word.isupper(): return replacement.upper() elif cleaned_word[0].isupper(): diff --git a/bot/utils/checks.py b/bot/utils/checks.py index 9dd4dde0..438ec750 100644 --- a/bot/utils/checks.py +++ b/bot/utils/checks.py @@ -75,6 +75,11 @@ def in_whitelist_check( log.trace(f"{ctx.author} may use the `{ctx.command.name}` command as they are in a whitelisted category.") return True + category = getattr(ctx.channel, "category", None) + if category and category.name == constants.codejam_categories_name: + log.trace(f"{ctx.author} may use the `{ctx.command.name}` command as they are in a codejam team channel.") + return True + # Only check the roles whitelist if we have one and ensure the author's roles attribute returns # an iterable to prevent breakage in DM channels (for if we ever decide to enable commands there). if roles and any(r.id in roles for r in getattr(ctx.author, "roles", ())): @@ -92,8 +97,10 @@ def in_whitelist_check( def with_role_check(ctx: Context, *role_ids: int) -> bool: """Returns True if the user has any one of the roles in role_ids.""" if not ctx.guild: # Return False in a DM - log.trace(f"{ctx.author} tried to use the '{ctx.command.name}'command from a DM. " - "This command is restricted by the with_role decorator. Rejecting request.") + log.trace( + f"{ctx.author} tried to use the '{ctx.command.name}'command from a DM. " + "This command is restricted by the with_role decorator. Rejecting request." + ) return False for role in ctx.author.roles: @@ -101,22 +108,28 @@ def with_role_check(ctx: Context, *role_ids: int) -> bool: log.trace(f"{ctx.author} has the '{role.name}' role, and passes the check.") return True - log.trace(f"{ctx.author} does not have the required role to use " - f"the '{ctx.command.name}' command, so the request is rejected.") + log.trace( + f"{ctx.author} does not have the required role to use " + f"the '{ctx.command.name}' command, so the request is rejected." + ) return False def without_role_check(ctx: Context, *role_ids: int) -> bool: """Returns True if the user does not have any of the roles in role_ids.""" if not ctx.guild: # Return False in a DM - log.trace(f"{ctx.author} tried to use the '{ctx.command.name}' command from a DM. " - "This command is restricted by the without_role decorator. Rejecting request.") + log.trace( + f"{ctx.author} tried to use the '{ctx.command.name}' command from a DM. " + "This command is restricted by the without_role decorator. Rejecting request." + ) return False author_roles = [role.id for role in ctx.author.roles] check = all(role not in author_roles for role in role_ids) - log.trace(f"{ctx.author} tried to call the '{ctx.command.name}' command. " - f"The result of the without_role check was {check}.") + log.trace( + f"{ctx.author} tried to call the '{ctx.command.name}' command. " + f"The result of the without_role check was {check}." + ) return check @@ -154,8 +167,10 @@ def cooldown_with_role_bypass(rate: int, per: float, type: BucketType = BucketTy # # If the `before_invoke` detail is ever a problem then I can quickly just swap over. if not isinstance(command, Command): - raise TypeError('Decorator `cooldown_with_role_bypass` must be applied after the command decorator. ' - 'This means it has to be above the command decorator in the code.') + raise TypeError( + "Decorator `cooldown_with_role_bypass` must be applied after the command decorator. " + "This means it has to be above the command decorator in the code." + ) command._before_invoke = predicate diff --git a/bot/utils/converters.py b/bot/utils/converters.py index 228714c9..fe2c980c 100644 --- a/bot/utils/converters.py +++ b/bot/utils/converters.py @@ -1,11 +1,14 @@ +from datetime import datetime +from typing import Tuple, Union + import discord -from discord.ext.commands.converter import MessageConverter +from discord.ext import commands -class WrappedMessageConverter(MessageConverter): +class WrappedMessageConverter(commands.MessageConverter): """A converter that handles embed-suppressed links like <http://example.com>.""" - async def convert(self, ctx: discord.ext.commands.Context, argument: str) -> discord.Message: + async def convert(self, ctx: commands.Context, argument: str) -> discord.Message: """Wrap the commands.MessageConverter to handle <> delimited message links.""" # It's possible to wrap a message in [<>] as well, and it's supported because its easy if argument.startswith("[") and argument.endswith("]"): @@ -14,3 +17,99 @@ class WrappedMessageConverter(MessageConverter): argument = argument[1:-1] return await super().convert(ctx, argument) + + +class CoordinateConverter(commands.Converter): + """Converter for Coordinates.""" + + @staticmethod + async def convert(ctx: commands.Context, coordinate: str) -> Tuple[int, int]: + """Take in a coordinate string and turn it into an (x, y) tuple.""" + if len(coordinate) not in (2, 3): + raise commands.BadArgument("Invalid co-ordinate provided.") + + coordinate = coordinate.lower() + if coordinate[0].isalpha(): + digit = coordinate[1:] + letter = coordinate[0] + else: + digit = coordinate[:-1] + letter = coordinate[-1] + + if not digit.isdecimal(): + raise commands.BadArgument + + x = ord(letter) - ord("a") + y = int(digit) - 1 + + if (not 0 <= x <= 9) or (not 0 <= y <= 9): + raise commands.BadArgument + return x, y + + +SourceType = Union[commands.Command, commands.Cog] + + +class SourceConverter(commands.Converter): + """Convert an argument into a command or cog.""" + + @staticmethod + async def convert(ctx: commands.Context, argument: str) -> SourceType: + """Convert argument into source object.""" + cog = ctx.bot.get_cog(argument) + if cog: + return cog + + cmd = ctx.bot.get_command(argument) + if cmd: + return cmd + + raise commands.BadArgument( + f"Unable to convert `{argument}` to valid command or Cog." + ) + + +class DateConverter(commands.Converter): + """Parse SOL or earth date (in format YYYY-MM-DD) into `int` or `datetime`. When invalid input, raise error.""" + + @staticmethod + async def convert(ctx: commands.Context, argument: str) -> Union[int, datetime]: + """Parse date (SOL or earth) into `datetime` or `int`. When invalid value, raise error.""" + if argument.isdecimal(): + return int(argument) + try: + date = datetime.strptime(argument, "%Y-%m-%d") + except ValueError: + raise commands.BadArgument( + f"Can't convert `{argument}` to `datetime` in format `YYYY-MM-DD` or `int` in SOL." + ) + return date + + +class Subreddit(commands.Converter): + """Forces a string to begin with "r/" and checks if it's a valid subreddit.""" + + @staticmethod + async def convert(ctx: commands.Context, sub: str) -> str: + """ + Force sub to begin with "r/" and check if it's a valid subreddit. + + If sub is a valid subreddit, return it prepended with "r/" + """ + sub = sub.lower() + + if not sub.startswith("r/"): + sub = f"r/{sub}" + + resp = await ctx.bot.http_session.get( + "https://www.reddit.com/subreddits/search.json", + params={"q": sub} + ) + + json = await resp.json() + if not json["data"]["children"]: + raise commands.BadArgument( + f"The subreddit `{sub}` either doesn't exist, or it has no posts." + ) + + return sub diff --git a/bot/utils/decorators.py b/bot/utils/decorators.py index c12a15ff..c0783144 100644 --- a/bot/utils/decorators.py +++ b/bot/utils/decorators.py @@ -11,7 +11,7 @@ from discord import Colour, Embed from discord.ext import commands from discord.ext.commands import CheckFailure, Command, Context -from bot.constants import ERROR_REPLIES, Month +from bot.constants import Channels, ERROR_REPLIES, Month, WHITELISTED_CHANNELS from bot.utils import human_months, resolve_current_month from bot.utils.checks import in_whitelist_check @@ -253,6 +253,12 @@ def whitelist_check(**default_kwargs: t.Container[int]) -> t.Callable[[Context], channels = set(kwargs.get("channels") or {}) categories = kwargs.get("categories") + # Only output override channels + community_bot_commands + if channels: + default_whitelist_channels = set(WHITELISTED_CHANNELS) + default_whitelist_channels.discard(Channels.community_bot_commands) + channels.difference_update(default_whitelist_channels) + # Add all whitelisted category channels if categories: for category_id in categories: @@ -260,10 +266,10 @@ def whitelist_check(**default_kwargs: t.Container[int]) -> t.Callable[[Context], if category is None: continue - [channels.add(channel.id) for channel in category.text_channels] + channels.update(channel.id for channel in category.text_channels) if channels: - channels_str = ', '.join(f"<#{c_id}>" for c_id in channels) + channels_str = ", ".join(f"<#{c_id}>" for c_id in channels) message = f"Sorry, but you may only use this command within {channels_str}." else: message = "Sorry, but you may not use this command." diff --git a/bot/utils/exceptions.py b/bot/utils/exceptions.py index 2b1c1b31..bf0e5813 100644 --- a/bot/utils/exceptions.py +++ b/bot/utils/exceptions.py @@ -1,4 +1,17 @@ +from typing import Optional + + class UserNotPlayingError(Exception): - """Will raised when user try to use game commands when not playing.""" + """Raised when users try to use game commands when they are not playing.""" pass + + +class APIError(Exception): + """Raised when an external API (eg. Wikipedia) returns an error response.""" + + def __init__(self, api: str, status_code: int, error_msg: Optional[str] = None): + super().__init__() + self.api = api + self.status_code = status_code + self.error_msg = error_msg diff --git a/bot/utils/extensions.py b/bot/utils/extensions.py index 50350ea8..cd491c4b 100644 --- a/bot/utils/extensions.py +++ b/bot/utils/extensions.py @@ -3,6 +3,8 @@ import inspect import pkgutil from typing import Iterator, NoReturn +from discord.ext.commands import Context + from bot import exts @@ -31,4 +33,12 @@ def walk_extensions() -> Iterator[str]: yield module.name +async def invoke_help_command(ctx: Context) -> None: + """Invoke the help command or default help command if help extensions is not loaded.""" + if "bot.exts.evergreen.help" in ctx.bot.extensions: + help_command = ctx.bot.get_command("help") + await ctx.invoke(help_command, ctx.command.qualified_name) + return + await ctx.send_help(ctx.command) + EXTENSIONS = frozenset(walk_extensions()) diff --git a/bot/utils/halloween/spookifications.py b/bot/utils/halloween/spookifications.py index 11f69850..f69dd6fd 100644 --- a/bot/utils/halloween/spookifications.py +++ b/bot/utils/halloween/spookifications.py @@ -13,16 +13,16 @@ def inversion(im: Image) -> Image: Returns an inverted image when supplied with an Image object. """ - im = im.convert('RGB') + im = im.convert("RGB") inv = ImageOps.invert(im) return inv def pentagram(im: Image) -> Image: """Adds pentagram to the image.""" - im = im.convert('RGB') + im = im.convert("RGB") wt, ht = im.size - penta = Image.open('bot/resources/halloween/bloody-pentagram.png') + penta = Image.open("bot/resources/halloween/bloody-pentagram.png") penta = penta.resize((wt, ht)) im.paste(penta, (0, 0), penta) return im @@ -35,9 +35,9 @@ def bat(im: Image) -> Image: The bat silhoutte is of a size at least one-fifths that of the original image and may be rotated up to 90 degrees anti-clockwise. """ - im = im.convert('RGB') + im = im.convert("RGB") wt, ht = im.size - bat = Image.open('bot/resources/halloween/bat-clipart.png') + bat = Image.open("bot/resources/halloween/bat-clipart.png") bat_size = randint(wt//10, wt//7) rot = randint(0, 90) bat = bat.resize((bat_size, bat_size)) diff --git a/bot/utils/helpers.py b/bot/utils/helpers.py new file mode 100644 index 00000000..74c2ccd0 --- /dev/null +++ b/bot/utils/helpers.py @@ -0,0 +1,8 @@ +import re + + +def suppress_links(message: str) -> str: + """Accepts a message that may contain links, suppresses them, and returns them.""" + for link in set(re.findall(r"https?://[^\s]+", message, re.IGNORECASE)): + message = message.replace(link, f"<{link}>") + return message diff --git a/bot/utils/messages.py b/bot/utils/messages.py new file mode 100644 index 00000000..a6c035f9 --- /dev/null +++ b/bot/utils/messages.py @@ -0,0 +1,19 @@ +import re +from typing import Optional + + +def sub_clyde(username: Optional[str]) -> Optional[str]: + """ + Replace "e"/"E" in any "clyde" in `username` with a Cyrillic "е"/"E" and return the new string. + + Discord disallows "clyde" anywhere in the username for webhooks. It will return a 400. + Return None only if `username` is None. + """ + def replace_e(match: re.Match) -> str: + char = "е" if match[2] == "e" else "Е" + return match[1] + char + + if username: + return re.sub(r"(clyd)(e)", replace_e, username, flags=re.I) + else: + return username # Empty string or None diff --git a/bot/utils/pagination.py b/bot/utils/pagination.py index a4d0cc56..b1062c09 100644 --- a/bot/utils/pagination.py +++ b/bot/utils/pagination.py @@ -4,6 +4,7 @@ from typing import Iterable, List, Optional, Tuple from discord import Embed, Member, Reaction from discord.abc import User +from discord.embeds import EmptyEmbed from discord.ext.commands import Context, Paginator from bot.constants import Emojis @@ -19,14 +20,21 @@ PAGINATION_EMOJI = (FIRST_EMOJI, LEFT_EMOJI, RIGHT_EMOJI, LAST_EMOJI, DELETE_EMO log = logging.getLogger(__name__) -class EmptyPaginatorEmbed(Exception): +class EmptyPaginatorEmbedError(Exception): """Base Exception class for an empty paginator embed.""" class LinePaginator(Paginator): """A class that aids in paginating code blocks for Discord messages.""" - def __init__(self, prefix: str = '```', suffix: str = '```', max_size: int = 2000, max_lines: int = None): + def __init__( + self, + prefix: str = '```', + suffix: str = '```', + max_size: int = 2000, + max_lines: Optional[int] = None, + linesep: str = "\n" + ): """ Overrides the Paginator.__init__ from inside discord.ext.commands. @@ -35,16 +43,20 @@ class LinePaginator(Paginator): `max_size` and `max_lines` denote the maximum amount of codepoints and lines allowed per page. """ - self.prefix = prefix - self.suffix = suffix - self.max_size = max_size - len(suffix) + super().__init__( + prefix, + suffix, + max_size - len(suffix), + linesep + ) + self.max_lines = max_lines self._current_page = [prefix] self._linecount = 0 self._count = len(prefix) + 1 # prefix + newline self._pages = [] - def add_line(self, line: str = '', *, empty: bool = False) -> None: + def add_line(self, line: str = "", *, empty: bool = False) -> None: """ Adds a line to the current page. @@ -56,7 +68,7 @@ class LinePaginator(Paginator): If `empty` is True, an empty line will be placed after the a given `line`. """ if len(line) > self.max_size - len(self.prefix) - 2: - raise RuntimeError('Line exceeds maximum page size %s' % (self.max_size - len(self.prefix) - 2)) + raise RuntimeError("Line exceeds maximum page size %s" % (self.max_size - len(self.prefix) - 2)) if self.max_lines is not None: if self._linecount >= self.max_lines: @@ -71,7 +83,7 @@ class LinePaginator(Paginator): self._current_page.append(line) if empty: - self._current_page.append('') + self._current_page.append("") self._count += 1 @classmethod @@ -79,7 +91,7 @@ class LinePaginator(Paginator): prefix: str = "", suffix: str = "", max_lines: Optional[int] = None, max_size: int = 500, empty: bool = True, restrict_to_user: User = None, timeout: int = 300, footer_text: str = None, url: str = None, - exception_on_empty_embed: bool = False): + exception_on_empty_embed: bool = False) -> None: """ Use a paginator and set of reactions to provide pagination over a set of lines. @@ -129,7 +141,7 @@ class LinePaginator(Paginator): if not lines: if exception_on_empty_embed: log.exception("Pagination asked for empty lines iterable") - raise EmptyPaginatorEmbed("No lines to paginate") + raise EmptyPaginatorEmbedError("No lines to paginate") log.debug("No lines to add to paginator, adding '(nothing to display)' message") lines.append("(nothing to display)") @@ -157,7 +169,8 @@ class LinePaginator(Paginator): log.trace(f"Setting embed url to '{url}'") log.debug("There's less than two pages, so we won't paginate - sending single page on its own") - return await ctx.send(embed=embed) + await ctx.send(embed=embed) + return else: if footer_text: embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})") @@ -282,7 +295,7 @@ class ImagePaginator(Paginator): self.images = [] self._pages = [] - def add_line(self, line: str = '', *, empty: bool = False) -> None: + def add_line(self, line: str = "", *, empty: bool = False) -> None: """ Adds a line to each page, usually just 1 line in this context. @@ -302,7 +315,7 @@ class ImagePaginator(Paginator): @classmethod async def paginate(cls, pages: List[Tuple[str, str]], ctx: Context, embed: Embed, prefix: str = "", suffix: str = "", timeout: int = 300, - exception_on_empty_embed: bool = False): + exception_on_empty_embed: bool = False) -> None: """ Use a paginator and set of reactions to provide pagination over a set of title/image pairs. @@ -336,7 +349,7 @@ class ImagePaginator(Paginator): if not pages: if exception_on_empty_embed: log.exception("Pagination asked for empty image list") - raise EmptyPaginatorEmbed("No images to paginate") + raise EmptyPaginatorEmbedError("No images to paginate") log.debug("No images to add to paginator, adding '(no images to display)' message") pages.append(("(no images to display)", "")) @@ -352,7 +365,8 @@ class ImagePaginator(Paginator): embed.set_image(url=image) if len(paginator.pages) <= 1: - return await ctx.send(embed=embed) + await ctx.send(embed=embed) + return embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}") message = await ctx.send(embed=embed) @@ -417,9 +431,8 @@ class ImagePaginator(Paginator): await message.edit(embed=embed) embed.description = paginator.pages[current_page] - image = paginator.images[current_page] - if image: - embed.set_image(url=image) + image = paginator.images[current_page] or EmptyEmbed + embed.set_image(url=image) embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}") log.debug(f"Got {reaction_type} page reaction - changing to page {current_page + 1}/{len(paginator.pages)}") diff --git a/bot/utils/time.py b/bot/utils/time.py new file mode 100644 index 00000000..fbf2fd21 --- /dev/null +++ b/bot/utils/time.py @@ -0,0 +1,84 @@ +import datetime + +from dateutil.relativedelta import relativedelta + + +# All these functions are from https://github.com/python-discord/bot/blob/main/bot/utils/time.py +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. + + >>> _stringify_time_unit(1, "seconds") + "1 second" + >>> _stringify_time_unit(24, "hours") + "24 hours" + >>> _stringify_time_unit(0, "minutes") + "less than a minute" + """ + if unit == "seconds" and value == 0: + return "0 seconds" + elif value == 1: + return f"{value} {unit[:-1]}" + elif value == 0: + return f"less than a {unit[:-1]}" + else: + return f"{value} {unit}" + + +def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: int = 6) -> str: + """ + Returns a human-readable version of the relativedelta. + + 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). + """ + if max_units <= 0: + raise ValueError("max_units must be positive") + + units = ( + ("years", delta.years), + ("months", delta.months), + ("days", delta.days), + ("hours", delta.hours), + ("minutes", delta.minutes), + ("seconds", delta.seconds), + ) + + # Add the time units that are >0, but stop at accuracy or max_units. + time_strings = [] + unit_count = 0 + for unit, value in units: + if value: + time_strings.append(_stringify_time_unit(value, unit)) + unit_count += 1 + + if unit == precision or unit_count >= max_units: + break + + # 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] + + # If nothing has been found, just make the value 0 precision, e.g. `0 days`. + if not time_strings: + humanized = _stringify_time_unit(0, precision) + else: + humanized = ", ".join(time_strings) + + return humanized + + +def time_since(past_datetime: datetime.datetime, precision: str = "seconds", max_units: int = 6) -> str: + """ + Takes a datetime and returns a human-readable string that describes how long ago that datetime was. + + 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). + """ + now = datetime.datetime.utcnow() + delta = abs(relativedelta(now, past_datetime)) + + humanized = humanize_delta(delta, precision, max_units) + + return f"{humanized} ago" |