-
-
Notifications
You must be signed in to change notification settings - Fork 493
refactor: Command Auto Syncing #2990
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Icebluewolf
wants to merge
23
commits into
Pycord-Development:master
Choose a base branch
from
Icebluewolf:fix-auto-sync
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+1,494
−72
Open
Changes from 18 commits
Commits
Show all changes
23 commits
Select commit
Hold shift + click to select a range
804922b
fix: Initial Commit To Fix Command Auto Syncing
Icebluewolf ae4e171
fix: Apply Suggestions From Code Review
Icebluewolf a08ebef
refactor: Apply Suggestions From Code Review
Icebluewolf a5b3225
fix: Subcommand Checking
Icebluewolf d02ec72
refactor: Use Pipe Union
Icebluewolf 24dd6f7
feat: Support IntegrationTypes Defaults Via AppInfo
Icebluewolf 5723354
chore: Changelog
Icebluewolf 2bc087d
feat: Implement Tests For Command Comparison
Icebluewolf 93ff9c1
chore: Apply Minor Changes From Code Review
Icebluewolf 2bf4228
fix: Use Dummy Data In Place Of Fetching AppInfo
Icebluewolf 3fd30ed
fix: Only Checking First Sub Command
Icebluewolf 689d86f
feat(tests): Checks For Multiple Sub Commands
Icebluewolf 62a5900
Update tests/test_command_syncing.py
Paillat-dev 748a960
style(pre-commit): auto fixes from pre-commit.com hooks
pre-commit-ci[bot] 085d02f
fix: Wow that's messed up
Paillat-dev 345ba31
chore: Update Changelog Version
Icebluewolf 455a995
feat: Add Default Integration Types When None Are Given
Icebluewolf 11b3400
refactor: Use Enum For InteractionTypes
Icebluewolf af20adb
docs: Add To Changelog
Icebluewolf e93766b
style(pre-commit): auto fixes from pre-commit.com hooks
pre-commit-ci[bot] e532b5d
Merge branch 'master' into fix-auto-sync
Lulalaby 894d512
style(pre-commit): auto fixes from pre-commit.com hooks
pre-commit-ci[bot] 7637ea8
Merge branch 'master' into fix-auto-sync
Lulalaby File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -42,9 +42,13 @@ | |
| Generator, | ||
| Literal, | ||
| Mapping, | ||
| TypeAlias, | ||
| TypeVar, | ||
| ) | ||
|
|
||
| from typing_extensions import override | ||
|
|
||
| from discord import IntegrationTypesConfig | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not sure we wanna import from discord here |
||
| from .client import Client | ||
| from .cog import CogMixin | ||
| from .commands import ( | ||
|
|
@@ -87,6 +91,171 @@ | |
| _log = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| class DefaultComparison: | ||
|
Icebluewolf marked this conversation as resolved.
|
||
| """ | ||
| Comparison rule for when there are multiple default values that should be considered equivalent when comparing 2 objects. | ||
| Allows for a custom check to be passed for further control over equality. | ||
|
|
||
| Attributes | ||
| ---------- | ||
| defaults: :class:`tuple` | ||
| The values that should be considered equivalent to each other | ||
| callback: Callable[[Any, Any], bool] | ||
| A callable that will do additional comparison on the objects if neither are a default value. | ||
| Defaults to a `==` comparison. | ||
| It should accept the 2 objects as arguments and return True if they should be considered equivalent | ||
| and False otherwise. | ||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| defaults: tuple[Any, ...], | ||
| callback: Callable[[Any, Any], bool] = lambda x, y: x == y, | ||
| ): | ||
| self.defaults = defaults | ||
| self.callback = callback | ||
|
|
||
| def _check_defaults(self, local: Any, remote: Any) -> bool | None: | ||
| defaults = (local in self.defaults) + (remote in self.defaults) | ||
| if defaults == 2: | ||
| # Both are COMMAND_DEFAULTS, so they can be counted as the same | ||
| return True | ||
| elif defaults == 0: | ||
| # Neither are COMMAND_DEFAULTS so the callback has to be used | ||
| return None | ||
| else: | ||
| # Only one is a default, so the command must be out of sync | ||
| return False | ||
|
|
||
| def check(self, local: Any, remote: Any) -> bool: | ||
| """ | ||
| Compares the local and remote objects. | ||
|
|
||
| Returns | ||
| ------- | ||
| bool | ||
| True if local and remote are deemed to be equivalent. False otherwise. | ||
| """ | ||
| if (rtn := self._check_defaults(local, remote)) is not None: | ||
| return rtn | ||
| else: | ||
| return self.callback(local, remote) | ||
|
|
||
|
|
||
| class DefaultSetComparison(DefaultComparison): | ||
| @override | ||
| def check(self, local: Any, remote: Any) -> bool: | ||
| try: | ||
| local = set(local) | ||
| except TypeError: | ||
| pass | ||
| try: | ||
| remote = set(remote) | ||
| except TypeError: | ||
| pass | ||
| return super().check(local, remote) | ||
|
|
||
|
|
||
| NestedComparison: TypeAlias = dict[str, "NestedComparison | DefaultComparison"] | ||
|
|
||
|
|
||
| def _compare_defaults( | ||
| obj: Mapping[str, Any] | Any, | ||
| match: Mapping[str, Any] | Any, | ||
| schema: NestedComparison, | ||
| ) -> bool: | ||
| if not isinstance(match, Mapping) or not isinstance(obj, Mapping): | ||
| return obj == match | ||
| for field, comparison in schema.items(): | ||
| remote = match.get(field, MISSING) | ||
| local = obj.get(field, MISSING) | ||
| if isinstance(comparison, dict): | ||
| if not _compare_defaults(local, remote, comparison): | ||
| return False | ||
| elif isinstance(comparison, DefaultComparison): | ||
| if not comparison.check(local, remote): | ||
| return False | ||
| return True | ||
|
|
||
|
|
||
| OPTION_DEFAULT_VALUES = ([], MISSING) | ||
|
|
||
|
|
||
| def _option_comparison_check(local: Any, remote: Any) -> bool: | ||
| matching = (local in OPTION_DEFAULT_VALUES) + (remote in OPTION_DEFAULT_VALUES) | ||
| if matching == 2: | ||
| return True | ||
| elif matching == 1: | ||
| return False | ||
| else: | ||
| return len(local) == len(remote) and all( | ||
| [ | ||
| _compare_defaults(local[x], remote[x], COMMAND_OPTION_DEFAULTS) | ||
| for x in range(len(local)) | ||
| ] | ||
| ) | ||
|
|
||
|
|
||
| CHOICES_DEFAULT_VALUES = ([], MISSING) | ||
|
|
||
|
|
||
| def _choices_comparison_check(local: Any, remote: Any) -> bool: | ||
| matching = (local in CHOICES_DEFAULT_VALUES) + (remote in CHOICES_DEFAULT_VALUES) | ||
| if matching == 2: | ||
| return True | ||
| elif matching == 1: | ||
| return False | ||
| else: | ||
| return len(local) == len(remote) and all( | ||
| [ | ||
| _compare_defaults(local[x], remote[x], OPTIONS_CHOICES_DEFAULTS) | ||
| for x in range(len(local)) | ||
| ] | ||
| ) | ||
|
Paillat-dev marked this conversation as resolved.
|
||
|
|
||
|
|
||
| COMMAND_DEFAULTS: NestedComparison = { | ||
| "type": DefaultComparison((1, MISSING)), | ||
| "name": DefaultComparison(()), | ||
| "description": DefaultComparison((MISSING,)), | ||
| "name_localizations": DefaultComparison((None, {}, MISSING)), | ||
| "description_localizations": DefaultComparison((None, {}, MISSING)), | ||
| "options": DefaultComparison(OPTION_DEFAULT_VALUES, _option_comparison_check), | ||
| "default_member_permissions": DefaultComparison((None, MISSING)), | ||
| "nsfw": DefaultComparison((False, MISSING)), | ||
| # integration_types gets set in ApplicationCommandMixin._get_command_defaults | ||
| "contexts": DefaultSetComparison((None, MISSING), lambda x, y: set(x) == set(y)), | ||
| } | ||
| SUBCOMMAND_DEFAULTS: NestedComparison = { | ||
| "type": DefaultComparison(()), | ||
| "name": DefaultComparison(()), | ||
| "description": DefaultComparison(()), | ||
| "name_localizations": DefaultComparison((None, {}, MISSING)), | ||
| "description_localizations": DefaultComparison((None, {}, MISSING)), | ||
| "options": DefaultComparison(OPTION_DEFAULT_VALUES, _option_comparison_check), | ||
| } | ||
| COMMAND_OPTION_DEFAULTS: NestedComparison = { | ||
| "type": DefaultComparison(()), | ||
| "name": DefaultComparison(()), | ||
| "description": DefaultComparison(()), | ||
| "name_localizations": DefaultComparison((None, {}, MISSING)), | ||
| "description_localizations": DefaultComparison((None, {}, MISSING)), | ||
| "required": DefaultComparison((False, MISSING)), | ||
| "choices": DefaultComparison(CHOICES_DEFAULT_VALUES, _choices_comparison_check), | ||
| "channel_types": DefaultComparison(([], MISSING)), | ||
| "min_value": DefaultComparison((MISSING,)), | ||
| "max_value": DefaultComparison((MISSING,)), | ||
| "min_length": DefaultComparison((MISSING,)), | ||
| "max_length": DefaultComparison((MISSING,)), | ||
| "autocomplete": DefaultComparison((MISSING, False)), | ||
| } | ||
| OPTIONS_CHOICES_DEFAULTS: NestedComparison = { | ||
| "name": DefaultComparison(()), | ||
| "name_localizations": DefaultComparison((None, {}, MISSING)), | ||
| "value": DefaultComparison(()), | ||
| } | ||
|
|
||
|
|
||
| class ApplicationCommandMixin(ABC): | ||
| """A mixin that implements common functionality for classes that need | ||
| application command compatibility. | ||
|
|
@@ -245,6 +414,19 @@ def get_application_command( | |
| return | ||
| return command | ||
|
|
||
| async def _get_command_defaults(self): | ||
| app_info = await self._bot.application_info() | ||
| integration_contexts = app_info.integration_types_config._to_payload().keys() | ||
|
|
||
| if len(integration_contexts) == 0: | ||
| integration_contexts = {IntegrationType.guild_install.value, IntegrationType.user_install.value} | ||
|
|
||
| command_defaults = COMMAND_DEFAULTS.copy() | ||
| command_defaults["integration_types"] = DefaultSetComparison( | ||
| (MISSING, integration_contexts), lambda x, y: set(x) == set(y) | ||
| ) | ||
| return command_defaults | ||
|
|
||
| async def get_desynced_commands( | ||
| self, | ||
| guild_id: int | None = None, | ||
|
|
@@ -276,82 +458,31 @@ async def get_desynced_commands( | |
| the action, including ``id``. | ||
| """ | ||
|
|
||
| # We can suggest the user to upsert, edit, delete, or bulk upsert the commands | ||
| updated_command_defaults = await self._get_command_defaults() | ||
|
|
||
| # We can suggest the user to upsert, edit, delete, or bulk upsert the commands | ||
| def _check_command(cmd: ApplicationCommand, match: Mapping[str, Any]) -> bool: | ||
| """Returns True If Commands Are Equivalent""" | ||
| if isinstance(cmd, SlashCommandGroup): | ||
| if len(cmd.subcommands) != len(match.get("options", [])): | ||
| return True | ||
| return False | ||
| for i, subcommand in enumerate(cmd.subcommands): | ||
| match_ = next( | ||
| ( | ||
| data | ||
| for data in match["options"] | ||
| if data["name"] == subcommand.name | ||
| ), | ||
| MISSING, | ||
| match_ = find( | ||
| lambda x: x["name"] == subcommand.name, match["options"] | ||
| ) | ||
| if match_ is not MISSING and _check_command(subcommand, match_): | ||
| return True | ||
| if match_ is None: | ||
| return False | ||
| elif not _check_command(subcommand, match_): | ||
| return False | ||
| else: | ||
| return True | ||
| else: | ||
| as_dict = cmd.to_dict() | ||
| to_check = { | ||
| "nsfw": None, | ||
| "default_member_permissions": None, | ||
| "name": None, | ||
| "description": None, | ||
| "name_localizations": None, | ||
| "description_localizations": None, | ||
| "options": [ | ||
| "type", | ||
| "name", | ||
| "description", | ||
| "autocomplete", | ||
| "choices", | ||
| "name_localizations", | ||
| "description_localizations", | ||
| ], | ||
| "contexts": None, | ||
| "integration_types": None, | ||
| } | ||
| for check, value in to_check.items(): | ||
| if type(to_check[check]) == list: | ||
| # We need to do some falsy conversion here | ||
| # The API considers False (autocomplete) and [] (choices) to be falsy values | ||
| falsy_vals = (False, []) | ||
| for opt in value: | ||
| cmd_vals = ( | ||
| [val.get(opt, MISSING) for val in as_dict[check]] | ||
| if check in as_dict | ||
| else [] | ||
| ) | ||
| for i, val in enumerate(cmd_vals): | ||
| if val in falsy_vals: | ||
| cmd_vals[i] = MISSING | ||
| if match.get( | ||
| check, MISSING | ||
| ) is not MISSING and cmd_vals != [ | ||
| val.get(opt, MISSING) for val in match[check] | ||
| ]: | ||
| # We have a difference | ||
| return True | ||
| elif (attr := getattr(cmd, check, None)) != ( | ||
| found := match.get(check) | ||
| ): | ||
| # We might have a difference | ||
| if "localizations" in check and bool(attr) == bool(found): | ||
| # unlike other attrs, localizations are MISSING by default | ||
| continue | ||
| elif ( | ||
| check == "default_permission" | ||
| and attr is True | ||
| and found is None | ||
| ): | ||
| # This is a special case | ||
| # TODO: Remove for perms v2 | ||
| continue | ||
| return True | ||
| return False | ||
| if cmd.parent is None: | ||
| return _compare_defaults( | ||
| cmd.to_dict(), match, updated_command_defaults | ||
| ) | ||
| else: | ||
| return _compare_defaults(cmd.to_dict(), match, SUBCOMMAND_DEFAULTS) | ||
|
|
||
| return_value = [] | ||
| cmds = self.pending_application_commands.copy() | ||
|
|
@@ -382,19 +513,20 @@ def _check_command(cmd: ApplicationCommand, match: Mapping[str, Any]) -> bool: | |
| # First let's check if the commands we have locally are the same as the ones on discord | ||
| for cmd in pending: | ||
| match = registered_commands_dict.get(cmd.name) | ||
| # We don't have this command registered | ||
| if match is None: | ||
| # We don't have this command registered | ||
| return_value.append({"command": cmd, "action": "upsert"}) | ||
| elif _check_command(cmd, match): | ||
| # We have a different version of the command then Discord | ||
| elif not _check_command(cmd, match): | ||
| return_value.append( | ||
| { | ||
| "command": cmd, | ||
| "action": "edit", | ||
| "id": int(registered_commands_dict[cmd.name]["id"]), | ||
| } | ||
| ) | ||
| # We have this command registered and it's the same | ||
| else: | ||
| # We have this command registered but it's the same | ||
| return_value.append( | ||
| {"command": cmd, "action": None, "id": int(match["id"])} | ||
| ) | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| import asyncio | ||
|
|
||
| import pytest | ||
|
|
||
|
|
||
| @pytest.fixture(autouse=True) | ||
| def _reset_event_loop_policy(): | ||
| # pytest-asyncio calls per-test asyncio.set_event_loop(None) at the end | ||
| # but doesn't reset the event loop policy so we do it here or else it breaks on py <3.14 | ||
| yield | ||
| asyncio.set_event_loop_policy(None) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.