From ef24f6dcce1ed527f8561c4bfa41f390bde692bc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 18 Mar 2020 18:58:56 -0700 Subject: Reminders: remove duplicate deletion in scheduled task `send_reminder` already deletes the reminder so it's redundant to delete it in the scheduled task too. --- bot/cogs/reminders.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index b5998cc0e..cbc7d6920 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -144,16 +144,8 @@ class Reminders(Cog): def schedule_reminder(self, reminder: dict) -> None: """A coroutine which sends the reminder once the time is reached, and cancels the running task.""" - reminder_id = reminder["id"] reminder_datetime = isoparse(reminder['expiration']).replace(tzinfo=None) - - async def _remind() -> None: - await self.send_reminder(reminder) - - log.debug(f"Deleting reminder {reminder_id} (the user has been reminded).") - await self._delete_reminder(reminder_id) - - self.scheduler.schedule_at(reminder_datetime, reminder_id, _remind()) + self.scheduler.schedule_at(reminder_datetime, reminder["id"], self.send_reminder(reminder)) async def _delete_reminder(self, reminder_id: str, cancel_task: bool = True) -> None: """Delete a reminder from the database, given its ID, and cancel the running task.""" -- cgit v1.2.3 From 96729f9979a0e3db8ba86b86bb25026c212c35dc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 18 Mar 2020 19:10:46 -0700 Subject: Reminders: remove _delete_reminder function Only one call was benefiting from that function also cancelling the task. Therefore, the function was redundant and has been replaced with a direct request to delete. This change has the consequence of also fixing reminder tasks cancelling themselves. That issue was potentially suppressing errors (such as the duplicate DELETE request which was fixed earlier). Under normal circumstances, the scheduler will automatically removed finished tasks so tasks won't need to cancel/remove themselves. --- bot/cogs/reminders.py | 32 +++++++++++--------------------- 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index cbc7d6920..14fe43efb 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -48,7 +48,7 @@ class Reminders(Cog): now = datetime.utcnow() for reminder in response: - is_valid, *_ = self.ensure_valid_reminder(reminder, cancel_task=False) + is_valid, *_ = self.ensure_valid_reminder(reminder) if not is_valid: continue @@ -61,11 +61,7 @@ class Reminders(Cog): else: self.schedule_reminder(reminder) - def ensure_valid_reminder( - self, - reminder: dict, - cancel_task: bool = True - ) -> t.Tuple[bool, discord.User, discord.TextChannel]: + def ensure_valid_reminder(self, reminder: dict) -> t.Tuple[bool, discord.User, discord.TextChannel]: """Ensure reminder author and channel can be fetched otherwise delete the reminder.""" user = self.bot.get_user(reminder['author']) channel = self.bot.get_channel(reminder['channel_id']) @@ -76,7 +72,7 @@ class Reminders(Cog): f"Reminder {reminder['id']} invalid: " f"User {reminder['author']}={user}, Channel {reminder['channel_id']}={channel}." ) - asyncio.create_task(self._delete_reminder(reminder['id'], cancel_task)) + asyncio.create_task(self.bot.api_client.delete(f"bot/reminders/{reminder['id']}")) return is_valid, user, channel @@ -147,14 +143,6 @@ class Reminders(Cog): reminder_datetime = isoparse(reminder['expiration']).replace(tzinfo=None) self.scheduler.schedule_at(reminder_datetime, reminder["id"], self.send_reminder(reminder)) - async def _delete_reminder(self, reminder_id: str, cancel_task: bool = True) -> None: - """Delete a reminder from the database, given its ID, and cancel the running task.""" - await self.bot.api_client.delete('bot/reminders/' + str(reminder_id)) - - if cancel_task: - # Now we can remove it from the schedule list - self.scheduler.cancel(reminder_id) - async def _edit_reminder(self, reminder_id: int, payload: dict) -> dict: """ Edits a reminder in the database given the ID and payload. @@ -180,6 +168,7 @@ class Reminders(Cog): """Send the reminder.""" is_valid, user, channel = self.ensure_valid_reminder(reminder) if not is_valid: + # No need to cancel the task too; it'll simply be done once this coroutine returns. return embed = discord.Embed() @@ -205,11 +194,10 @@ class Reminders(Cog): mentionable.mention for mentionable in self.get_mentionables(reminder["mentions"]) ) - await channel.send( - content=f"{user.mention} {additional_mentions}", - embed=embed - ) - await self._delete_reminder(reminder["id"]) + await channel.send(content=f"{user.mention} {additional_mentions}", embed=embed) + + log.debug(f"Deleting reminder #{reminder['id']} (the user has been reminded).") + await self.bot.api_client.delete(f"bot/reminders/{reminder['id']}") @group(name="remind", aliases=("reminder", "reminders", "remindme"), invoke_without_command=True) async def remind_group( @@ -401,7 +389,9 @@ class Reminders(Cog): @remind_group.command("delete", aliases=("remove", "cancel")) async def delete_reminder(self, ctx: Context, id_: int) -> None: """Delete one of your active reminders.""" - await self._delete_reminder(id_) + await self.bot.api_client.delete(f"bot/reminders/{id_}") + self.scheduler.cancel(id_) + await self._send_confirmation( ctx, on_success="That reminder has been deleted successfully!", -- cgit v1.2.3 From 4d5dedc492c070b4fe98d630026ef7e0504ff5a8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 12 Jul 2020 18:26:30 -0700 Subject: Reminders: fix reminder_id type annotation It's fine to accept an int since it'll get converted to a string anyway. --- bot/cogs/reminders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 14fe43efb..a043b7d75 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -80,7 +80,7 @@ class Reminders(Cog): async def _send_confirmation( ctx: Context, on_success: str, - reminder_id: str, + reminder_id: t.Union[str, int], delivery_dt: t.Optional[datetime], ) -> None: """Send an embed confirming the reminder change was made successfully.""" -- cgit v1.2.3 From b3e1ebfb7a8d9a31bbd5eba1a14c1c132590ee86 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Jul 2020 10:01:56 -0700 Subject: Decorators: more accurate return type for checks --- bot/decorators.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index 500197c89..b9182f664 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -24,7 +24,7 @@ def in_whitelist( roles: Container[int] = (), redirect: Optional[int] = Channels.bot_commands, fail_silently: bool = False, -) -> Callable: +) -> commands.Command: """ Check if a command was issued in a whitelisted context. @@ -45,7 +45,7 @@ def in_whitelist( return commands.check(predicate) -def with_role(*role_ids: int) -> Callable: +def with_role(*role_ids: int) -> commands.Command: """Returns True if the user has any one of the roles in role_ids.""" async def predicate(ctx: Context) -> bool: """With role checker predicate.""" @@ -53,7 +53,7 @@ def with_role(*role_ids: int) -> Callable: return commands.check(predicate) -def without_role(*role_ids: int) -> Callable: +def without_role(*role_ids: int) -> commands.Command: """Returns True if the user does not have any of the roles in role_ids.""" async def predicate(ctx: Context) -> bool: return without_role_check(ctx, *role_ids) -- cgit v1.2.3 From da33c330a02f2ff10838d0827e8c26a045729449 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Jul 2020 10:08:05 -0700 Subject: Decorators: clean up imports --- bot/decorators.py | 50 +++++++++++++++++++++----------------------- tests/bot/test_decorators.py | 4 ++-- 2 files changed, 26 insertions(+), 28 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index b9182f664..d9e5e3a83 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -1,15 +1,13 @@ +import asyncio import logging import random -from asyncio import Lock, create_task, sleep +import typing as t from contextlib import suppress from functools import wraps -from typing import Callable, Container, Optional, Union from weakref import WeakValueDictionary -from discord import Colour, Embed, Member -from discord.errors import NotFound -from discord.ext import commands -from discord.ext.commands import Cog, Context +from discord import Colour, Embed, Member, NotFound +from discord.ext.commands import Cog, Command, Context, check from bot.constants import Channels, ERROR_REPLIES, RedirectOutput from bot.utils.checks import in_whitelist_check, with_role_check, without_role_check @@ -19,12 +17,12 @@ log = logging.getLogger(__name__) def in_whitelist( *, - channels: Container[int] = (), - categories: Container[int] = (), - roles: Container[int] = (), - redirect: Optional[int] = Channels.bot_commands, + channels: t.Container[int] = (), + categories: t.Container[int] = (), + roles: t.Container[int] = (), + redirect: t.Optional[int] = Channels.bot_commands, fail_silently: bool = False, -) -> commands.Command: +) -> Command: """ Check if a command was issued in a whitelisted context. @@ -42,25 +40,25 @@ def in_whitelist( """Check if command was issued in a whitelisted context.""" return in_whitelist_check(ctx, channels, categories, roles, redirect, fail_silently) - return commands.check(predicate) + return check(predicate) -def with_role(*role_ids: int) -> commands.Command: +def with_role(*role_ids: int) -> Command: """Returns True if the user has any one of the roles in role_ids.""" async def predicate(ctx: Context) -> bool: """With role checker predicate.""" return with_role_check(ctx, *role_ids) - return commands.check(predicate) + return check(predicate) -def without_role(*role_ids: int) -> commands.Command: +def without_role(*role_ids: int) -> Command: """Returns True if the user does not have any of the roles in role_ids.""" async def predicate(ctx: Context) -> bool: return without_role_check(ctx, *role_ids) - return commands.check(predicate) + return check(predicate) -def locked() -> Callable: +def locked() -> t.Callable: """ Allows the user to only run one instance of the decorated command at a time. @@ -68,12 +66,12 @@ def locked() -> Callable: This decorator must go before (below) the `command` decorator. """ - def wrap(func: Callable) -> Callable: + def wrap(func: t.Callable) -> t.Callable: func.__locks = WeakValueDictionary() @wraps(func) async def inner(self: Cog, ctx: Context, *args, **kwargs) -> None: - lock = func.__locks.setdefault(ctx.author.id, Lock()) + lock = func.__locks.setdefault(ctx.author.id, asyncio.Lock()) if lock.locked(): embed = Embed() embed.colour = Colour.red() @@ -86,13 +84,13 @@ def locked() -> Callable: await ctx.send(embed=embed) return - async with func.__locks.setdefault(ctx.author.id, Lock()): + async with func.__locks.setdefault(ctx.author.id, asyncio.Lock()): await func(self, ctx, *args, **kwargs) return inner return wrap -def redirect_output(destination_channel: int, bypass_roles: Container[int] = None) -> Callable: +def redirect_output(destination_channel: int, bypass_roles: t.Container[int] = None) -> t.Callable: """ Changes the channel in the context of the command to redirect the output to a certain channel. @@ -100,7 +98,7 @@ def redirect_output(destination_channel: int, bypass_roles: Container[int] = Non This decorator must go before (below) the `command` decorator. """ - def wrap(func: Callable) -> Callable: + def wrap(func: t.Callable) -> t.Callable: @wraps(func) async def inner(self: Cog, ctx: Context, *args, **kwargs) -> None: if ctx.channel.id == destination_channel: @@ -119,14 +117,14 @@ def redirect_output(destination_channel: int, bypass_roles: Container[int] = Non log.trace(f"Redirecting output of {ctx.author}'s command '{ctx.command.name}' to {redirect_channel.name}") ctx.channel = redirect_channel await ctx.channel.send(f"Here's the output of your command, {ctx.author.mention}") - create_task(func(self, ctx, *args, **kwargs)) + asyncio.create_task(func(self, ctx, *args, **kwargs)) message = await old_channel.send( f"Hey, {ctx.author.mention}, you can find the output of your command here: " f"{redirect_channel.mention}" ) if RedirectOutput.delete_invocation: - await sleep(RedirectOutput.delete_delay) + await asyncio.sleep(RedirectOutput.delete_delay) with suppress(NotFound): await message.delete() @@ -140,7 +138,7 @@ def redirect_output(destination_channel: int, bypass_roles: Container[int] = Non return wrap -def respect_role_hierarchy(target_arg: Union[int, str] = 0) -> Callable: +def respect_role_hierarchy(target_arg: t.Union[int, str] = 0) -> t.Callable: """ Ensure the highest role of the invoking member is greater than that of the target member. @@ -152,7 +150,7 @@ def respect_role_hierarchy(target_arg: Union[int, str] = 0) -> Callable: This decorator must go before (below) the `command` decorator. """ - def wrap(func: Callable) -> Callable: + def wrap(func: t.Callable) -> t.Callable: @wraps(func) async def inner(self: Cog, ctx: Context, *args, **kwargs) -> None: try: diff --git a/tests/bot/test_decorators.py b/tests/bot/test_decorators.py index 3d450caa0..22e93c1c4 100644 --- a/tests/bot/test_decorators.py +++ b/tests/bot/test_decorators.py @@ -67,7 +67,7 @@ class InWhitelistTests(unittest.TestCase): for test_case in test_cases: # patch `commands.check` with a no-op lambda that just returns the predicate passed to it # so we can test the predicate that was generated from the specified kwargs. - with unittest.mock.patch("bot.decorators.commands.check", new=lambda predicate: predicate): + with unittest.mock.patch("bot.decorators.check", new=lambda predicate: predicate): predicate = in_whitelist(**test_case.kwargs) with self.subTest(test_description=test_case.description): @@ -139,7 +139,7 @@ class InWhitelistTests(unittest.TestCase): # patch `commands.check` with a no-op lambda that just returns the predicate passed to it # so we can test the predicate that was generated from the specified kwargs. - with unittest.mock.patch("bot.decorators.commands.check", new=lambda predicate: predicate): + with unittest.mock.patch("bot.decorators.check", new=lambda predicate: predicate): predicate = in_whitelist(**test_case.kwargs) with self.subTest(test_description=test_case.description): -- cgit v1.2.3 From 5bc3c1e9732955020d8a5f92a8c1952bca3dae0c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Jul 2020 10:13:52 -0700 Subject: Decorators: create helper function to get arg value --- bot/decorators.py | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index d9e5e3a83..1fe082b6e 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -14,6 +14,8 @@ from bot.utils.checks import in_whitelist_check, with_role_check, without_role_c log = logging.getLogger(__name__) +Argument = t.Union[int, str] + def in_whitelist( *, @@ -138,7 +140,7 @@ def redirect_output(destination_channel: int, bypass_roles: t.Container[int] = N return wrap -def respect_role_hierarchy(target_arg: t.Union[int, str] = 0) -> t.Callable: +def respect_role_hierarchy(target_arg: Argument = 0) -> t.Callable: """ Ensure the highest role of the invoking member is greater than that of the target member. @@ -153,15 +155,7 @@ def respect_role_hierarchy(target_arg: t.Union[int, str] = 0) -> t.Callable: def wrap(func: t.Callable) -> t.Callable: @wraps(func) async def inner(self: Cog, ctx: Context, *args, **kwargs) -> None: - try: - target = kwargs[target_arg] - except KeyError: - try: - target = args[target_arg] - except IndexError: - raise ValueError(f"Could not find target argument at position {target_arg}") - except TypeError: - raise ValueError(f"Could not find target kwarg with key {target_arg!r}") + target = _get_arg_value(target_arg, args, kwargs) if not isinstance(target, Member): log.trace("The target is not a discord.Member; skipping role hierarchy check.") @@ -183,3 +177,23 @@ def respect_role_hierarchy(target_arg: t.Union[int, str] = 0) -> t.Callable: await func(self, ctx, *args, **kwargs) return inner return wrap + + +def _get_arg_value(target_arg: Argument, args: t.Tuple, kwargs: t.Dict[str, t.Any]) -> t.Any: + """ + Return the value of the arg at the given position or name `target_arg`. + + Use an integer as a position if the target argument is positional. + Use a string as a parameter name if the target argument is a keyword argument. + + Raise ValueError if `target_arg` cannot be found. + """ + try: + return kwargs[target_arg] + except KeyError: + try: + return args[target_arg] + except IndexError: + raise ValueError(f"Could not find target argument at position {target_arg}") + except TypeError: + raise ValueError(f"Could not find target kwarg with key {target_arg!r}") -- cgit v1.2.3 From be93601a31dcfa8acb03996eaaf2edcb654712f5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Jul 2020 10:33:21 -0700 Subject: Decorators: add mutually exclusive decorator This will be used to prevent race conditions on a resource by stopping all other access to the resource once its been acquired. --- bot/decorators.py | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/bot/decorators.py b/bot/decorators.py index 1fe082b6e..cae0870b6 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -2,6 +2,7 @@ import asyncio import logging import random import typing as t +from collections import defaultdict from contextlib import suppress from functools import wraps from weakref import WeakValueDictionary @@ -13,8 +14,10 @@ from bot.constants import Channels, ERROR_REPLIES, RedirectOutput from bot.utils.checks import in_whitelist_check, with_role_check, without_role_check log = logging.getLogger(__name__) +__lock_dicts = defaultdict(WeakValueDictionary) Argument = t.Union[int, str] +ResourceId = t.Union[Argument, t.Callable[..., t.Hashable]] def in_whitelist( @@ -92,6 +95,42 @@ def locked() -> t.Callable: return wrap +def mutually_exclusive(namespace: t.Hashable, resource_arg: ResourceId) -> t.Callable: + """ + Turn the decorated coroutine function into a mutually exclusive operation on a resource. + + If any other mutually exclusive function currently holds the lock for a resource, do not run the + decorated function and return None. + + `namespace` is an identifier used to prevent collisions among resource IDs. + + `resource_arg` is the positional index or name of the parameter of the decorated function whose + value will be the resource ID. It may also be a callable which will return the resource ID + given the decorated function's args and kwargs. + """ + def decorator(func: t.Callable) -> t.Callable: + @wraps(func) + async def wrapper(*args, **kwargs) -> t.Any: + if callable(resource_arg): + # Call to get the ID if a callable was given. + id_ = resource_arg(*args, **kwargs) + else: + # Retrieve the ID from the args via position or name. + id_ = _get_arg_value(resource_arg, args, kwargs) + + # Get the lock for the ID. Create a Lock if one doesn't exist yet. + locks = __lock_dicts[namespace] + lock = locks.setdefault(id_, asyncio.Lock) + + if not lock.locked(): + # Resource is free; acquire it. + async with lock: + return await func(*args, **kwargs) + + return wrapper + return decorator + + def redirect_output(destination_channel: int, bypass_roles: t.Container[int] = None) -> t.Callable: """ Changes the channel in the context of the command to redirect the output to a certain channel. -- cgit v1.2.3 From a16ba995944e213f90fbe78d2ec534f99f70b9f8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Jul 2020 11:06:17 -0700 Subject: Decorators: drop arg pos/name support for mutually_exclusive Supporting ID retrieval by arg name or position made for a confusing interface. I also doubt it would have been used much. A callable can achieve the same thing, albeit with a little more code. Now the decorator instead supports passing an ID directly or a callable. --- bot/decorators.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index cae0870b6..f49499856 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -17,7 +17,7 @@ log = logging.getLogger(__name__) __lock_dicts = defaultdict(WeakValueDictionary) Argument = t.Union[int, str] -ResourceId = t.Union[Argument, t.Callable[..., t.Hashable]] +ResourceId = t.Union[t.Hashable, t.Callable[..., t.Hashable]] def in_whitelist( @@ -95,28 +95,27 @@ def locked() -> t.Callable: return wrap -def mutually_exclusive(namespace: t.Hashable, resource_arg: ResourceId) -> t.Callable: +def mutually_exclusive(namespace: t.Hashable, resource_id: ResourceId) -> t.Callable: """ - Turn the decorated coroutine function into a mutually exclusive operation on a resource. + Turn the decorated coroutine function into a mutually exclusive operation on a `resource_id`. If any other mutually exclusive function currently holds the lock for a resource, do not run the decorated function and return None. `namespace` is an identifier used to prevent collisions among resource IDs. - `resource_arg` is the positional index or name of the parameter of the decorated function whose - value will be the resource ID. It may also be a callable which will return the resource ID - given the decorated function's args and kwargs. + `resource_id` identifies a resource on which to perform a mutually exclusive operation. It may + also be a callable which will return the resource ID given the decorated function's args and + kwargs. """ def decorator(func: t.Callable) -> t.Callable: @wraps(func) async def wrapper(*args, **kwargs) -> t.Any: - if callable(resource_arg): + if callable(resource_id): # Call to get the ID if a callable was given. - id_ = resource_arg(*args, **kwargs) + id_ = resource_id(*args, **kwargs) else: - # Retrieve the ID from the args via position or name. - id_ = _get_arg_value(resource_arg, args, kwargs) + id_ = resource_id # Get the lock for the ID. Create a Lock if one doesn't exist yet. locks = __lock_dicts[namespace] -- cgit v1.2.3 From 226d5e17b74d711776f7e7f49a8712ba820ac5ba Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Jul 2020 11:22:00 -0700 Subject: Decorators: support awaitables for resource ID --- bot/decorators.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index f49499856..063368dda 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -1,4 +1,5 @@ import asyncio +import inspect import logging import random import typing as t @@ -17,7 +18,9 @@ log = logging.getLogger(__name__) __lock_dicts = defaultdict(WeakValueDictionary) Argument = t.Union[int, str] -ResourceId = t.Union[t.Hashable, t.Callable[..., t.Hashable]] +_IdCallable = t.Callable[..., t.Hashable] +_IdAwaitable = t.Callable[..., t.Awaitable[t.Hashable]] +ResourceId = t.Union[t.Hashable, _IdCallable, _IdAwaitable] def in_whitelist( @@ -104,9 +107,9 @@ def mutually_exclusive(namespace: t.Hashable, resource_id: ResourceId) -> t.Call `namespace` is an identifier used to prevent collisions among resource IDs. - `resource_id` identifies a resource on which to perform a mutually exclusive operation. It may - also be a callable which will return the resource ID given the decorated function's args and - kwargs. + `resource_id` identifies a resource on which to perform a mutually exclusive operation. + It may also be a callable or awaitable which will return the resource ID given the decorated + function's args and kwargs. """ def decorator(func: t.Callable) -> t.Callable: @wraps(func) @@ -114,6 +117,10 @@ def mutually_exclusive(namespace: t.Hashable, resource_id: ResourceId) -> t.Call if callable(resource_id): # Call to get the ID if a callable was given. id_ = resource_id(*args, **kwargs) + + if inspect.isawaitable(id_): + # Await to get the ID if an awaitable was given. + id_ = await id_ else: id_ = resource_id -- cgit v1.2.3 From 66ca3a8313183bcb245804d68a6f09abd2724245 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Jul 2020 12:04:45 -0700 Subject: Decorators: fix lock creation --- bot/decorators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/decorators.py b/bot/decorators.py index 063368dda..abf7474ef 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -126,7 +126,7 @@ def mutually_exclusive(namespace: t.Hashable, resource_id: ResourceId) -> t.Call # Get the lock for the ID. Create a Lock if one doesn't exist yet. locks = __lock_dicts[namespace] - lock = locks.setdefault(id_, asyncio.Lock) + lock = locks.setdefault(id_, asyncio.Lock()) if not lock.locked(): # Resource is free; acquire it. -- cgit v1.2.3 From 6e2d3dfaacf03435c18e843fb366758f49e09181 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Jul 2020 12:23:31 -0700 Subject: Decorators: add logging for mutually_exclusive --- bot/decorators.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index abf7474ef..91104fc6c 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -112,26 +112,34 @@ def mutually_exclusive(namespace: t.Hashable, resource_id: ResourceId) -> t.Call function's args and kwargs. """ def decorator(func: t.Callable) -> t.Callable: + name = func.__name__ + @wraps(func) async def wrapper(*args, **kwargs) -> t.Any: + log.trace(f"{name}: mutually exclusive decorator called") + if callable(resource_id): - # Call to get the ID if a callable was given. + log.trace(f"{name}: calling the given callable to get the resource ID") id_ = resource_id(*args, **kwargs) if inspect.isawaitable(id_): - # Await to get the ID if an awaitable was given. + log.trace(f"{name}: awaiting to get resource ID") id_ = await id_ else: id_ = resource_id - # Get the lock for the ID. Create a Lock if one doesn't exist yet. + log.trace(f"{name}: getting lock for resource {id_!r} under namespace {namespace!r}") + + # Get the lock for the ID. Create a lock if one doesn't exist yet. locks = __lock_dicts[namespace] lock = locks.setdefault(id_, asyncio.Lock()) if not lock.locked(): - # Resource is free; acquire it. + log.debug(f"{name}: resource {namespace!r}:{id_!r} is free; acquiring it...") async with lock: return await func(*args, **kwargs) + else: + log.info(f"{name}: aborted because resource {namespace!r}:{id_!r} is locked") return wrapper return decorator -- cgit v1.2.3 From 0c03950cd4a4d1c8734e2a7172344dd073d3d188 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Jul 2020 13:22:40 -0700 Subject: Decorators: clarify use of mutually_exclusive with commands --- bot/decorators.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/decorators.py b/bot/decorators.py index 91104fc6c..f581e66d2 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -110,6 +110,8 @@ def mutually_exclusive(namespace: t.Hashable, resource_id: ResourceId) -> t.Call `resource_id` identifies a resource on which to perform a mutually exclusive operation. It may also be a callable or awaitable which will return the resource ID given the decorated function's args and kwargs. + + If decorating a command, this decorator must go before (below) the `command` decorator. """ def decorator(func: t.Callable) -> t.Callable: name = func.__name__ -- cgit v1.2.3 From a30b640a61943c1992b8614b98667d49653d8b71 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Jul 2020 23:12:07 -0700 Subject: Decorators: pass bound arguments to callable Bound arguments are more convenient to work with than the raw args and kwargs. --- bot/decorators.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index f581e66d2..7f58abd1c 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -18,9 +18,10 @@ log = logging.getLogger(__name__) __lock_dicts = defaultdict(WeakValueDictionary) Argument = t.Union[int, str] -_IdCallable = t.Callable[..., t.Hashable] -_IdAwaitable = t.Callable[..., t.Awaitable[t.Hashable]] -ResourceId = t.Union[t.Hashable, _IdCallable, _IdAwaitable] +BoundArgs = t.OrderedDict[str, t.Any] +_IdCallableReturn = t.Union[t.Hashable, t.Awaitable[t.Hashable]] +_IdCallable = t.Callable[[BoundArgs], _IdCallableReturn] +ResourceId = t.Union[t.Hashable, _IdCallable] def in_whitelist( @@ -108,8 +109,8 @@ def mutually_exclusive(namespace: t.Hashable, resource_id: ResourceId) -> t.Call `namespace` is an identifier used to prevent collisions among resource IDs. `resource_id` identifies a resource on which to perform a mutually exclusive operation. - It may also be a callable or awaitable which will return the resource ID given the decorated - function's args and kwargs. + It may also be a callable or awaitable which will return the resource ID given an ordered + mapping of the parameters' names to arguments' values. If decorating a command, this decorator must go before (below) the `command` decorator. """ @@ -121,8 +122,13 @@ def mutually_exclusive(namespace: t.Hashable, resource_id: ResourceId) -> t.Call log.trace(f"{name}: mutually exclusive decorator called") if callable(resource_id): + log.trace(f"{name}: binding args to signature") + sig = inspect.signature(func) + bound_args = sig.bind(*args, **kwargs) + bound_args.apply_defaults() + log.trace(f"{name}: calling the given callable to get the resource ID") - id_ = resource_id(*args, **kwargs) + id_ = resource_id(bound_args.arguments) if inspect.isawaitable(id_): log.trace(f"{name}: awaiting to get resource ID") -- cgit v1.2.3 From dc2c1c7f44de99ad9cbc69edc90607c625562760 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Jul 2020 23:53:17 -0700 Subject: Add util function to get value from arg This is a more advanced version meant to eventually replace the `_get_arg_values` in decorators.py. --- bot/utils/function.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 bot/utils/function.py diff --git a/bot/utils/function.py b/bot/utils/function.py new file mode 100644 index 000000000..7c5949122 --- /dev/null +++ b/bot/utils/function.py @@ -0,0 +1,34 @@ +"""Utilities for interaction with functions.""" + +import typing as t + +Argument = t.Union[int, str] + + +def get_arg_value(name_or_pos: Argument, arguments: t.OrderedDict[str, t.Any]) -> t.Any: + """ + Return a value from `arguments` based on a name or position. + + `arguments` is an ordered mapping of parameter names to argument values. + + Raise TypeError if `name_or_pos` isn't a str or int. + Raise ValueError if `name_or_pos` does not match any argument. + """ + if isinstance(name_or_pos, int): + # Convert arguments to a tuple to make them indexable. + arg_values = tuple(arguments.items()) + arg_pos = name_or_pos + + try: + name, value = arg_values[arg_pos] + return value + except IndexError: + raise ValueError(f"Argument position {arg_pos} is out of bounds.") + elif isinstance(name_or_pos, str): + arg_name = name_or_pos + try: + return arguments[arg_name] + except KeyError: + raise ValueError(f"Argument {arg_name!r} doesn't exist.") + else: + raise TypeError("'arg' must either be an int (positional index) or a str (keyword).") -- cgit v1.2.3 From 022b43c2651cd568592327e8b493fbefaa4f332a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 18 Jul 2020 10:31:35 -0700 Subject: Reminders: make operations mutually exclusive This fixes race conditions between editing, deleting, and sending a reminder. If one operation is already happening, the others will be aborted. --- bot/cogs/reminders.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index a043b7d75..1a0a9d303 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -4,6 +4,7 @@ import random import textwrap import typing as t from datetime import datetime, timedelta +from functools import partial from operator import itemgetter import discord @@ -14,14 +15,17 @@ from discord.ext.commands import Cog, Context, Greedy, group from bot.bot import Bot from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, STAFF_ROLES from bot.converters import Duration +from bot.decorators import mutually_exclusive from bot.pagination import LinePaginator from bot.utils.checks import without_role_check +from bot.utils.function import get_arg_value from bot.utils.messages import send_denial from bot.utils.scheduling import Scheduler from bot.utils.time import humanize_delta log = logging.getLogger(__name__) +NAMESPACE = "reminders" # Used for the mutually_exclusive decorator; constant to prevent typos WHITELISTED_CHANNELS = Guild.reminder_whitelist MAXIMUM_REMINDERS = 5 @@ -164,6 +168,7 @@ class Reminders(Cog): log.trace(f"Scheduling new task #{reminder['id']}") self.schedule_reminder(reminder) + @mutually_exclusive(NAMESPACE, lambda args: get_arg_value("reminder", args)["id"]) async def send_reminder(self, reminder: dict, late: relativedelta = None) -> None: """Send the reminder.""" is_valid, user, channel = self.ensure_valid_reminder(reminder) @@ -370,6 +375,7 @@ class Reminders(Cog): mention_ids = [mention.id for mention in mentions] await self.edit_reminder(ctx, id_, {"mentions": mention_ids}) + @mutually_exclusive(NAMESPACE, partial(get_arg_value, "id_")) async def edit_reminder(self, ctx: Context, id_: int, payload: dict) -> None: """Edits a reminder with the given payload, then sends a confirmation message.""" reminder = await self._edit_reminder(id_, payload) @@ -387,6 +393,7 @@ class Reminders(Cog): await self._reschedule_reminder(reminder) @remind_group.command("delete", aliases=("remove", "cancel")) + @mutually_exclusive(NAMESPACE, partial(get_arg_value, "id_")) async def delete_reminder(self, ctx: Context, id_: int) -> None: """Delete one of your active reminders.""" await self.bot.api_client.delete(f"bot/reminders/{id_}") -- cgit v1.2.3 From 8620e5541b89fb42b396dde0dbc5439de103417a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 18 Jul 2020 12:29:40 -0700 Subject: Add a function to wrap a decorator to use get_arg_value --- bot/utils/function.py | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/bot/utils/function.py b/bot/utils/function.py index 7c5949122..23188e79e 100644 --- a/bot/utils/function.py +++ b/bot/utils/function.py @@ -3,9 +3,12 @@ import typing as t Argument = t.Union[int, str] +BoundArgs = t.OrderedDict[str, t.Any] +Decorator = t.Callable[[t.Callable], t.Callable] +ArgValGetter = t.Callable[[BoundArgs], t.Any] -def get_arg_value(name_or_pos: Argument, arguments: t.OrderedDict[str, t.Any]) -> t.Any: +def get_arg_value(name_or_pos: Argument, arguments: BoundArgs) -> t.Any: """ Return a value from `arguments` based on a name or position. @@ -32,3 +35,27 @@ def get_arg_value(name_or_pos: Argument, arguments: t.OrderedDict[str, t.Any]) - raise ValueError(f"Argument {arg_name!r} doesn't exist.") else: raise TypeError("'arg' must either be an int (positional index) or a str (keyword).") + + +def get_arg_value_wrapper( + decorator_func: t.Callable[[ArgValGetter], Decorator], + name_or_pos: Argument, + func: t.Callable[[t.Any], t.Any] = None, +) -> Decorator: + """ + Call `decorator_func` with the value of the arg at the given name/position. + + `decorator_func` must accept a callable as a parameter to which it will pass a mapping of + parameter names to argument values of the function it's decorating. + + `func` is an optional callable which will return a new value given the argument's value. + + Return the decorator returned by `decorator_func`. + """ + def wrapper(args: BoundArgs) -> t.Any: + value = get_arg_value(name_or_pos, args) + if func: + value = func(value) + return value + + return decorator_func(wrapper) -- cgit v1.2.3 From d0e92aeaf9142c80aac949e8a01ff31c40e8ca96 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 18 Jul 2020 13:11:21 -0700 Subject: Add a function to get bound args --- bot/decorators.py | 7 +++---- bot/utils/function.py | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index 7f58abd1c..ef4951141 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -12,6 +12,7 @@ from discord import Colour, Embed, Member, NotFound from discord.ext.commands import Cog, Command, Context, check from bot.constants import Channels, ERROR_REPLIES, RedirectOutput +from bot.utils import function from bot.utils.checks import in_whitelist_check, with_role_check, without_role_check log = logging.getLogger(__name__) @@ -123,12 +124,10 @@ def mutually_exclusive(namespace: t.Hashable, resource_id: ResourceId) -> t.Call if callable(resource_id): log.trace(f"{name}: binding args to signature") - sig = inspect.signature(func) - bound_args = sig.bind(*args, **kwargs) - bound_args.apply_defaults() + bound_args = function.get_bound_args(func, args, kwargs) log.trace(f"{name}: calling the given callable to get the resource ID") - id_ = resource_id(bound_args.arguments) + id_ = resource_id(bound_args) if inspect.isawaitable(id_): log.trace(f"{name}: awaiting to get resource ID") diff --git a/bot/utils/function.py b/bot/utils/function.py index 23188e79e..3ab32fe3c 100644 --- a/bot/utils/function.py +++ b/bot/utils/function.py @@ -1,5 +1,6 @@ """Utilities for interaction with functions.""" +import inspect import typing as t Argument = t.Union[int, str] @@ -59,3 +60,16 @@ def get_arg_value_wrapper( return value return decorator_func(wrapper) + + +def get_bound_args(func: t.Callable, args: t.Tuple, kwargs: t.Dict[str, t.Any]) -> BoundArgs: + """ + Bind `args` and `kwargs` to `func` and return a mapping of parameter names to argument values. + + Default parameter values are also set. + """ + sig = inspect.signature(func) + bound_args = sig.bind(*args, **kwargs) + bound_args.apply_defaults() + + return bound_args.arguments -- cgit v1.2.3 From e0d6138d89112740e1407873a8eb903b9f49ac0a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 18 Jul 2020 13:28:34 -0700 Subject: Decorators: use new func utils in respect_role_hierarchy Replace the `_get_arg_value` call with `function.get_arg_value` cause the latter makes use of bound arguments, which are more accurate. --- bot/decorators.py | 43 +++++++++++++------------------------------ 1 file changed, 13 insertions(+), 30 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index ef4951141..b4bf9ba05 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -200,30 +200,33 @@ def redirect_output(destination_channel: int, bypass_roles: t.Container[int] = N return wrap -def respect_role_hierarchy(target_arg: Argument = 0) -> t.Callable: +def respect_role_hierarchy(name_or_pos: Argument = 2) -> t.Callable: """ Ensure the highest role of the invoking member is greater than that of the target member. If the condition fails, a warning is sent to the invoking context. A target which is not an instance of discord.Member will always pass. - A value of 0 (i.e. position 0) for `target_arg` corresponds to the argument which comes after - `ctx`. If the target argument is a kwarg, its name can instead be given. + `name_or_pos` is the keyword name or position index of the parameter of the decorated command + whose value is the target member. This decorator must go before (below) the `command` decorator. """ - def wrap(func: t.Callable) -> t.Callable: + def decorator(func: t.Callable) -> t.Callable: @wraps(func) - async def inner(self: Cog, ctx: Context, *args, **kwargs) -> None: - target = _get_arg_value(target_arg, args, kwargs) + async def wrapper(*args, **kwargs) -> None: + bound_args = function.get_bound_args(func, args, kwargs) + target = function.get_arg_value(name_or_pos, bound_args) if not isinstance(target, Member): log.trace("The target is not a discord.Member; skipping role hierarchy check.") - await func(self, ctx, *args, **kwargs) + await func(*args, **kwargs) return + ctx = function.get_arg_value(1, bound_args) cmd = ctx.command.name actor = ctx.author + if target.top_role >= actor.top_role: log.info( f"{actor} ({actor.id}) attempted to {cmd} " @@ -234,26 +237,6 @@ def respect_role_hierarchy(target_arg: Argument = 0) -> t.Callable: "someone with an equal or higher top role." ) else: - await func(self, ctx, *args, **kwargs) - return inner - return wrap - - -def _get_arg_value(target_arg: Argument, args: t.Tuple, kwargs: t.Dict[str, t.Any]) -> t.Any: - """ - Return the value of the arg at the given position or name `target_arg`. - - Use an integer as a position if the target argument is positional. - Use a string as a parameter name if the target argument is a keyword argument. - - Raise ValueError if `target_arg` cannot be found. - """ - try: - return kwargs[target_arg] - except KeyError: - try: - return args[target_arg] - except IndexError: - raise ValueError(f"Could not find target argument at position {target_arg}") - except TypeError: - raise ValueError(f"Could not find target kwarg with key {target_arg!r}") + await func(*args, **kwargs) + return wrapper + return decorator -- cgit v1.2.3 From 578477da164ef8c3ff77400b22e608a7d4c6d5f2 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 18 Jul 2020 13:29:51 -0700 Subject: Decorators: remove default value for respect_role_hierarchy Explicit is better than implicit, and this default value wasn't much of a convenience. --- bot/cogs/moderation/infractions.py | 4 ++-- bot/decorators.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 8df642428..d720c2911 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -230,7 +230,7 @@ class Infractions(InfractionScheduler, commands.Cog): await self.apply_infraction(ctx, infraction, user, action()) - @respect_role_hierarchy() + @respect_role_hierarchy(2) async def apply_kick(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None: """Apply a kick infraction with kwargs passed to `post_infraction`.""" infraction = await utils.post_infraction(ctx, user, "kick", reason, active=False, **kwargs) @@ -245,7 +245,7 @@ class Infractions(InfractionScheduler, commands.Cog): action = user.kick(reason=reason) await self.apply_infraction(ctx, infraction, user, action) - @respect_role_hierarchy() + @respect_role_hierarchy(2) async def apply_ban(self, ctx: Context, user: UserSnowflake, reason: t.Optional[str], **kwargs) -> None: """ Apply a ban infraction with kwargs passed to `post_infraction`. diff --git a/bot/decorators.py b/bot/decorators.py index b4bf9ba05..6b2214f53 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -200,7 +200,7 @@ def redirect_output(destination_channel: int, bypass_roles: t.Container[int] = N return wrap -def respect_role_hierarchy(name_or_pos: Argument = 2) -> t.Callable: +def respect_role_hierarchy(name_or_pos: Argument) -> t.Callable: """ Ensure the highest role of the invoking member is greater than that of the target member. -- cgit v1.2.3 From bb0cb84546820b9399c2b08edda641b079898538 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 18 Jul 2020 13:36:45 -0700 Subject: Decorators: use type aliases from function module --- bot/decorators.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index 6b2214f53..eefe2f9ba 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -18,10 +18,8 @@ from bot.utils.checks import in_whitelist_check, with_role_check, without_role_c log = logging.getLogger(__name__) __lock_dicts = defaultdict(WeakValueDictionary) -Argument = t.Union[int, str] -BoundArgs = t.OrderedDict[str, t.Any] _IdCallableReturn = t.Union[t.Hashable, t.Awaitable[t.Hashable]] -_IdCallable = t.Callable[[BoundArgs], _IdCallableReturn] +_IdCallable = t.Callable[[function.BoundArgs], _IdCallableReturn] ResourceId = t.Union[t.Hashable, _IdCallable] @@ -200,7 +198,7 @@ def redirect_output(destination_channel: int, bypass_roles: t.Container[int] = N return wrap -def respect_role_hierarchy(name_or_pos: Argument) -> t.Callable: +def respect_role_hierarchy(name_or_pos: function.Argument) -> t.Callable: """ Ensure the highest role of the invoking member is greater than that of the target member. -- cgit v1.2.3 From c9a3a73c93d553b65df282dd5a42fa694dd3dfe2 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 18 Jul 2020 13:37:38 -0700 Subject: Decorators: remove redundant word in docstring --- bot/decorators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/decorators.py b/bot/decorators.py index eefe2f9ba..cffd97440 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -38,7 +38,7 @@ def in_whitelist( - `channels`: a container with channel ids for whitelisted channels - `categories`: a container with category ids for whitelisted categories - - `roles`: a container with with role ids for whitelisted roles + - `roles`: a container with role ids for whitelisted roles If the command was invoked in a context that was not whitelisted, the member is either redirected to the `redirect` channel that was passed (default: #bot-commands) or simply -- cgit v1.2.3 From 0010d2d332f64bebd0ca4eadeac682c7f83014f9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 18 Jul 2020 13:48:56 -0700 Subject: Decorators: wrap mutually_exclusive to use get_arg_value Instead of taking a callable, this wrapper just takes a name or position to get the resource ID. --- bot/cogs/reminders.py | 10 ++++------ bot/decorators.py | 17 ++++++++++++++++- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 1a0a9d303..30f7c8876 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -4,7 +4,6 @@ import random import textwrap import typing as t from datetime import datetime, timedelta -from functools import partial from operator import itemgetter import discord @@ -15,10 +14,9 @@ from discord.ext.commands import Cog, Context, Greedy, group from bot.bot import Bot from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, STAFF_ROLES from bot.converters import Duration -from bot.decorators import mutually_exclusive +from bot.decorators import mutually_exclusive_arg from bot.pagination import LinePaginator from bot.utils.checks import without_role_check -from bot.utils.function import get_arg_value from bot.utils.messages import send_denial from bot.utils.scheduling import Scheduler from bot.utils.time import humanize_delta @@ -168,7 +166,7 @@ class Reminders(Cog): log.trace(f"Scheduling new task #{reminder['id']}") self.schedule_reminder(reminder) - @mutually_exclusive(NAMESPACE, lambda args: get_arg_value("reminder", args)["id"]) + @mutually_exclusive_arg(NAMESPACE, "reminder", itemgetter("id")) async def send_reminder(self, reminder: dict, late: relativedelta = None) -> None: """Send the reminder.""" is_valid, user, channel = self.ensure_valid_reminder(reminder) @@ -375,7 +373,7 @@ class Reminders(Cog): mention_ids = [mention.id for mention in mentions] await self.edit_reminder(ctx, id_, {"mentions": mention_ids}) - @mutually_exclusive(NAMESPACE, partial(get_arg_value, "id_")) + @mutually_exclusive_arg(NAMESPACE, "id_") async def edit_reminder(self, ctx: Context, id_: int, payload: dict) -> None: """Edits a reminder with the given payload, then sends a confirmation message.""" reminder = await self._edit_reminder(id_, payload) @@ -393,7 +391,7 @@ class Reminders(Cog): await self._reschedule_reminder(reminder) @remind_group.command("delete", aliases=("remove", "cancel")) - @mutually_exclusive(NAMESPACE, partial(get_arg_value, "id_")) + @mutually_exclusive_arg(NAMESPACE, "id_") async def delete_reminder(self, ctx: Context, id_: int) -> None: """Delete one of your active reminders.""" await self.bot.api_client.delete(f"bot/reminders/{id_}") diff --git a/bot/decorators.py b/bot/decorators.py index cffd97440..c9e4a0560 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -5,7 +5,7 @@ import random import typing as t from collections import defaultdict from contextlib import suppress -from functools import wraps +from functools import partial, wraps from weakref import WeakValueDictionary from discord import Colour, Embed, Member, NotFound @@ -150,6 +150,21 @@ def mutually_exclusive(namespace: t.Hashable, resource_id: ResourceId) -> t.Call return decorator +def mutually_exclusive_arg( + namespace: t.Hashable, + name_or_pos: function.Argument, + func: t.Callable[[t.Any], _IdCallableReturn] = None +) -> t.Callable: + """ + Apply `mutually_exclusive` using the value of the arg at the given name/position as the ID. + + `func` is an optional callable or awaitable which will return the ID given the argument value. + See `mutually_exclusive` docs for more information. + """ + decorator_func = partial(mutually_exclusive, namespace) + return function.get_arg_value_wrapper(decorator_func, name_or_pos, func) + + def redirect_output(destination_channel: int, bypass_roles: t.Container[int] = None) -> t.Callable: """ Changes the channel in the context of the command to redirect the output to a certain channel. -- cgit v1.2.3 From 90e0a3707c77b41144964dcfd3eb82714ac26b25 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 18 Jul 2020 13:59:38 -0700 Subject: Decorators: add some trace logging --- bot/decorators.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/decorators.py b/bot/decorators.py index c9e4a0560..e370bf834 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -228,6 +228,8 @@ def respect_role_hierarchy(name_or_pos: function.Argument) -> t.Callable: def decorator(func: t.Callable) -> t.Callable: @wraps(func) async def wrapper(*args, **kwargs) -> None: + log.trace(f"{func.__name__}: respect role hierarchy decorator called") + bound_args = function.get_bound_args(func, args, kwargs) target = function.get_arg_value(name_or_pos, bound_args) @@ -250,6 +252,7 @@ def respect_role_hierarchy(name_or_pos: function.Argument) -> t.Callable: "someone with an equal or higher top role." ) else: + log.trace(f"{func.__name__}: {target.top_role=} < {actor.top_role=}; calling func") await func(*args, **kwargs) return wrapper return decorator -- cgit v1.2.3 From 44fa8d7aef62b0cac5edede8002ae8cf8ac8c74b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 31 Jul 2020 22:22:13 -0700 Subject: Decorators: optionally raise an exception if resource is locked The exception will facilitate user feedback for commands which use the decorator. --- bot/decorators.py | 19 +++++++++++++++---- bot/errors.py | 20 ++++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) create mode 100644 bot/errors.py diff --git a/bot/decorators.py b/bot/decorators.py index e370bf834..15386e506 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -12,6 +12,7 @@ from discord import Colour, Embed, Member, NotFound from discord.ext.commands import Cog, Command, Context, check from bot.constants import Channels, ERROR_REPLIES, RedirectOutput +from bot.errors import LockedResourceError from bot.utils import function from bot.utils.checks import in_whitelist_check, with_role_check, without_role_check @@ -98,12 +99,18 @@ def locked() -> t.Callable: return wrap -def mutually_exclusive(namespace: t.Hashable, resource_id: ResourceId) -> t.Callable: +def mutually_exclusive( + namespace: t.Hashable, + resource_id: ResourceId, + *, + raise_error: bool = False, +) -> t.Callable: """ Turn the decorated coroutine function into a mutually exclusive operation on a `resource_id`. If any other mutually exclusive function currently holds the lock for a resource, do not run the - decorated function and return None. + decorated function and return None. If `raise_error` is True, raise `LockedResourceError` if + the lock cannot be acquired. `namespace` is an identifier used to prevent collisions among resource IDs. @@ -145,6 +152,8 @@ def mutually_exclusive(namespace: t.Hashable, resource_id: ResourceId) -> t.Call return await func(*args, **kwargs) else: log.info(f"{name}: aborted because resource {namespace!r}:{id_!r} is locked") + if raise_error: + raise LockedResourceError(str(namespace), id_) return wrapper return decorator @@ -153,7 +162,9 @@ def mutually_exclusive(namespace: t.Hashable, resource_id: ResourceId) -> t.Call def mutually_exclusive_arg( namespace: t.Hashable, name_or_pos: function.Argument, - func: t.Callable[[t.Any], _IdCallableReturn] = None + func: t.Callable[[t.Any], _IdCallableReturn] = None, + *, + raise_error: bool = False, ) -> t.Callable: """ Apply `mutually_exclusive` using the value of the arg at the given name/position as the ID. @@ -161,7 +172,7 @@ def mutually_exclusive_arg( `func` is an optional callable or awaitable which will return the ID given the argument value. See `mutually_exclusive` docs for more information. """ - decorator_func = partial(mutually_exclusive, namespace) + decorator_func = partial(mutually_exclusive, namespace, raise_error=raise_error) return function.get_arg_value_wrapper(decorator_func, name_or_pos, func) diff --git a/bot/errors.py b/bot/errors.py new file mode 100644 index 000000000..34de3c2b1 --- /dev/null +++ b/bot/errors.py @@ -0,0 +1,20 @@ +from typing import Hashable + + +class LockedResourceError(RuntimeError): + """ + Exception raised when an operation is attempted on a locked resource. + + Attributes: + `type` -- name of the locked resource's type + `resource_id` -- ID of the locked resource + """ + + def __init__(self, resource_type: str, resource_id: Hashable): + self.type = resource_type + self.id = resource_id + + super().__init__( + f"Cannot operate on {self.type.lower()} `{self.id}`; " + "it is currently locked and in use by another operation." + ) -- cgit v1.2.3 From b1a677cd0a64b2ad4da400a492d9d5157d558546 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 31 Jul 2020 22:26:59 -0700 Subject: Send users an error message if command raises LockedResourceError --- bot/cogs/error_handler.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 233851e41..a9c6d50b7 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -9,6 +9,7 @@ from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import Channels from bot.converters import TagNameConverter +from bot.errors import LockedResourceError from bot.utils.checks import InWhitelistCheckFailure log = logging.getLogger(__name__) @@ -66,6 +67,8 @@ class ErrorHandler(Cog): elif isinstance(e, errors.CommandInvokeError): if isinstance(e.original, ResponseCodeError): await self.handle_api_error(ctx, e.original) + elif isinstance(e.original, LockedResourceError): + await ctx.send(f"{e.original} Please wait for it to finish and try again later.") else: await self.handle_unexpected_error(ctx, e.original) return # Exit early to avoid logging. -- cgit v1.2.3 From ea74cd51b3821caf0298eacd451d0519cb4d1b9a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 31 Jul 2020 22:29:42 -0700 Subject: Reminders: show error to users if reminder is in use Silent failure is confusing to users. Showing an error message clears up why nothing happened with their command. --- bot/cogs/reminders.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 30f7c8876..292435f24 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -166,7 +166,7 @@ class Reminders(Cog): log.trace(f"Scheduling new task #{reminder['id']}") self.schedule_reminder(reminder) - @mutually_exclusive_arg(NAMESPACE, "reminder", itemgetter("id")) + @mutually_exclusive_arg(NAMESPACE, "reminder", itemgetter("id"), raise_error=True) async def send_reminder(self, reminder: dict, late: relativedelta = None) -> None: """Send the reminder.""" is_valid, user, channel = self.ensure_valid_reminder(reminder) @@ -373,7 +373,7 @@ class Reminders(Cog): mention_ids = [mention.id for mention in mentions] await self.edit_reminder(ctx, id_, {"mentions": mention_ids}) - @mutually_exclusive_arg(NAMESPACE, "id_") + @mutually_exclusive_arg(NAMESPACE, "id_", raise_error=True) async def edit_reminder(self, ctx: Context, id_: int, payload: dict) -> None: """Edits a reminder with the given payload, then sends a confirmation message.""" reminder = await self._edit_reminder(id_, payload) @@ -391,7 +391,7 @@ class Reminders(Cog): await self._reschedule_reminder(reminder) @remind_group.command("delete", aliases=("remove", "cancel")) - @mutually_exclusive_arg(NAMESPACE, "id_") + @mutually_exclusive_arg(NAMESPACE, "id_", raise_error=True) async def delete_reminder(self, ctx: Context, id_: int) -> None: """Delete one of your active reminders.""" await self.bot.api_client.delete(f"bot/reminders/{id_}") -- cgit v1.2.3 From 6618359ac3484544fe4e88d66dd8b5669254c154 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 31 Jul 2020 22:40:18 -0700 Subject: Reminders: use singular form for mutually exclusive namespace The exception it raises reads better if the singular form of the word is used. --- bot/cogs/reminders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 292435f24..be97d34b6 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -23,7 +23,7 @@ from bot.utils.time import humanize_delta log = logging.getLogger(__name__) -NAMESPACE = "reminders" # Used for the mutually_exclusive decorator; constant to prevent typos +NAMESPACE = "reminder" # Used for the mutually_exclusive decorator; constant to prevent typos WHITELISTED_CHANNELS = Guild.reminder_whitelist MAXIMUM_REMINDERS = 5 -- cgit v1.2.3 From 675d9a2abf7212f4680d124c72da1a914c87756c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 1 Aug 2020 08:54:42 -0700 Subject: Decorators: fix type annotations for checks The annotation was previously changed on the basis of an incorrect return annotation PyCharm inferred for `check()`. --- bot/decorators.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index 15386e506..96f0d1408 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -9,7 +9,7 @@ from functools import partial, wraps from weakref import WeakValueDictionary from discord import Colour, Embed, Member, NotFound -from discord.ext.commands import Cog, Command, Context, check +from discord.ext.commands import Cog, Context, check from bot.constants import Channels, ERROR_REPLIES, RedirectOutput from bot.errors import LockedResourceError @@ -31,7 +31,7 @@ def in_whitelist( roles: t.Container[int] = (), redirect: t.Optional[int] = Channels.bot_commands, fail_silently: bool = False, -) -> Command: +) -> t.Callable: """ Check if a command was issued in a whitelisted context. @@ -52,7 +52,7 @@ def in_whitelist( return check(predicate) -def with_role(*role_ids: int) -> Command: +def with_role(*role_ids: int) -> t.Callable: """Returns True if the user has any one of the roles in role_ids.""" async def predicate(ctx: Context) -> bool: """With role checker predicate.""" @@ -60,7 +60,7 @@ def with_role(*role_ids: int) -> Command: return check(predicate) -def without_role(*role_ids: int) -> Command: +def without_role(*role_ids: int) -> t.Callable: """Returns True if the user does not have any of the roles in role_ids.""" async def predicate(ctx: Context) -> bool: return without_role_check(ctx, *role_ids) -- cgit v1.2.3 From d96e605162d40f8890bdc57734631a0cc47b8e13 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 1 Aug 2020 09:00:00 -0700 Subject: Explicitly use kwarg with respect_role_hierarchy Clarify the significance of the argument being passed. --- bot/cogs/moderation/infractions.py | 4 ++-- bot/decorators.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index d720c2911..b68b5f117 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -230,7 +230,7 @@ class Infractions(InfractionScheduler, commands.Cog): await self.apply_infraction(ctx, infraction, user, action()) - @respect_role_hierarchy(2) + @respect_role_hierarchy(member_arg=2) async def apply_kick(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None: """Apply a kick infraction with kwargs passed to `post_infraction`.""" infraction = await utils.post_infraction(ctx, user, "kick", reason, active=False, **kwargs) @@ -245,7 +245,7 @@ class Infractions(InfractionScheduler, commands.Cog): action = user.kick(reason=reason) await self.apply_infraction(ctx, infraction, user, action) - @respect_role_hierarchy(2) + @respect_role_hierarchy(member_arg=2) async def apply_ban(self, ctx: Context, user: UserSnowflake, reason: t.Optional[str], **kwargs) -> None: """ Apply a ban infraction with kwargs passed to `post_infraction`. diff --git a/bot/decorators.py b/bot/decorators.py index 96f0d1408..0e84cf37e 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -224,14 +224,14 @@ def redirect_output(destination_channel: int, bypass_roles: t.Container[int] = N return wrap -def respect_role_hierarchy(name_or_pos: function.Argument) -> t.Callable: +def respect_role_hierarchy(member_arg: function.Argument) -> t.Callable: """ Ensure the highest role of the invoking member is greater than that of the target member. If the condition fails, a warning is sent to the invoking context. A target which is not an instance of discord.Member will always pass. - `name_or_pos` is the keyword name or position index of the parameter of the decorated command + `member_arg` is the keyword name or position index of the parameter of the decorated command whose value is the target member. This decorator must go before (below) the `command` decorator. @@ -242,7 +242,7 @@ def respect_role_hierarchy(name_or_pos: function.Argument) -> t.Callable: log.trace(f"{func.__name__}: respect role hierarchy decorator called") bound_args = function.get_bound_args(func, args, kwargs) - target = function.get_arg_value(name_or_pos, bound_args) + target = function.get_arg_value(member_arg, bound_args) if not isinstance(target, Member): log.trace("The target is not a discord.Member; skipping role hierarchy check.") -- cgit v1.2.3 From 2544d192fd7403cf92cc568537bb93aa7a859815 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 22 Aug 2020 09:50:25 -0700 Subject: Decorators: replace asyncio.Lock with a custom object Concerns were raised over possible race conditions due `asyncio.Lock` internally awaiting coroutines. Does a mere `await` suspend the current coroutine, or does it have to actually await something asynchronous, like a future? Avoid answering that question by doing away with the awaits, which aren't necessary but are there as a consequence of using `asyncio.Lock`. Instead, add a custom `LockGuard` object to replace the previous locks. --- bot/decorators.py | 6 +++--- bot/utils/__init__.py | 3 ++- bot/utils/lock.py | 23 +++++++++++++++++++++++ 3 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 bot/utils/lock.py diff --git a/bot/decorators.py b/bot/decorators.py index 0e84cf37e..3418dfd11 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -13,7 +13,7 @@ from discord.ext.commands import Cog, Context, check from bot.constants import Channels, ERROR_REPLIES, RedirectOutput from bot.errors import LockedResourceError -from bot.utils import function +from bot.utils import LockGuard, function from bot.utils.checks import in_whitelist_check, with_role_check, without_role_check log = logging.getLogger(__name__) @@ -144,11 +144,11 @@ def mutually_exclusive( # Get the lock for the ID. Create a lock if one doesn't exist yet. locks = __lock_dicts[namespace] - lock = locks.setdefault(id_, asyncio.Lock()) + lock = locks.setdefault(id_, LockGuard()) if not lock.locked(): log.debug(f"{name}: resource {namespace!r}:{id_!r} is free; acquiring it...") - async with lock: + with lock: return await func(*args, **kwargs) else: log.info(f"{name}: aborted because resource {namespace!r}:{id_!r} is locked") diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 5a6e1811b..0dd9605e8 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -2,9 +2,10 @@ from abc import ABCMeta from discord.ext.commands import CogMeta +from bot.utils.lock import LockGuard from bot.utils.redis_cache import RedisCache -__all__ = ['RedisCache', 'CogABCMeta'] +__all__ = ["CogABCMeta", "LockGuard", "RedisCache"] class CogABCMeta(CogMeta, ABCMeta): diff --git a/bot/utils/lock.py b/bot/utils/lock.py new file mode 100644 index 000000000..8f1b738aa --- /dev/null +++ b/bot/utils/lock.py @@ -0,0 +1,23 @@ +class LockGuard: + """ + A context manager which acquires and releases a lock (mutex). + + Raise RuntimeError if trying to acquire a locked lock. + """ + + def __init__(self): + self._locked = False + + def locked(self) -> bool: + """Return True if currently locked or False if unlocked.""" + return self._locked + + def __enter__(self): + if self._locked: + raise RuntimeError("Cannot acquire a locked lock.") + + self._locked = True + + def __exit__(self, _exc_type, _exc_value, _traceback): # noqa: ANN001 + self._locked = False + return False # Indicate any raised exception shouldn't be suppressed. -- cgit v1.2.3 From 6556a5cc02c391657e26950ede84a2fb7e4f679e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 9 Sep 2020 17:23:26 -0700 Subject: Decorators: remove locked() decorator It was not being used anywhere. --- bot/decorators.py | 37 ++----------------------------------- 1 file changed, 2 insertions(+), 35 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index 3418dfd11..333716cf5 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -1,17 +1,16 @@ import asyncio import inspect import logging -import random import typing as t from collections import defaultdict from contextlib import suppress from functools import partial, wraps from weakref import WeakValueDictionary -from discord import Colour, Embed, Member, NotFound +from discord import Member, NotFound from discord.ext.commands import Cog, Context, check -from bot.constants import Channels, ERROR_REPLIES, RedirectOutput +from bot.constants import Channels, RedirectOutput from bot.errors import LockedResourceError from bot.utils import LockGuard, function from bot.utils.checks import in_whitelist_check, with_role_check, without_role_check @@ -67,38 +66,6 @@ def without_role(*role_ids: int) -> t.Callable: return check(predicate) -def locked() -> t.Callable: - """ - Allows the user to only run one instance of the decorated command at a time. - - Subsequent calls to the command from the same author are ignored until the command has completed invocation. - - This decorator must go before (below) the `command` decorator. - """ - def wrap(func: t.Callable) -> t.Callable: - func.__locks = WeakValueDictionary() - - @wraps(func) - async def inner(self: Cog, ctx: Context, *args, **kwargs) -> None: - lock = func.__locks.setdefault(ctx.author.id, asyncio.Lock()) - if lock.locked(): - embed = Embed() - embed.colour = Colour.red() - - log.debug("User tried to invoke a locked command.") - embed.description = ( - "You're already using this command. Please wait until it is done before you use it again." - ) - embed.title = random.choice(ERROR_REPLIES) - await ctx.send(embed=embed) - return - - async with func.__locks.setdefault(ctx.author.id, asyncio.Lock()): - await func(self, ctx, *args, **kwargs) - return inner - return wrap - - def mutually_exclusive( namespace: t.Hashable, resource_id: ResourceId, -- cgit v1.2.3 From 85e4d910da9fafb67f46330e4446e83a738d7a9b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 22 Aug 2020 10:10:22 -0700 Subject: Decorators: rename mutually_exclusive decorators A mutex is the same thing as a lock. The former is a relatively esoteric contraction, so the latter is preferred. --- bot/cogs/reminders.py | 8 ++++---- bot/decorators.py | 15 +++++---------- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index be97d34b6..734e0bd2d 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -14,7 +14,7 @@ from discord.ext.commands import Cog, Context, Greedy, group from bot.bot import Bot from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, STAFF_ROLES from bot.converters import Duration -from bot.decorators import mutually_exclusive_arg +from bot.decorators import lock_arg from bot.pagination import LinePaginator from bot.utils.checks import without_role_check from bot.utils.messages import send_denial @@ -166,7 +166,7 @@ class Reminders(Cog): log.trace(f"Scheduling new task #{reminder['id']}") self.schedule_reminder(reminder) - @mutually_exclusive_arg(NAMESPACE, "reminder", itemgetter("id"), raise_error=True) + @lock_arg(NAMESPACE, "reminder", itemgetter("id"), raise_error=True) async def send_reminder(self, reminder: dict, late: relativedelta = None) -> None: """Send the reminder.""" is_valid, user, channel = self.ensure_valid_reminder(reminder) @@ -373,7 +373,7 @@ class Reminders(Cog): mention_ids = [mention.id for mention in mentions] await self.edit_reminder(ctx, id_, {"mentions": mention_ids}) - @mutually_exclusive_arg(NAMESPACE, "id_", raise_error=True) + @lock_arg(NAMESPACE, "id_", raise_error=True) async def edit_reminder(self, ctx: Context, id_: int, payload: dict) -> None: """Edits a reminder with the given payload, then sends a confirmation message.""" reminder = await self._edit_reminder(id_, payload) @@ -391,7 +391,7 @@ class Reminders(Cog): await self._reschedule_reminder(reminder) @remind_group.command("delete", aliases=("remove", "cancel")) - @mutually_exclusive_arg(NAMESPACE, "id_", raise_error=True) + @lock_arg(NAMESPACE, "id_", raise_error=True) async def delete_reminder(self, ctx: Context, id_: int) -> None: """Delete one of your active reminders.""" await self.bot.api_client.delete(f"bot/reminders/{id_}") diff --git a/bot/decorators.py b/bot/decorators.py index 333716cf5..aabbe2cc9 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -66,12 +66,7 @@ def without_role(*role_ids: int) -> t.Callable: return check(predicate) -def mutually_exclusive( - namespace: t.Hashable, - resource_id: ResourceId, - *, - raise_error: bool = False, -) -> t.Callable: +def lock(namespace: t.Hashable, resource_id: ResourceId, *, raise_error: bool = False) -> t.Callable: """ Turn the decorated coroutine function into a mutually exclusive operation on a `resource_id`. @@ -126,7 +121,7 @@ def mutually_exclusive( return decorator -def mutually_exclusive_arg( +def lock_arg( namespace: t.Hashable, name_or_pos: function.Argument, func: t.Callable[[t.Any], _IdCallableReturn] = None, @@ -134,12 +129,12 @@ def mutually_exclusive_arg( raise_error: bool = False, ) -> t.Callable: """ - Apply `mutually_exclusive` using the value of the arg at the given name/position as the ID. + Apply the `lock` decorator using the value of the arg at the given name/position as the ID. `func` is an optional callable or awaitable which will return the ID given the argument value. - See `mutually_exclusive` docs for more information. + See `lock` docs for more information. """ - decorator_func = partial(mutually_exclusive, namespace, raise_error=raise_error) + decorator_func = partial(lock, namespace, raise_error=raise_error) return function.get_arg_value_wrapper(decorator_func, name_or_pos, func) -- cgit v1.2.3 From ac25ada30f4e43c130e0183be16ad6eef41c44d8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 22 Aug 2020 10:23:21 -0700 Subject: Move lock decorators to utils/lock.py `LockGuard` was lonely and the decorators were cluttering up decorators.py. --- bot/cogs/reminders.py | 2 +- bot/decorators.py | 85 ++---------------------------------------------- bot/utils/__init__.py | 3 +- bot/utils/lock.py | 90 +++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 94 insertions(+), 86 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 734e0bd2d..25b2c9421 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -14,9 +14,9 @@ from discord.ext.commands import Cog, Context, Greedy, group from bot.bot import Bot from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, STAFF_ROLES from bot.converters import Duration -from bot.decorators import lock_arg from bot.pagination import LinePaginator from bot.utils.checks import without_role_check +from bot.utils.lock import lock_arg from bot.utils.messages import send_denial from bot.utils.scheduling import Scheduler from bot.utils.time import humanize_delta diff --git a/bot/decorators.py b/bot/decorators.py index aabbe2cc9..2ec0cb122 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -1,26 +1,17 @@ import asyncio -import inspect import logging import typing as t -from collections import defaultdict from contextlib import suppress -from functools import partial, wraps -from weakref import WeakValueDictionary +from functools import wraps from discord import Member, NotFound from discord.ext.commands import Cog, Context, check from bot.constants import Channels, RedirectOutput -from bot.errors import LockedResourceError -from bot.utils import LockGuard, function +from bot.utils import function from bot.utils.checks import in_whitelist_check, with_role_check, without_role_check log = logging.getLogger(__name__) -__lock_dicts = defaultdict(WeakValueDictionary) - -_IdCallableReturn = t.Union[t.Hashable, t.Awaitable[t.Hashable]] -_IdCallable = t.Callable[[function.BoundArgs], _IdCallableReturn] -ResourceId = t.Union[t.Hashable, _IdCallable] def in_whitelist( @@ -66,78 +57,6 @@ def without_role(*role_ids: int) -> t.Callable: return check(predicate) -def lock(namespace: t.Hashable, resource_id: ResourceId, *, raise_error: bool = False) -> t.Callable: - """ - Turn the decorated coroutine function into a mutually exclusive operation on a `resource_id`. - - If any other mutually exclusive function currently holds the lock for a resource, do not run the - decorated function and return None. If `raise_error` is True, raise `LockedResourceError` if - the lock cannot be acquired. - - `namespace` is an identifier used to prevent collisions among resource IDs. - - `resource_id` identifies a resource on which to perform a mutually exclusive operation. - It may also be a callable or awaitable which will return the resource ID given an ordered - mapping of the parameters' names to arguments' values. - - If decorating a command, this decorator must go before (below) the `command` decorator. - """ - def decorator(func: t.Callable) -> t.Callable: - name = func.__name__ - - @wraps(func) - async def wrapper(*args, **kwargs) -> t.Any: - log.trace(f"{name}: mutually exclusive decorator called") - - if callable(resource_id): - log.trace(f"{name}: binding args to signature") - bound_args = function.get_bound_args(func, args, kwargs) - - log.trace(f"{name}: calling the given callable to get the resource ID") - id_ = resource_id(bound_args) - - if inspect.isawaitable(id_): - log.trace(f"{name}: awaiting to get resource ID") - id_ = await id_ - else: - id_ = resource_id - - log.trace(f"{name}: getting lock for resource {id_!r} under namespace {namespace!r}") - - # Get the lock for the ID. Create a lock if one doesn't exist yet. - locks = __lock_dicts[namespace] - lock = locks.setdefault(id_, LockGuard()) - - if not lock.locked(): - log.debug(f"{name}: resource {namespace!r}:{id_!r} is free; acquiring it...") - with lock: - return await func(*args, **kwargs) - else: - log.info(f"{name}: aborted because resource {namespace!r}:{id_!r} is locked") - if raise_error: - raise LockedResourceError(str(namespace), id_) - - return wrapper - return decorator - - -def lock_arg( - namespace: t.Hashable, - name_or_pos: function.Argument, - func: t.Callable[[t.Any], _IdCallableReturn] = None, - *, - raise_error: bool = False, -) -> t.Callable: - """ - Apply the `lock` decorator using the value of the arg at the given name/position as the ID. - - `func` is an optional callable or awaitable which will return the ID given the argument value. - See `lock` docs for more information. - """ - decorator_func = partial(lock, namespace, raise_error=raise_error) - return function.get_arg_value_wrapper(decorator_func, name_or_pos, func) - - def redirect_output(destination_channel: int, bypass_roles: t.Container[int] = None) -> t.Callable: """ Changes the channel in the context of the command to redirect the output to a certain channel. diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 0dd9605e8..b73410e96 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -2,10 +2,9 @@ from abc import ABCMeta from discord.ext.commands import CogMeta -from bot.utils.lock import LockGuard from bot.utils.redis_cache import RedisCache -__all__ = ["CogABCMeta", "LockGuard", "RedisCache"] +__all__ = ["CogABCMeta", "RedisCache"] class CogABCMeta(CogMeta, ABCMeta): diff --git a/bot/utils/lock.py b/bot/utils/lock.py index 8f1b738aa..5c9dd3725 100644 --- a/bot/utils/lock.py +++ b/bot/utils/lock.py @@ -1,3 +1,21 @@ +import inspect +import logging +from collections import defaultdict +from functools import partial, wraps +from typing import Any, Awaitable, Callable, Hashable, Union +from weakref import WeakValueDictionary + +from bot.errors import LockedResourceError +from bot.utils import function + +log = logging.getLogger(__name__) +__lock_dicts = defaultdict(WeakValueDictionary) + +_IdCallableReturn = Union[Hashable, Awaitable[Hashable]] +_IdCallable = Callable[[function.BoundArgs], _IdCallableReturn] +ResourceId = Union[Hashable, _IdCallable] + + class LockGuard: """ A context manager which acquires and releases a lock (mutex). @@ -21,3 +39,75 @@ class LockGuard: def __exit__(self, _exc_type, _exc_value, _traceback): # noqa: ANN001 self._locked = False return False # Indicate any raised exception shouldn't be suppressed. + + +def lock(namespace: Hashable, resource_id: ResourceId, *, raise_error: bool = False) -> Callable: + """ + Turn the decorated coroutine function into a mutually exclusive operation on a `resource_id`. + + If any other mutually exclusive function currently holds the lock for a resource, do not run the + decorated function and return None. If `raise_error` is True, raise `LockedResourceError` if + the lock cannot be acquired. + + `namespace` is an identifier used to prevent collisions among resource IDs. + + `resource_id` identifies a resource on which to perform a mutually exclusive operation. + It may also be a callable or awaitable which will return the resource ID given an ordered + mapping of the parameters' names to arguments' values. + + If decorating a command, this decorator must go before (below) the `command` decorator. + """ + def decorator(func: Callable) -> Callable: + name = func.__name__ + + @wraps(func) + async def wrapper(*args, **kwargs) -> Any: + log.trace(f"{name}: mutually exclusive decorator called") + + if callable(resource_id): + log.trace(f"{name}: binding args to signature") + bound_args = function.get_bound_args(func, args, kwargs) + + log.trace(f"{name}: calling the given callable to get the resource ID") + id_ = resource_id(bound_args) + + if inspect.isawaitable(id_): + log.trace(f"{name}: awaiting to get resource ID") + id_ = await id_ + else: + id_ = resource_id + + log.trace(f"{name}: getting lock for resource {id_!r} under namespace {namespace!r}") + + # Get the lock for the ID. Create a lock if one doesn't exist yet. + locks = __lock_dicts[namespace] + lock = locks.setdefault(id_, LockGuard()) + + if not lock.locked(): + log.debug(f"{name}: resource {namespace!r}:{id_!r} is free; acquiring it...") + with lock: + return await func(*args, **kwargs) + else: + log.info(f"{name}: aborted because resource {namespace!r}:{id_!r} is locked") + if raise_error: + raise LockedResourceError(str(namespace), id_) + + return wrapper + return decorator + + +def lock_arg( + namespace: Hashable, + name_or_pos: function.Argument, + func: Callable[[Any], _IdCallableReturn] = None, + *, + raise_error: bool = False, +) -> Callable: + """ + Apply the `lock` decorator using the value of the arg at the given name/position as the ID. + + `func` is an optional callable or awaitable which will return the ID given the argument value. + See `lock` docs for more information. + """ + decorator_func = partial(lock, namespace, raise_error=raise_error) + return function.get_arg_value_wrapper(decorator_func, name_or_pos, func) -- cgit v1.2.3 From fe08fd275d492637abc78071efe9441e7526e588 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 9 Sep 2020 17:31:21 -0700 Subject: Fix attribute docstring for LockedResourceError --- bot/errors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/errors.py b/bot/errors.py index 34de3c2b1..65d715203 100644 --- a/bot/errors.py +++ b/bot/errors.py @@ -7,7 +7,7 @@ class LockedResourceError(RuntimeError): Attributes: `type` -- name of the locked resource's type - `resource_id` -- ID of the locked resource + `id` -- ID of the locked resource """ def __init__(self, resource_type: str, resource_id: Hashable): -- cgit v1.2.3 From c90e50a81b1db63c12ef36af58d4cc04d035db2f Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 29 Sep 2020 10:36:48 +0200 Subject: Deps: bump 'discord.py' to 1.5 & re-lock This also removes a duplicate 'discord' entry from the lockfile. --- Pipfile | 2 +- Pipfile.lock | 25 ++++++++----------------- 2 files changed, 9 insertions(+), 18 deletions(-) diff --git a/Pipfile b/Pipfile index e6f84d911..99fc70b46 100644 --- a/Pipfile +++ b/Pipfile @@ -14,7 +14,7 @@ beautifulsoup4 = "~=4.9" colorama = {version = "~=0.4.3",sys_platform = "== 'win32'"} coloredlogs = "~=14.0" deepdiff = "~=4.0" -discord.py = "~=1.4.0" +"discord.py" = "~=1.5.0" feedparser = "~=5.2" fuzzywuzzy = "~=0.17" lxml = "~=4.4" diff --git a/Pipfile.lock b/Pipfile.lock index 4c63277de..becd85c55 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "644012a1c3fa3e3a30f8b8f8e672c468dfaa155d9e43d26e2be8713c8dc5ebb3" + "sha256": "073fd0c51749aafa188fdbe96c5b90dd157cb1d23bdd144801fb0d0a369ffa88" }, "pipfile-spec": 6, "requires": { @@ -18,11 +18,11 @@ "default": { "aio-pika": { "hashes": [ - "sha256:4a20d4d941e1f113a950ea529a90bd9159c8d7aafaa1c71e9c707c8c2b526ea6", - "sha256:7bf3f183df1eb348d007210a0c1a3c5c755f1b3def1a9a395e93f30b91da1daf" + "sha256:9773440a89840941ac3099a7720bf9d51e8764a484066b82ede4d395660ff430", + "sha256:a8065be3c722eb8f9fff8c0e7590729e7782202cdb9363d9830d7d5d47b45c7c" ], "index": "pypi", - "version": "==6.7.0" + "version": "==6.7.1" }, "aiodns": { "hashes": [ @@ -205,22 +205,13 @@ "index": "pypi", "version": "==4.3.2" }, - "discord": { - "hashes": [ - "sha256:9d4debb4a37845543bd4b92cb195bc53a302797333e768e70344222857ff1559", - "sha256:ff6653655e342e7721dfb3f10421345fd852c2a33f2cca912b1c39b3778a9429" - ], - "index": "pypi", - "py": "~=1.4.0", - "version": "==1.0.1" - }, "discord.py": { "hashes": [ - "sha256:98ea3096a3585c9c379209926f530808f5fcf4930928d8cfb579d2562d119570", - "sha256:f9decb3bfa94613d922376288617e6a6f969260923643e2897f4540c34793442" + "sha256:3acb61fde0d862ed346a191d69c46021e6063673f63963bc984ae09a685ab211", + "sha256:e71089886aa157341644bdecad63a72ff56b44406b1a6467b66db31c8e5a5a15" ], - "markers": "python_full_version >= '3.5.3'", - "version": "==1.4.1" + "index": "pypi", + "version": "==1.5.0" }, "docutils": { "hashes": [ -- cgit v1.2.3 From 7e9283260104999973301fe09859c75b87e62514 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Wed, 30 Sep 2020 20:17:22 +0200 Subject: Add intents setup to the bot --- bot/__main__.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/bot/__main__.py b/bot/__main__.py index a07bc21d6..009f0ff27 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -47,6 +47,13 @@ loop.run_until_complete(redis_session.connect()) # Instantiate the bot. allowed_roles = [discord.Object(id_) for id_ in constants.MODERATION_ROLES] +intents = discord.Intents().all() +intents.presences = False +intents.dm_typing = False +intents.dm_reactions = False +intents.invites = False +intents.webhooks = False +intents.integrations = False bot = Bot( redis_session=redis_session, loop=loop, @@ -54,7 +61,8 @@ bot = Bot( activity=discord.Game(name="Commands: !help"), case_insensitive=True, max_messages=10_000, - allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles) + allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles), + intents=intents ) # Load extensions. -- cgit v1.2.3 From 85e31b8d933900dde221d158cc27b08b923d53b3 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Wed, 30 Sep 2020 20:21:10 +0200 Subject: Remove Custom Status and Status from `create_user_embed` --- bot/exts/info/information.py | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index f6ed176f1..c9739dccd 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -211,25 +211,6 @@ class Information(Cog): """Creates an embed containing information on the `user`.""" created = time_since(user.created_at, max_units=3) - # Custom status - custom_status = '' - for activity in user.activities: - if isinstance(activity, CustomActivity): - state = "" - - if activity.name: - state = escape_markdown(activity.name) - - emoji = "" - if activity.emoji: - # If an emoji is unicode use the emoji, else write the emote like :abc: - if not activity.emoji.id: - emoji += activity.emoji.name + " " - else: - emoji += f"`:{activity.emoji.name}:` " - - custom_status = f'Status: {emoji}{state}\n' - name = str(user) if user.nick: name = f"{user.nick} ({name})" @@ -243,10 +224,6 @@ class Information(Cog): joined = time_since(user.joined_at, max_units=3) roles = ", ".join(role.mention for role in user.roles[1:]) - desktop_status = STATUS_EMOTES.get(user.desktop_status, constants.Emojis.status_online) - web_status = STATUS_EMOTES.get(user.web_status, constants.Emojis.status_online) - mobile_status = STATUS_EMOTES.get(user.mobile_status, constants.Emojis.status_online) - fields = [ ( "User information", @@ -254,7 +231,6 @@ class Information(Cog): Created: {created} Profile: {user.mention} ID: {user.id} - {custom_status} """).strip() ), ( @@ -264,14 +240,6 @@ class Information(Cog): Roles: {roles or None} """).strip() ), - ( - "Status", - textwrap.dedent(f""" - {desktop_status} Desktop - {web_status} Web - {mobile_status} Mobile - """).strip() - ) ] # Use getattr to future-proof for commands invoked via DMs. -- cgit v1.2.3 From d2fe88adb94c9dc3d84ff560c5246a023c72d9a8 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Wed, 30 Sep 2020 20:27:39 +0200 Subject: update member status info in `server` command --- bot/exts/info/information.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index c9739dccd..a50433c33 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -6,10 +6,9 @@ from collections import Counter, defaultdict from string import Template from typing import Any, Mapping, Optional, Tuple, Union -from discord import ChannelType, Colour, CustomActivity, Embed, Guild, Member, Message, Role, Status, utils +from discord import ChannelType, Colour, Embed, Guild, Member, Message, Role, Status, utils from discord.abc import GuildChannel from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group, has_any_role -from discord.utils import escape_markdown from bot import constants from bot.bot import Bot @@ -153,7 +152,9 @@ class Information(Cog): channel_counts = self.get_channel_type_counts(ctx.guild) # How many of each user status? - statuses = Counter(member.status for member in ctx.guild.members) + py_invite = await self.bot.fetch_invite("python") + online_presences = py_invite.approximate_presence_count + offline_presences = ctx.guild.member_count - online_presences embed = Embed(colour=Colour.blurple()) # How many staff members and staff channels do we have? @@ -181,10 +182,8 @@ class Information(Cog): Roles: {roles} **Member statuses** - {constants.Emojis.status_online} {statuses[Status.online]:,} - {constants.Emojis.status_idle} {statuses[Status.idle]:,} - {constants.Emojis.status_dnd} {statuses[Status.dnd]:,} - {constants.Emojis.status_offline} {statuses[Status.offline]:,} + {constants.Emojis.status_online} {online_presences:,} + {constants.Emojis.status_offline} {offline_presences:,} """) ).substitute({"channel_counts": channel_counts}) embed.set_thumbnail(url=ctx.guild.icon_url) -- cgit v1.2.3 From f791bc32adceeb765638fd8cf2c849e6f642b345 Mon Sep 17 00:00:00 2001 From: Hedy Li Date: Thu, 1 Oct 2020 17:04:09 +0800 Subject: fix spelling typos in bot/ python files --- bot/exts/help_channels.py | 2 +- bot/exts/info/help.py | 2 +- bot/exts/info/information.py | 2 +- bot/exts/utils/bot.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/exts/help_channels.py b/bot/exts/help_channels.py index 9e33a6aba..f5c9a5dd0 100644 --- a/bot/exts/help_channels.py +++ b/bot/exts/help_channels.py @@ -494,7 +494,7 @@ class HelpChannels(commands.Cog): If `options` are provided, the channel will be edited after the move is completed. This is the same order of operations that `discord.TextChannel.edit` uses. For information on available - options, see the documention on `discord.TextChannel.edit`. While possible, position-related + options, see the documentation on `discord.TextChannel.edit`. While possible, position-related options should be avoided, as it may interfere with the category move we perform. """ # Get a fresh copy of the category from the bot to avoid the cache mismatch issue we had. diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py index 99d503f5c..599c5d5c0 100644 --- a/bot/exts/info/help.py +++ b/bot/exts/info/help.py @@ -229,7 +229,7 @@ class CustomHelpCommand(HelpCommand): async def send_cog_help(self, cog: Cog) -> None: """Send help for a cog.""" - # sort commands by name, and remove any the user cant run or are hidden. + # sort commands by name, and remove any the user can't run or are hidden. commands_ = await self.filter_commands(cog.get_commands(), sort=True) embed = Embed() diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index f6ed176f1..719f43b14 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -161,7 +161,7 @@ class Information(Cog): staff_channel_count = self.get_staff_channel_count(ctx.guild) # Because channel_counts lacks leading whitespace, it breaks the dedent if it's inserted directly by the - # f-string. While this is correctly formated by Discord, it makes unit testing difficult. To keep the formatting + # f-string. While this is correctly formatted by Discord, it makes unit testing difficult. To keep the formatting # without joining a tuple of strings we can use a Template string to insert the already-formatted channel_counts # after the dedent is made. embed.description = Template( diff --git a/bot/exts/utils/bot.py b/bot/exts/utils/bot.py index 7ed487d47..ba1fd2a5c 100644 --- a/bot/exts/utils/bot.py +++ b/bot/exts/utils/bot.py @@ -130,7 +130,7 @@ class BotCog(Cog, name="Bot"): else: content = "".join(content[1:]) - # Strip it again to remove any leading whitespace. This is neccessary + # Strip it again to remove any leading whitespace. This is necessary # if the first line of the message looked like ```python old = content.strip() -- cgit v1.2.3 From bb423b8105be2b9b5b843ee2661c4ff18be741e0 Mon Sep 17 00:00:00 2001 From: Hedy Li Date: Fri, 2 Oct 2020 06:05:01 +0000 Subject: fix line length in bot/exts/info/information.py --- bot/exts/info/information.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 719f43b14..52239c19e 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -161,9 +161,9 @@ class Information(Cog): staff_channel_count = self.get_staff_channel_count(ctx.guild) # Because channel_counts lacks leading whitespace, it breaks the dedent if it's inserted directly by the - # f-string. While this is correctly formatted by Discord, it makes unit testing difficult. To keep the formatting - # without joining a tuple of strings we can use a Template string to insert the already-formatted channel_counts - # after the dedent is made. + # f-string. While this is correctly formatted by Discord, it makes unit testing difficult. To keep the + # formatting without joining a tuple of strings we can use a Template string to insert the already-formatted + # channel_counts after the dedent is made. embed.description = Template( textwrap.dedent(f""" **Server information** -- cgit v1.2.3 From 4e695bc1c8ea48056e4fe155fca2c518c50277a9 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Fri, 2 Oct 2020 08:27:15 +0200 Subject: Remove failing unit tests Testing `information` cog seems redutant as it is not too important part of the bot. --- tests/bot/exts/info/test_information.py | 78 --------------------------------- 1 file changed, 78 deletions(-) diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index d3f2995fb..83fc6d188 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -97,79 +97,6 @@ class InformationCogTests(unittest.TestCase): self.assertEqual(admin_embed.title, "Admins info") self.assertEqual(admin_embed.colour, discord.Colour.red()) - @unittest.mock.patch('bot.exts.info.information.time_since') - def test_server_info_command(self, time_since_patch): - time_since_patch.return_value = '2 days ago' - - self.ctx.guild = helpers.MockGuild( - features=('lemons', 'apples'), - region="The Moon", - roles=[self.moderator_role], - channels=[ - discord.TextChannel( - state={}, - guild=self.ctx.guild, - data={'id': 42, 'name': 'lemons-offering', 'position': 22, 'type': 'text'} - ), - discord.CategoryChannel( - state={}, - guild=self.ctx.guild, - data={'id': 5125, 'name': 'the-lemon-collection', 'position': 22, 'type': 'category'} - ), - discord.VoiceChannel( - state={}, - guild=self.ctx.guild, - data={'id': 15290, 'name': 'listen-to-lemon', 'position': 22, 'type': 'voice'} - ) - ], - members=[ - *(helpers.MockMember(status=discord.Status.online) for _ in range(2)), - *(helpers.MockMember(status=discord.Status.idle) for _ in range(1)), - *(helpers.MockMember(status=discord.Status.dnd) for _ in range(4)), - *(helpers.MockMember(status=discord.Status.offline) for _ in range(3)), - ], - member_count=1_234, - icon_url='a-lemon.jpg', - ) - - coroutine = self.cog.server_info.callback(self.cog, self.ctx) - self.assertIsNone(asyncio.run(coroutine)) - - time_since_patch.assert_called_once_with(self.ctx.guild.created_at, precision='days') - _, kwargs = self.ctx.send.call_args - embed = kwargs.pop('embed') - self.assertEqual(embed.colour, discord.Colour.blurple()) - self.assertEqual( - embed.description, - textwrap.dedent( - f""" - **Server information** - Created: {time_since_patch.return_value} - Voice region: {self.ctx.guild.region} - Features: {', '.join(self.ctx.guild.features)} - - **Channel counts** - Category channels: 1 - Text channels: 1 - Voice channels: 1 - Staff channels: 0 - - **Member counts** - Members: {self.ctx.guild.member_count:,} - Staff members: 0 - Roles: {len(self.ctx.guild.roles)} - - **Member statuses** - {constants.Emojis.status_online} 2 - {constants.Emojis.status_idle} 1 - {constants.Emojis.status_dnd} 4 - {constants.Emojis.status_offline} 3 - """ - ) - ) - self.assertEqual(embed.thumbnail.url, 'a-lemon.jpg') - - class UserInfractionHelperMethodTests(unittest.TestCase): """Tests for the helper methods of the `!user` command.""" @@ -465,11 +392,6 @@ class UserEmbedTests(unittest.TestCase): embed.fields[1].value ) - self.assertEqual( - "basic infractions info", - embed.fields[3].value - ) - @unittest.mock.patch( f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) -- cgit v1.2.3 From 93ce90d28e7a8314dbbc34600ab5b1bc89476b4b Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Fri, 2 Oct 2020 08:27:34 +0200 Subject: Remove presence stat tracking. --- bot/exts/info/stats.py | 37 ++----------------------------------- 1 file changed, 2 insertions(+), 35 deletions(-) diff --git a/bot/exts/info/stats.py b/bot/exts/info/stats.py index d42f55466..21aa91873 100644 --- a/bot/exts/info/stats.py +++ b/bot/exts/info/stats.py @@ -1,12 +1,11 @@ import string -from datetime import datetime -from discord import Member, Message, Status +from discord import Member, Message from discord.ext.commands import Cog, Context from discord.ext.tasks import loop from bot.bot import Bot -from bot.constants import Categories, Channels, Guild, Stats as StatConf +from bot.constants import Categories, Channels, Guild CHANNEL_NAME_OVERRIDES = { @@ -79,38 +78,6 @@ class Stats(Cog): self.bot.stats.gauge("guild.total_members", len(member.guild.members)) - @Cog.listener() - async def on_member_update(self, _before: Member, after: Member) -> None: - """Update presence estimates on member update.""" - if after.guild.id != Guild.id: - return - - if self.last_presence_update: - if (datetime.now() - self.last_presence_update).seconds < StatConf.presence_update_timeout: - return - - self.last_presence_update = datetime.now() - - online = 0 - idle = 0 - dnd = 0 - offline = 0 - - for member in after.guild.members: - if member.status is Status.online: - online += 1 - elif member.status is Status.dnd: - dnd += 1 - elif member.status is Status.idle: - idle += 1 - elif member.status is Status.offline: - offline += 1 - - self.bot.stats.gauge("guild.status.online", online) - self.bot.stats.gauge("guild.status.idle", idle) - self.bot.stats.gauge("guild.status.do_not_disturb", dnd) - self.bot.stats.gauge("guild.status.offline", offline) - @loop(hours=1) async def update_guild_boost(self) -> None: """Post the server boost level and tier every hour.""" -- cgit v1.2.3 From b87f3163ab05556c82cfe3d826aded68efa5ade4 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Fri, 2 Oct 2020 08:35:04 +0200 Subject: Add missing blank line to satisfy the linting gods --- tests/bot/exts/info/test_information.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index 83fc6d188..23eeb88cd 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -97,6 +97,7 @@ class InformationCogTests(unittest.TestCase): self.assertEqual(admin_embed.title, "Admins info") self.assertEqual(admin_embed.colour, discord.Colour.red()) + class UserInfractionHelperMethodTests(unittest.TestCase): """Tests for the helper methods of the `!user` command.""" -- cgit v1.2.3 From 10a65fee8b843990a87ab468c924e9f6cd4493d1 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Fri, 2 Oct 2020 16:38:26 +0200 Subject: Reminder: no feedback message when no mention --- bot/exts/utils/reminders.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 6806f2889..6fdb0b8ea 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -286,10 +286,11 @@ class Reminders(Cog): now = datetime.utcnow() - timedelta(seconds=1) humanized_delta = humanize_delta(relativedelta(expiration, now)) - mention_string = ( - f"Your reminder will arrive in {humanized_delta} " - f"and will mention {len(mentions)} other(s)!" - ) + mention_string = f"Your reminder will arrive in {humanized_delta}" + + if mentions: + mention_string += f" and will mention {len(mentions)} other(s)" + mention_string += "!" # Confirm to the user that it worked. await self._send_confirmation( -- cgit v1.2.3 From 925219dec3ba199718ac0504cfc7f8b3e6917a1f Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sat, 3 Oct 2020 11:36:19 +0100 Subject: Add a socket stats command --- bot/exts/utils/eval.py | 226 --------------------------------------- bot/exts/utils/internal.py | 258 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 258 insertions(+), 226 deletions(-) delete mode 100644 bot/exts/utils/eval.py create mode 100644 bot/exts/utils/internal.py diff --git a/bot/exts/utils/eval.py b/bot/exts/utils/eval.py deleted file mode 100644 index 6419b320e..000000000 --- a/bot/exts/utils/eval.py +++ /dev/null @@ -1,226 +0,0 @@ -import contextlib -import inspect -import logging -import pprint -import re -import textwrap -import traceback -from io import StringIO -from typing import Any, Optional, Tuple - -import discord -from discord.ext.commands import Cog, Context, group, has_any_role - -from bot.bot import Bot -from bot.constants import Roles -from bot.interpreter import Interpreter -from bot.utils import find_nth_occurrence, send_to_paste_service - -log = logging.getLogger(__name__) - - -class CodeEval(Cog): - """Owner and admin feature that evaluates code and returns the result to the channel.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.env = {} - self.ln = 0 - self.stdout = StringIO() - - self.interpreter = Interpreter(bot) - - def _format(self, inp: str, out: Any) -> Tuple[str, Optional[discord.Embed]]: - """Format the eval output into a string & attempt to format it into an Embed.""" - self._ = out - - res = "" - - # Erase temp input we made - if inp.startswith("_ = "): - inp = inp[4:] - - # Get all non-empty lines - lines = [line for line in inp.split("\n") if line.strip()] - if len(lines) != 1: - lines += [""] - - # Create the input dialog - for i, line in enumerate(lines): - if i == 0: - # Start dialog - start = f"In [{self.ln}]: " - - else: - # Indent the 3 dots correctly; - # Normally, it's something like - # In [X]: - # ...: - # - # But if it's - # In [XX]: - # ...: - # - # You can see it doesn't look right. - # This code simply indents the dots - # far enough to align them. - # we first `str()` the line number - # then we get the length - # and use `str.rjust()` - # to indent it. - start = "...: ".rjust(len(str(self.ln)) + 7) - - if i == len(lines) - 2: - if line.startswith("return"): - line = line[6:].strip() - - # Combine everything - res += (start + line + "\n") - - self.stdout.seek(0) - text = self.stdout.read() - self.stdout.close() - self.stdout = StringIO() - - if text: - res += (text + "\n") - - if out is None: - # No output, return the input statement - return (res, None) - - res += f"Out[{self.ln}]: " - - if isinstance(out, discord.Embed): - # We made an embed? Send that as embed - res += "" - res = (res, out) - - else: - if (isinstance(out, str) and out.startswith("Traceback (most recent call last):\n")): - # Leave out the traceback message - out = "\n" + "\n".join(out.split("\n")[1:]) - - if isinstance(out, str): - pretty = out - else: - pretty = pprint.pformat(out, compact=True, width=60) - - if pretty != str(out): - # We're using the pretty version, start on the next line - res += "\n" - - if pretty.count("\n") > 20: - # Text too long, shorten - li = pretty.split("\n") - - pretty = ("\n".join(li[:3]) # First 3 lines - + "\n ...\n" # Ellipsis to indicate removed lines - + "\n".join(li[-3:])) # last 3 lines - - # Add the output - res += pretty - res = (res, None) - - return res # Return (text, embed) - - async def _eval(self, ctx: Context, code: str) -> Optional[discord.Message]: - """Eval the input code string & send an embed to the invoking context.""" - self.ln += 1 - - if code.startswith("exit"): - self.ln = 0 - self.env = {} - return await ctx.send("```Reset history!```") - - env = { - "message": ctx.message, - "author": ctx.message.author, - "channel": ctx.channel, - "guild": ctx.guild, - "ctx": ctx, - "self": self, - "bot": self.bot, - "inspect": inspect, - "discord": discord, - "contextlib": contextlib - } - - self.env.update(env) - - # Ignore this code, it works - code_ = """ -async def func(): # (None,) -> Any - try: - with contextlib.redirect_stdout(self.stdout): -{0} - if '_' in locals(): - if inspect.isawaitable(_): - _ = await _ - return _ - finally: - self.env.update(locals()) -""".format(textwrap.indent(code, ' ')) - - try: - exec(code_, self.env) # noqa: B102,S102 - func = self.env['func'] - res = await func() - - except Exception: - res = traceback.format_exc() - - out, embed = self._format(code, res) - out = out.rstrip("\n") # Strip empty lines from output - - # Truncate output to max 15 lines or 1500 characters - newline_truncate_index = find_nth_occurrence(out, "\n", 15) - - if newline_truncate_index is None or newline_truncate_index > 1500: - truncate_index = 1500 - else: - truncate_index = newline_truncate_index - - if len(out) > truncate_index: - paste_link = await send_to_paste_service(self.bot.http_session, out, extension="py") - if paste_link is not None: - paste_text = f"full contents at {paste_link}" - else: - paste_text = "failed to upload contents to paste service." - - await ctx.send( - f"```py\n{out[:truncate_index]}\n```" - f"... response truncated; {paste_text}", - embed=embed - ) - return - - await ctx.send(f"```py\n{out}```", embed=embed) - - @group(name='internal', aliases=('int',)) - @has_any_role(Roles.owners, Roles.admins) - async def internal_group(self, ctx: Context) -> None: - """Internal commands. Top secret!""" - if not ctx.invoked_subcommand: - await ctx.send_help(ctx.command) - - @internal_group.command(name='eval', aliases=('e',)) - @has_any_role(Roles.admins, Roles.owners) - async def eval(self, ctx: Context, *, code: str) -> None: - """Run eval in a REPL-like format.""" - code = code.strip("`") - if re.match('py(thon)?\n', code): - code = "\n".join(code.split("\n")[1:]) - - if not re.search( # Check if it's an expression - r"^(return|import|for|while|def|class|" - r"from|exit|[a-zA-Z0-9]+\s*=)", code, re.M) and len( - code.split("\n")) == 1: - code = "_ = " + code - - await self._eval(ctx, code) - - -def setup(bot: Bot) -> None: - """Load the CodeEval cog.""" - bot.add_cog(CodeEval(bot)) diff --git a/bot/exts/utils/internal.py b/bot/exts/utils/internal.py new file mode 100644 index 000000000..d61200575 --- /dev/null +++ b/bot/exts/utils/internal.py @@ -0,0 +1,258 @@ +import contextlib +import inspect +import logging +import pprint +import re +import textwrap +import traceback +from collections import Counter +from datetime import datetime +from io import StringIO +from typing import Any, Optional, Tuple + +import discord +from discord.ext.commands import Cog, Context, group, has_any_role + +from bot.bot import Bot +from bot.constants import Roles +from bot.interpreter import Interpreter +from bot.utils import find_nth_occurrence, send_to_paste_service + +log = logging.getLogger(__name__) + + +class Internal(Cog): + """Administrator and Core Developer commands.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.env = {} + self.ln = 0 + self.stdout = StringIO() + + self.interpreter = Interpreter(bot) + + self.socket_since = datetime.utcnow() + self.socket_event_total = 0 + self.socket_events = Counter() + + @Cog.listener() + async def on_socket_response(self, msg: dict) -> None: + """When a websocket event is received, increase our counters.""" + if event_type := msg.get("t"): + self.socket_event_total += 1 + self.socket_events[event_type] += 1 + + def _format(self, inp: str, out: Any) -> Tuple[str, Optional[discord.Embed]]: + """Format the eval output into a string & attempt to format it into an Embed.""" + self._ = out + + res = "" + + # Erase temp input we made + if inp.startswith("_ = "): + inp = inp[4:] + + # Get all non-empty lines + lines = [line for line in inp.split("\n") if line.strip()] + if len(lines) != 1: + lines += [""] + + # Create the input dialog + for i, line in enumerate(lines): + if i == 0: + # Start dialog + start = f"In [{self.ln}]: " + + else: + # Indent the 3 dots correctly; + # Normally, it's something like + # In [X]: + # ...: + # + # But if it's + # In [XX]: + # ...: + # + # You can see it doesn't look right. + # This code simply indents the dots + # far enough to align them. + # we first `str()` the line number + # then we get the length + # and use `str.rjust()` + # to indent it. + start = "...: ".rjust(len(str(self.ln)) + 7) + + if i == len(lines) - 2: + if line.startswith("return"): + line = line[6:].strip() + + # Combine everything + res += (start + line + "\n") + + self.stdout.seek(0) + text = self.stdout.read() + self.stdout.close() + self.stdout = StringIO() + + if text: + res += (text + "\n") + + if out is None: + # No output, return the input statement + return (res, None) + + res += f"Out[{self.ln}]: " + + if isinstance(out, discord.Embed): + # We made an embed? Send that as embed + res += "" + res = (res, out) + + else: + if (isinstance(out, str) and out.startswith("Traceback (most recent call last):\n")): + # Leave out the traceback message + out = "\n" + "\n".join(out.split("\n")[1:]) + + if isinstance(out, str): + pretty = out + else: + pretty = pprint.pformat(out, compact=True, width=60) + + if pretty != str(out): + # We're using the pretty version, start on the next line + res += "\n" + + if pretty.count("\n") > 20: + # Text too long, shorten + li = pretty.split("\n") + + pretty = ("\n".join(li[:3]) # First 3 lines + + "\n ...\n" # Ellipsis to indicate removed lines + + "\n".join(li[-3:])) # last 3 lines + + # Add the output + res += pretty + res = (res, None) + + return res # Return (text, embed) + + async def _eval(self, ctx: Context, code: str) -> Optional[discord.Message]: + """Eval the input code string & send an embed to the invoking context.""" + self.ln += 1 + + if code.startswith("exit"): + self.ln = 0 + self.env = {} + return await ctx.send("```Reset history!```") + + env = { + "message": ctx.message, + "author": ctx.message.author, + "channel": ctx.channel, + "guild": ctx.guild, + "ctx": ctx, + "self": self, + "bot": self.bot, + "inspect": inspect, + "discord": discord, + "contextlib": contextlib + } + + self.env.update(env) + + # Ignore this code, it works + code_ = """ +async def func(): # (None,) -> Any + try: + with contextlib.redirect_stdout(self.stdout): +{0} + if '_' in locals(): + if inspect.isawaitable(_): + _ = await _ + return _ + finally: + self.env.update(locals()) +""".format(textwrap.indent(code, ' ')) + + try: + exec(code_, self.env) # noqa: B102,S102 + func = self.env['func'] + res = await func() + + except Exception: + res = traceback.format_exc() + + out, embed = self._format(code, res) + out = out.rstrip("\n") # Strip empty lines from output + + # Truncate output to max 15 lines or 1500 characters + newline_truncate_index = find_nth_occurrence(out, "\n", 15) + + if newline_truncate_index is None or newline_truncate_index > 1500: + truncate_index = 1500 + else: + truncate_index = newline_truncate_index + + if len(out) > truncate_index: + paste_link = await send_to_paste_service(self.bot.http_session, out, extension="py") + if paste_link is not None: + paste_text = f"full contents at {paste_link}" + else: + paste_text = "failed to upload contents to paste service." + + await ctx.send( + f"```py\n{out[:truncate_index]}\n```" + f"... response truncated; {paste_text}", + embed=embed + ) + return + + await ctx.send(f"```py\n{out}```", embed=embed) + + @group(name='internal', aliases=('int',)) + @has_any_role(Roles.owners, Roles.admins, Roles.core_developers) + async def internal_group(self, ctx: Context) -> None: + """Internal commands. Top secret!""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @internal_group.command(name='eval', aliases=('e',)) + @has_any_role(Roles.admins, Roles.owners) + async def eval(self, ctx: Context, *, code: str) -> None: + """Run eval in a REPL-like format.""" + code = code.strip("`") + if re.match('py(thon)?\n', code): + code = "\n".join(code.split("\n")[1:]) + + if not re.search( # Check if it's an expression + r"^(return|import|for|while|def|class|" + r"from|exit|[a-zA-Z0-9]+\s*=)", code, re.M) and len( + code.split("\n")) == 1: + code = "_ = " + code + + await self._eval(ctx, code) + + @internal_group.command(name='socketstats', aliases=('socket', 'stats')) + @has_any_role(Roles.admins, Roles.owners, Roles.core_developers) + async def socketstats(self, ctx: Context) -> None: + """Fetch information on the socket events received from Discord.""" + running_s = (datetime.utcnow() - self.socket_since).total_seconds() + + per_s = self.socket_event_total / running_s + + stats_embed = discord.Embed( + title="WebSocket statistics", + description=f"Receiving {per_s:0.2f} event per second.", + color=discord.Color.blurple() + ) + + for event_type, count in self.socket_events.most_common(): + stats_embed.add_field(name=event_type, value=count, inline=False) + + await ctx.send(embed=stats_embed) + + +def setup(bot: Bot) -> None: + """Load the Internal cog.""" + bot.add_cog(Internal(bot)) -- cgit v1.2.3 From 58072451a02a59672dd186358e164ea580e8050f Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sat, 3 Oct 2020 11:48:37 +0100 Subject: Cap most_common to 25 to not go over the embed fields limit --- bot/exts/utils/internal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/internal.py b/bot/exts/utils/internal.py index d61200575..1b4900f42 100644 --- a/bot/exts/utils/internal.py +++ b/bot/exts/utils/internal.py @@ -247,7 +247,7 @@ async def func(): # (None,) -> Any color=discord.Color.blurple() ) - for event_type, count in self.socket_events.most_common(): + for event_type, count in self.socket_events.most_common(25): stats_embed.add_field(name=event_type, value=count, inline=False) await ctx.send(embed=stats_embed) -- cgit v1.2.3 From 764f35fa9c54d651625aad813e2e32a0a6e6d2d6 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Sat, 3 Oct 2020 13:49:16 +0200 Subject: add missing test for `user` command --- tests/bot/exts/info/test_information.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index 23eeb88cd..4e391eb57 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -393,6 +393,11 @@ class UserEmbedTests(unittest.TestCase): embed.fields[1].value ) + self.assertEqual( + "basic infractions info", + embed.fields[2].value + ) + @unittest.mock.patch( f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) -- cgit v1.2.3 From 73a0291a4ab4b10eb9d5d4e78bc574ca25fc9c98 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 3 Oct 2020 10:06:43 -0700 Subject: Lock: rename variable to avoid shadowing --- bot/utils/lock.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/utils/lock.py b/bot/utils/lock.py index 5c9dd3725..510f41234 100644 --- a/bot/utils/lock.py +++ b/bot/utils/lock.py @@ -81,11 +81,11 @@ def lock(namespace: Hashable, resource_id: ResourceId, *, raise_error: bool = Fa # Get the lock for the ID. Create a lock if one doesn't exist yet. locks = __lock_dicts[namespace] - lock = locks.setdefault(id_, LockGuard()) + lock_guard = locks.setdefault(id_, LockGuard()) - if not lock.locked(): + if not lock_guard.locked(): log.debug(f"{name}: resource {namespace!r}:{id_!r} is free; acquiring it...") - with lock: + with lock_guard: return await func(*args, **kwargs) else: log.info(f"{name}: aborted because resource {namespace!r}:{id_!r} is locked") -- cgit v1.2.3 From c1c754a01b10a5c79d35c04431dd43855015ed20 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 3 Oct 2020 10:08:55 -0700 Subject: Lock: make LockGuard.locked a property --- bot/utils/lock.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/utils/lock.py b/bot/utils/lock.py index 510f41234..7aaafbc88 100644 --- a/bot/utils/lock.py +++ b/bot/utils/lock.py @@ -26,6 +26,7 @@ class LockGuard: def __init__(self): self._locked = False + @property def locked(self) -> bool: """Return True if currently locked or False if unlocked.""" return self._locked @@ -83,7 +84,7 @@ def lock(namespace: Hashable, resource_id: ResourceId, *, raise_error: bool = Fa locks = __lock_dicts[namespace] lock_guard = locks.setdefault(id_, LockGuard()) - if not lock_guard.locked(): + if not lock_guard.locked: log.debug(f"{name}: resource {namespace!r}:{id_!r} is free; acquiring it...") with lock_guard: return await func(*args, **kwargs) -- cgit v1.2.3 From a0f42eba424fe3d119f5af2632822b38b78b5bd2 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Sat, 3 Oct 2020 21:28:15 +0200 Subject: Add trailing comma to intents --- bot/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/__main__.py b/bot/__main__.py index 133c96302..da042a5ed 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -62,7 +62,7 @@ bot = Bot( case_insensitive=True, max_messages=10_000, allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles), - intents=intents + intents=intents, ) # Load extensions. -- cgit v1.2.3 From 397d29a2f51f2a02558998efe8777a9efa575a43 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Sat, 3 Oct 2020 21:28:57 +0200 Subject: Use invite for tracking offline presences instead of `ctx` --- bot/exts/info/information.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index ca9895d61..0f50138e7 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -152,9 +152,9 @@ class Information(Cog): channel_counts = self.get_channel_type_counts(ctx.guild) # How many of each user status? - py_invite = await self.bot.fetch_invite("python") + py_invite = await self.bot.fetch_invite(constants.Guild.invite) online_presences = py_invite.approximate_presence_count - offline_presences = ctx.guild.member_count - online_presences + offline_presences = py_invite.approximate_member_count - online_presences embed = Embed(colour=Colour.blurple()) # How many staff members and staff channels do we have? -- cgit v1.2.3