diff --git a/.gitignore b/.gitignore index 630f838700..430cd4d7ce 100644 --- a/.gitignore +++ b/.gitignore @@ -125,3 +125,19 @@ TEST-**.xml # Mac OS .DS_Store, which is a file that stores custom attributes of its containing folder .DS_Store *.env* + +# # Métricas +# /metrics-before-radon +# /metrics-before-pylint +# /metrics-after-pylint +# /metrics-before-codecarbon +# /metrics-after-codecarbon +# /metrics-before-pytest + +# extract_metrics_before_radon.py +# extract_metrics_before_pylint.py +# extract_metrics_after_pylint.py +# extract_score_before_pylint.py +# extract_metrics_before_codecarbon.py +# extract_metrics_after_codecarbon.py +# extract_metrics_before_pytest.py \ No newline at end of file diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index 352be313dc..b9cbc1a9ee 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -96,24 +96,7 @@ async def on_command_error(self, ctx: Context, e: errors.CommandError) -> None: ) if isinstance(e, errors.CommandNotFound) and not getattr(ctx, "invoked_from_error_handler", False): - # We might not invoke a command from the error handler, but it's easier and safer to ensure - # this is always set rather than trying to get it exact, and shouldn't cause any issues. - ctx.invoked_from_error_handler = True - - # All errors from attempting to execute these commands should be handled by the error handler. - # We wrap non CommandErrors in CommandInvokeError to mirror the behaviour of normal commands. - try: - if await self.try_silence(ctx): - return - if await self.try_run_fixed_codeblock(ctx): - return - await self.try_get_tag(ctx) - except Exception as err: - log.info("Re-handling error raised by command in error handler") - if isinstance(err, errors.CommandError): - await self.on_command_error(ctx, err) - else: - await self.on_command_error(ctx, errors.CommandInvokeError(err)) + await self._handle_command_not_found(ctx, e) elif isinstance(e, errors.UserInputError): log.debug(debug_message) await self.handle_user_input_error(ctx, e) @@ -124,30 +107,55 @@ async def on_command_error(self, ctx: Context, e: errors.CommandError) -> None: log.debug(debug_message) await ctx.send(e) 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.") - elif isinstance(e.original, InvalidInfractedUserError): - await ctx.send(f"Cannot infract that user. {e.original.reason}") - elif isinstance(e.original, Forbidden): - try: - await handle_forbidden_from_block(e.original, ctx.message) - except Forbidden: - await self.handle_unexpected_error(ctx, e.original) - else: - await self.handle_unexpected_error(ctx, e.original) + await self._handle_command_invoke_error(ctx, e) elif isinstance(e, errors.ConversionError): - if isinstance(e.original, ResponseCodeError): - await self.handle_api_error(ctx, e.original) - else: - await self.handle_unexpected_error(ctx, e.original) + await self._handle_conversion_error(ctx, e) elif isinstance(e, errors.DisabledCommand): log.debug(debug_message) else: # ExtensionError await self.handle_unexpected_error(ctx, e) + async def _handle_command_not_found(self, ctx: Context, e: errors.CommandError) -> None: + """Handle CommandNotFound errors by attempting silence, codeblock, or tag commands.""" + ctx.invoked_from_error_handler = True + + try: + if await self.try_silence(ctx): + return + if await self.try_run_fixed_codeblock(ctx): + return + await self.try_get_tag(ctx) + except Exception as err: + log.info("Re-handling error raised by command in error handler") + if isinstance(err, errors.CommandError): + await self.on_command_error(ctx, err) + else: + await self.on_command_error(ctx, errors.CommandInvokeError(err)) + + async def _handle_command_invoke_error(self, ctx: Context, e: errors.CommandInvokeError) -> None: + """Handle CommandInvokeError by dispatching on the underlying exception type.""" + 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.") + elif isinstance(e.original, InvalidInfractedUserError): + await ctx.send(f"Cannot infract that user. {e.original.reason}") + elif isinstance(e.original, Forbidden): + try: + await handle_forbidden_from_block(e.original, ctx.message) + except Forbidden: + await self.handle_unexpected_error(ctx, e.original) + else: + await self.handle_unexpected_error(ctx, e.original) + + async def _handle_conversion_error(self, ctx: Context, e: errors.ConversionError) -> None: + """Handle ConversionError by dispatching on the underlying exception type.""" + if isinstance(e.original, ResponseCodeError): + await self.handle_api_error(ctx, e.original) + else: + await self.handle_unexpected_error(ctx, e.original) + async def try_silence(self, ctx: Context) -> bool: """ Attempt to invoke the silence or unsilence command if invoke with matches a pattern. diff --git a/bot/exts/filtering/_filter_context.py b/bot/exts/filtering/_filter_context.py index 5e43b0eef3..50fbe3fbf5 100644 --- a/bot/exts/filtering/_filter_context.py +++ b/bot/exts/filtering/_filter_context.py @@ -1,6 +1,8 @@ +from __future__ import annotations + import typing from collections.abc import Callable, Coroutine, Iterable -from dataclasses import dataclass, field, replace +from dataclasses import dataclass, field, replace as dataclass_replace from enum import Enum, auto import discord @@ -25,60 +27,124 @@ class Event(Enum): @dataclass -class FilterContext: - """A dataclass containing the information that should be filtered, and output information of the filtering.""" - - # Input context - event: Event # The type of event - author: User | Member | None # Who triggered the event - channel: TextChannel | VoiceChannel | StageChannel | Thread | DMChannel | None # The channel involved - content: str | Iterable # What actually needs filtering. The Iterable type depends on the filter list. - message: Message | None # The message involved - embeds: list[Embed] = field(default_factory=list) # Any embeds involved - attachments: list[discord.Attachment | FileAttachment] = field(default_factory=list) # Any attachments sent. +class FilterSource: + """The source/sender metadata for a filtering context.""" + + event: Event + author: User | Member | None + channel: TextChannel | VoiceChannel | StageChannel | Thread | DMChannel | None + message: Message | None before_message: Message | None = None message_cache: MessageCache | None = None - # Output context - dm_content: str = "" # The content to DM the invoker - dm_embed: str = "" # The embed description to DM the invoker - send_alert: bool = False # Whether to send an alert for the moderators - alert_content: str = "" # The content of the alert - alert_embeds: list[Embed] = field(default_factory=list) # Any embeds to add to the alert - action_descriptions: list[str] = field(default_factory=list) # What actions were taken - matches: list[str] = field(default_factory=list) # What exactly was found - notification_domain: str = "" # A domain to send the user for context - filter_info: dict[Filter, str] = field(default_factory=dict) # Additional info from a filter. - messages_deletion: bool = False # Whether the messages were deleted. Can't upload deletion log otherwise. - blocked_exts: set[str] = field(default_factory=set) # Any extensions blocked (used for snekbox) - potential_phish: dict[FilterList, set[str]] = field(default_factory=dict) - # Additional actions to perform + + +@dataclass +class FilterContent: + """The content being filtered.""" + + content: str | Iterable + embeds: list[Embed] = field(default_factory=list) + attachments: list[discord.Attachment | FileAttachment] = field(default_factory=list) + + +@dataclass +class FilterNotifications: + """DM and alert content produced by filtering.""" + + dm_content: str = "" + dm_embed: str = "" + send_alert: bool = False + alert_content: str = "" + alert_embeds: list[Embed] = field(default_factory=list) + notification_domain: str = "" + action_descriptions: list[str] = field(default_factory=list) + + +@dataclass +class FilterActions: + """Side effects and deletion metadata produced by filtering.""" + additional_actions: list[Callable[[FilterContext], Coroutine]] = field(default_factory=list) - related_messages: set[Message] = field(default_factory=set) # Deletion will include these. + messages_deletion: bool = False + related_messages: set[Message] = field(default_factory=set) related_channels: set[TextChannel | Thread | DMChannel] = field(default_factory=set) - uploaded_attachments: dict[int, list[str]] = field(default_factory=dict) # Message ID to attachment URLs. - upload_deletion_logs: bool = True # Whether it's allowed to upload deletion logs. + uploaded_attachments: dict[int, list[str]] = field(default_factory=dict) + upload_deletion_logs: bool = True + + +@dataclass +class FilterResults: + """Filter match results and tracking data.""" + + matches: list[str] = field(default_factory=list) + filter_info: dict[Filter, str] = field(default_factory=dict) + blocked_exts: set[str] = field(default_factory=set) + potential_phish: dict[FilterList, set[str]] = field(default_factory=dict) + - def __post_init__(self): - # If it's in the context of a DM channel, self.channel won't be None, but self.channel.guild will. - self.in_guild = self.channel is None or self.channel.guild is not None +class FilterContext: + """A context object containing the information that should be filtered, and output information of the filtering. + + Attributes are delegated to sub-objects for organization: + - ``source``: event, author, channel, message, before_message, message_cache + - ``content``: content, embeds, attachments + - ``notifications``: dm_content, dm_embed, send_alert, alert_content, alert_embeds, notification_domain, action_descriptions + - ``actions``: additional_actions, messages_deletion, related_messages, related_channels, uploaded_attachments, upload_deletion_logs + - ``results``: matches, filter_info, blocked_exts, potential_phish + """ + + def __init__(self, source, content, notifications=None, actions=None, results=None): + self._source = source + self._content = content + self._notifications = notifications or FilterNotifications() + self._actions = actions or FilterActions() + self._results = results or FilterResults() + self.in_guild = source.channel is None or source.channel.guild is not None + + def __getattr__(self, name): + for obj in (self._source, self._content, self._notifications, self._actions, self._results): + if hasattr(obj, name): + return getattr(obj, name) + raise AttributeError(f"'{type(self).__name__}' has no attribute '{name}'") + + def __setattr__(self, name, value): + if name.startswith('_') or name == 'in_guild': + object.__setattr__(self, name, value) + return + for obj in (self._source, self._content, self._notifications, self._actions, self._results): + if hasattr(obj, name): + setattr(obj, name, value) + return + object.__setattr__(self, name, value) @classmethod def from_message( cls, event: Event, message: Message, before: Message | None = None, cache: MessageCache | None = None ) -> FilterContext: """Create a filtering context from the attributes of a message.""" - return cls( - event, - message.author, - message.channel, - message.content, - message, - message.embeds, - message.attachments, - before, - cache - ) + source = FilterSource(event, message.author, message.channel, message, before, cache) + content = FilterContent(message.content, message.embeds, message.attachments) + return cls(source, content) def replace(self, **changes) -> FilterContext: """Return a new context object assigning new values to the specified fields.""" - return replace(self, **changes) + sub_objects = { + '_source': self._source, + '_content': self._content, + '_notifications': self._notifications, + '_actions': self._actions, + '_results': self._results, + } + sub_changes = {} + for key, value in changes.items(): + for attr_name, obj in sub_objects.items(): + if hasattr(obj, key): + sub_changes.setdefault(attr_name, {})[key] = value + break + return FilterContext( + source=dataclass_replace(self._source, **sub_changes.get('_source', {})), + content=dataclass_replace(self._content, **sub_changes.get('_content', {})), + notifications=dataclass_replace(self._notifications, **sub_changes.get('_notifications', {})), + actions=dataclass_replace(self._actions, **sub_changes.get('_actions', {})), + results=dataclass_replace(self._results, **sub_changes.get('_results', {})), + ) diff --git a/bot/exts/filtering/_filter_lists/antispam.py b/bot/exts/filtering/_filter_lists/antispam.py index ecb895e013..83fb36628c 100644 --- a/bot/exts/filtering/_filter_lists/antispam.py +++ b/bot/exts/filtering/_filter_lists/antispam.py @@ -13,7 +13,7 @@ from pydis_core.utils import scheduling from pydis_core.utils.logging import get_logger -from bot.exts.filtering._filter_context import FilterContext +from bot.exts.filtering._filter_context import FilterContent, FilterContext, FilterSource from bot.exts.filtering._filter_lists.filter_list import ListType, SubscribingAtomicList, UniquesListBase from bot.exts.filtering._filters.antispam import antispam_filter_types from bot.exts.filtering._filters.filter import Filter, UniqueFilter @@ -158,7 +158,7 @@ async def send_alert(self, antispam_list: AntispamList) -> None: return ctx, *other_contexts = self.contexts - new_ctx = FilterContext(ctx.event, ctx.author, ctx.channel, ctx.content, ctx.message) + new_ctx = FilterContext(FilterSource(ctx.event, ctx.author, ctx.channel, ctx.message), FilterContent(ctx.content)) all_descriptions_counts = Counter(reduce( add, (other_ctx.action_descriptions for other_ctx in other_contexts), ctx.action_descriptions )) diff --git a/bot/exts/filtering/_filter_lists/invite.py b/bot/exts/filtering/_filter_lists/invite.py index f8b5b3189c..2abb2479d9 100644 --- a/bot/exts/filtering/_filter_lists/invite.py +++ b/bot/exts/filtering/_filter_lists/invite.py @@ -59,29 +59,59 @@ async def actions_for( ) -> tuple[ActionSettings | None, list[str], dict[ListType, list[Filter]]]: """Dispatch the given event to the list's filters, and return actions to take and messages to relay to mods.""" text = clean_input(ctx.content, keep_newlines=True) - - matches = list(DISCORD_INVITE.finditer(text)) - invite_codes = {m.group("invite") for m in matches} + matches, invite_codes, refined_invites = self._process_invite_codes(text) if not invite_codes: return None, [], {} - all_triggers = {} + _, failed = self[ListType.ALLOW].defaults.validations.evaluate(ctx) + check_if_allowed = not failed + + invites_for_inspection, unknown_invites = await self._fetch_and_categorize_invites( + refined_invites, check_if_allowed + ) + + new_ctx = ctx.replace(content={invite.guild.id for invite in invites_for_inspection.values()}) + triggered = await self[ListType.DENY].filter_list_result(new_ctx) + + blocked_invites, invites_for_inspection = self._apply_deny_filters(invites_for_inspection, triggered) + + all_triggers = await self._check_allow_list( + ctx, invites_for_inspection, unknown_invites, check_if_allowed + ) + + if not triggered and not unknown_invites: + return None, [], all_triggers + + actions = self._determine_actions(unknown_invites, triggered, all_triggers) + + blocked_invites |= unknown_invites + ctx.matches += {match[0] for match in matches if refined_invites.get(match.group("invite")) in blocked_invites} + ctx.alert_embeds += (self._guild_embed(invite) for invite in blocked_invites.values() if invite) + if unknown_invites: + ctx.potential_phish[self] = set(unknown_invites) + + messages = self._build_messages(triggered, unknown_invites) + return actions, messages, all_triggers + + @staticmethod + def _process_invite_codes(text: str) -> tuple[list[re.Match], set[str], dict[str, str]]: + """Extract invite codes from the text and refine obfuscated codes.""" + matches = list(DISCORD_INVITE.finditer(text)) + invite_codes = {m.group("invite") for m in matches} refined_invites = {} for invite_code in invite_codes: - # Attempt to overcome an obfuscated invite. - # If the result is incorrect, it won't make the whitelist more permissive or the blacklist stricter. refined_invite_code = invite_code if match := REFINED_INVITE_CODE.search(invite_code): refined_invite_code = match.group("invite") refined_invites[invite_code] = refined_invite_code - - _, failed = self[ListType.ALLOW].defaults.validations.evaluate(ctx) - # If the allowed list doesn't operate in the context, unknown invites are allowed. - check_if_allowed = not failed - - # Sort the invites into two categories: - invites_for_inspection = dict() # Found guild invites requiring further inspection. - unknown_invites = dict() # Either don't resolve or group DMs. + return matches, invite_codes, refined_invites + + async def _fetch_and_categorize_invites( + self, refined_invites: dict[str, str], check_if_allowed: bool + ) -> tuple[dict[str, Invite], dict[str, Invite | None]]: + """Fetch invites from the Discord API and categorize them into guild invites or unknown/group DMs.""" + invites_for_inspection = {} + unknown_invites = {} for invite_code in refined_invites.values(): try: invite = await bot.instance.fetch_invite(invite_code) @@ -91,63 +121,72 @@ async def actions_for( else: if invite.guild: invites_for_inspection[invite_code] = invite - elif check_if_allowed: # Group DM + elif check_if_allowed: unknown_invites[invite_code] = invite + return invites_for_inspection, unknown_invites + + async def _check_allow_list( + self, ctx: FilterContext, invites_for_inspection: dict[str, Invite], + unknown_invites: dict[str, Invite | None], check_if_allowed: bool + ) -> dict[ListType, list[Filter]]: + """Check remaining invites against the allow list and update unknown invites.""" + if not check_if_allowed: + return {} + guilds_for_inspection = {invite.guild.id for invite in invites_for_inspection.values()} + new_ctx = ctx.replace(content=guilds_for_inspection) + all_triggers = { + ListType.ALLOW: [ + filter_ for filter_ in self[ListType.ALLOW].filters.values() + if await filter_.triggered_on(new_ctx) + ] + } + allowed = {filter_.content for filter_ in all_triggers[ListType.ALLOW]} + unknown_invites.update({ + code: invite for code, invite in invites_for_inspection.items() if invite.guild.id not in allowed + }) + return all_triggers - # Find any blocked invites - new_ctx = ctx.replace(content={invite.guild.id for invite in invites_for_inspection.values()}) - triggered = await self[ListType.DENY].filter_list_result(new_ctx) + @staticmethod + def _apply_deny_filters( + invites_for_inspection: dict[str, Invite], triggered: list[Filter] + ) -> tuple[dict[str, Invite], dict[str, Invite]]: + """Separate blocked invites and filter out partnered/verified guilds from inspection.""" blocked_guilds = {filter_.content for filter_ in triggered} blocked_invites = { - code: invite for code, invite in invites_for_inspection.items() if invite.guild.id in blocked_guilds + code: invite for code, invite in invites_for_inspection.items() + if invite.guild.id in blocked_guilds } - - # Remove the ones which are already confirmed as blocked, or otherwise ones which are partnered or verified. - invites_for_inspection = { + filtered_invites = { code: invite for code, invite in invites_for_inspection.items() if invite.guild.id not in blocked_guilds - and "PARTNERED" not in invite.guild.features and "VERIFIED" not in invite.guild.features + and "PARTNERED" not in invite.guild.features + and "VERIFIED" not in invite.guild.features } + return blocked_invites, filtered_invites - # Remove any remaining invites which are allowed - guilds_for_inspection = {invite.guild.id for invite in invites_for_inspection.values()} - - if check_if_allowed: # Whether unknown invites need to be checked. - new_ctx = ctx.replace(content=guilds_for_inspection) - all_triggers[ListType.ALLOW] = [ - filter_ for filter_ in self[ListType.ALLOW].filters.values() - if await filter_.triggered_on(new_ctx) - ] - allowed = {filter_.content for filter_ in all_triggers[ListType.ALLOW]} - unknown_invites.update({ - code: invite for code, invite in invites_for_inspection.items() if invite.guild.id not in allowed - }) - - if not triggered and not unknown_invites: - return None, [], all_triggers - + def _determine_actions( + self, unknown_invites: dict, triggered: list[Filter], + all_triggers: dict[ListType, list[Filter]] + ) -> ActionSettings | None: + """Determine the actions to take based on triggered filters and unknown invites.""" actions = None - if unknown_invites: # There are invites which weren't allowed but aren't explicitly blocked. + if unknown_invites: actions = self[ListType.ALLOW].defaults.actions - # Blocked invites come second so that their actions have preference. if triggered: if actions: actions = actions.union(self[ListType.DENY].merge_actions(triggered)) else: actions = self[ListType.DENY].merge_actions(triggered) all_triggers[ListType.DENY] = triggered + return actions - blocked_invites |= unknown_invites - ctx.matches += {match[0] for match in matches if refined_invites.get(match.group("invite")) in blocked_invites} - ctx.alert_embeds += (self._guild_embed(invite) for invite in blocked_invites.values() if invite) - if unknown_invites: - ctx.potential_phish[self] = set(unknown_invites) - + def _build_messages(self, triggered: list[Filter], unknown_invites: dict) -> list[str]: + """Build alert messages for triggered filters and unknown invites.""" messages = self[ListType.DENY].format_messages(triggered) messages += [ f"`{code} - {invite.guild.id}`" if invite else f"`{code}`" for code, invite in unknown_invites.items() ] - return actions, messages, all_triggers + return messages @staticmethod def _guild_embed(invite: Invite) -> Embed: diff --git a/bot/exts/filtering/_filters/filter.py b/bot/exts/filtering/_filters/filter.py index 3f201cfde4..674d36db66 100644 --- a/bot/exts/filtering/_filters/filter.py +++ b/bot/exts/filtering/_filters/filter.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod +from dataclasses import dataclass from typing import Any import arrow @@ -9,6 +10,13 @@ from bot.exts.filtering._utils import FieldRequiring +@dataclass +class FilterTimestamps: + """Timestamps for when a filter was created and last updated.""" + created_at: arrow.Arrow + updated_at: arrow.Arrow + + class Filter(FieldRequiring): """ A class representing a filter. @@ -23,12 +31,14 @@ class Filter(FieldRequiring): # If a subclass uses extra fields, it should assign the pydantic model type to this variable. extra_fields_type = None - def __init__(self, filter_data: dict, defaults: Defaults | None = None): + def __init__(self, filter_data: dict, defaults: Defaults | None=None): self.id = filter_data["id"] self.content = filter_data["content"] self.description = filter_data["description"] - self.created_at = arrow.get(filter_data["created_at"]) - self.updated_at = arrow.get(filter_data["updated_at"]) + self.timestamps = FilterTimestamps( + created_at=arrow.get(filter_data["created_at"]), + updated_at=arrow.get(filter_data["updated_at"]), + ) self.actions, self.validations = create_settings(filter_data["settings"], defaults=defaults) if self.extra_fields_type: self.extra_fields = self.extra_fields_type.model_validate(filter_data["additional_settings"]) @@ -50,6 +60,11 @@ def overrides(self) -> tuple[dict[str, Any], dict[str, Any]]: return settings, filter_settings + @property + def last_updated(self) -> arrow.Arrow: + """The most recent time this filter was created or updated.""" + return max(self.timestamps.created_at, self.timestamps.updated_at) + @abstractmethod async def triggered_on(self, ctx: FilterContext) -> bool: """Search for the filter's content within a given context.""" @@ -75,6 +90,15 @@ async def process_input(cls, content: str, description: str) -> tuple[str, str]: A BadArgument should be raised if the content can't be used. """ return content, description + + + @property + def created_at(self) -> arrow.Arrow: + return self.timestamps.created_at + + @property + def updated_at(self) -> arrow.Arrow: + return self.timestamps.updated_at def __str__(self) -> str: """A string representation of the filter.""" diff --git a/bot/exts/filtering/_loaded_types.py b/bot/exts/filtering/_loaded_types.py new file mode 100644 index 0000000000..38afccc132 --- /dev/null +++ b/bot/exts/filtering/_loaded_types.py @@ -0,0 +1,15 @@ +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from bot.exts.filtering._filters.filter import Filter + from bot.exts.filtering._settings_types.settings_entry import SettingsEntry + + +@dataclass +class LoadedTypes: + """Container for loaded type metadata used across the filtering UI.""" + + filters: dict[str, type["Filter"]] + settings: dict[str, tuple[str, "SettingsEntry", type]] + filter_settings: dict[str, dict[str, tuple[str, "SettingsEntry", type]]] diff --git a/bot/exts/filtering/_ui/filter.py b/bot/exts/filtering/_ui/filter.py index bf19ed414d..c95428337a 100644 --- a/bot/exts/filtering/_ui/filter.py +++ b/bot/exts/filtering/_ui/filter.py @@ -1,4 +1,5 @@ from collections.abc import Callable +from dataclasses import dataclass from typing import Any import discord @@ -10,6 +11,7 @@ from bot.exts.filtering._filter_lists.filter_list import FilterList, ListType from bot.exts.filtering._filters.filter import Filter +from bot.exts.filtering._loaded_types import LoadedTypes from bot.exts.filtering._ui.ui import ( COMPONENT_TIMEOUT, CustomCallbackSelect, @@ -109,6 +111,35 @@ async def on_submit(self, interaction: Interaction) -> None: await self.embed_view.apply_template(self.template.value, self.message, interaction) +@dataclass +class FilterTarget: + """The filter being edited and its context.""" + filter_list: FilterList + list_type: ListType + filter_type: type[Filter] + + +@dataclass +class FilterContent: + """Content and description of the filter being edited.""" + content: str | None + description: str | None + + +def build_type_per_setting_name( + filter_type: type[Filter], + loaded_settings: dict, + loaded_filter_settings: dict, +) -> dict: + """Build the type_per_setting_name dict from the loaded settings.""" + type_per_setting_name = {setting: info[2] for setting, info in loaded_settings.items()} + type_per_setting_name.update({ + f"{filter_type.name}/{name}": type_ + for name, (_, _, type_) in loaded_filter_settings.get(filter_type.name, {}).items() + }) + return type_per_setting_name + + class FilterEditView(EditBaseView): """A view used to edit a filter's settings before updating the database.""" @@ -117,54 +148,48 @@ class _REMOVE: def __init__( self, - filter_list: FilterList, - list_type: ListType, - filter_type: type[Filter], - content: str | None, - description: str | None, + filter_target: FilterTarget, + filter_content: FilterContent, settings_overrides: dict, filter_settings_overrides: dict, - loaded_settings: dict, - loaded_filter_settings: dict, + loaded: LoadedTypes, author: User, embed: Embed, confirm_callback: Callable ): super().__init__(author) - self.filter_list = filter_list - self.list_type = list_type - self.filter_type = filter_type - self.content = content - self.description = description + self.filter_target = filter_target + self.filter_content = filter_content self.settings_overrides = settings_overrides self.filter_settings_overrides = filter_settings_overrides - self.loaded_settings = loaded_settings - self.loaded_filter_settings = loaded_filter_settings + self.loaded = loaded self.embed = embed self.confirm_callback = confirm_callback all_settings_repr_dict = build_filter_repr_dict( - filter_list, list_type, filter_type, settings_overrides, filter_settings_overrides + filter_target.filter_list, filter_target.list_type, filter_target.filter_type, + settings_overrides, filter_settings_overrides ) populate_embed_from_dict(embed, all_settings_repr_dict) - self.type_per_setting_name = {setting: info[2] for setting, info in loaded_settings.items()} + self.type_per_setting_name = {setting: info[2] for setting, info in loaded.settings.items()} self.type_per_setting_name.update({ f"{filter_type.name}/{name}": type_ - for name, (_, _, type_) in loaded_filter_settings.get(filter_type.name, {}).items() + for name, (_, _, type_) in loaded.filter_settings.get(filter_type.name, {}).items() }) add_select = CustomCallbackSelect( self._prompt_new_value, placeholder="Select a setting to edit", - options=[SelectOption(label=name) for name in sorted(self.type_per_setting_name)], + options=[SelectOption(label=name) for name in sorted(type_per_setting_name)], row=1 ) self.add_item(add_select) if settings_overrides or filter_settings_overrides: override_names = ( - list(settings_overrides) + [f"{filter_list.name}/{setting}" for setting in filter_settings_overrides] + list(settings_overrides) + + [f"{filter_target.filter_list.name}/{setting}" for setting in filter_settings_overrides] ) remove_select = CustomCallbackSelect( self._remove_override, @@ -200,21 +225,21 @@ async def enter_template(self, interaction: Interaction, button: discord.ui.Butt @discord.ui.button(label="✅ Confirm", style=discord.ButtonStyle.green, row=4) async def confirm(self, interaction: Interaction, button: discord.ui.Button) -> None: """Confirm the content, description, and settings, and update the filters database.""" - if self.content is None: + if self.filter_content.content is None: await interaction.response.send_message( ":x: Cannot add a filter with no content.", ephemeral=True, reference=interaction.message ) - if self.description is None: - self.description = "" + if self.filter_content.description is None: + self.filter_content.description = "" await interaction.response.edit_message(view=None) # Make sure the interaction succeeds first. try: await self.confirm_callback( interaction.message, - self.filter_list, - self.list_type, - self.filter_type, - self.content, - self.description, + self.filter_target.filter_list, + self.filter_target.list_type, + self.filter_target.filter_type, + self.filter_content.content, + self.filter_content.description, self.settings_overrides, self.filter_settings_overrides ) @@ -245,6 +270,63 @@ def current_value(self, setting_name: str) -> Any: return self.filter_settings_overrides[setting_name] return MISSING + async def _update_content_and_description( + self, + interaction_or_msg: discord.Interaction | discord.Message, + content: str | None, + description: str | type[FilterEditView._REMOVE] | None, + ) -> bool: + """Update the content and description state and embed. Returns False if an error was sent.""" + if content is not None: + filter_type = self.filter_list.get_filter_type(content) + if not filter_type: + if isinstance(interaction_or_msg, discord.Message): + send_method = interaction_or_msg.channel.send + else: + send_method = interaction_or_msg.response.send_message + await send_method(f":x: Could not find a filter type appropriate for `{content}`.") + return False + self.content = content + self.filter_type = filter_type + else: + content = self.content + + if description is self._REMOVE: + self.description = None + elif description is not None: + self.description = description + else: + description = self.description + + self.embed.description = f"`{content}`" if content else "*No content*" + if description and description is not self._REMOVE: + self.embed.description += f" - {description}" + if len(self.embed.description) > MAX_EMBED_DESCRIPTION: + self.embed.description = self.embed.description[:MAX_EMBED_DESCRIPTION - 5] + "[...]" + return True + + def _update_setting_override( + self, + setting_name: str, + setting_value: str | type[FilterEditView._REMOVE] | None, + ) -> None: + """Update or remove the setting override in the appropriate dictionary.""" + if "/" in setting_name: + _filter_name, setting_name = setting_name.split("/", maxsplit=1) + dict_to_edit = self.filter_settings_overrides + default_value = self.filter_type.extra_fields_type().model_dump()[setting_name] + else: + dict_to_edit = self.settings_overrides + default_value = self.filter_list[self.list_type].default(setting_name) + + if setting_value is not self._REMOVE: + if not repr_equals(setting_value, default_value): + dict_to_edit[setting_name] = setting_value + elif setting_name in dict_to_edit: + dict_to_edit.pop(setting_name) + elif setting_name in dict_to_edit: + dict_to_edit.pop(setting_name) + async def update_embed( self, interaction_or_msg: discord.Interaction | discord.Message, @@ -261,54 +343,12 @@ async def update_embed( If `interaction_or_msg` is a Message, the invoking Interaction must be deferred before calling this function. """ if content is not None or description is not None: - if content is not None: - filter_type = self.filter_list.get_filter_type(content) - if not filter_type: - if isinstance(interaction_or_msg, discord.Message): - send_method = interaction_or_msg.channel.send - else: - send_method = interaction_or_msg.response.send_message - await send_method(f":x: Could not find a filter type appropriate for `{content}`.") - return - self.content = content - self.filter_type = filter_type - else: - content = self.content # If there's no content or description, use the existing values. - if description is self._REMOVE: - self.description = None - elif description is not None: - self.description = description - else: - description = self.description - - # Update the embed with the new content and/or description. - self.embed.description = f"`{content}`" if content else "*No content*" - if description and description is not self._REMOVE: - self.embed.description += f" - {description}" - if len(self.embed.description) > MAX_EMBED_DESCRIPTION: - self.embed.description = self.embed.description[:MAX_EMBED_DESCRIPTION - 5] + "[...]" + if not await self._update_content_and_description(interaction_or_msg, content, description): + return if setting_name: - # Find the right dictionary to update. - if "/" in setting_name: - _filter_name, setting_name = setting_name.split("/", maxsplit=1) - dict_to_edit = self.filter_settings_overrides - default_value = self.filter_type.extra_fields_type().model_dump()[setting_name] - else: - dict_to_edit = self.settings_overrides - default_value = self.filter_list[self.list_type].default(setting_name) - # Update the setting override value or remove it - if setting_value is not self._REMOVE: - if not repr_equals(setting_value, default_value): - dict_to_edit[setting_name] = setting_value - # If there's already an override, remove it, since the new value is the same as the default. - elif setting_name in dict_to_edit: - dict_to_edit.pop(setting_name) - elif setting_name in dict_to_edit: - dict_to_edit.pop(setting_name) + self._update_setting_override(setting_name, setting_value) - # This is inefficient, but otherwise the selects go insane if the user attempts to edit the same setting - # multiple times, even when replacing the select with a new one. self.embed.clear_fields() new_view = self.copy() @@ -317,7 +357,7 @@ async def update_embed( await interaction_or_msg.response.edit_message(embed=self.embed, view=new_view) else: await interaction_or_msg.edit(embed=self.embed, view=new_view) - except discord.errors.HTTPException: # Various unexpected errors. + except discord.errors.HTTPException: pass else: self.stop() @@ -334,7 +374,7 @@ async def apply_template(self, template_id: str, embed_message: discord.Message, """Replace any non-overridden settings with overrides from the given filter.""" try: settings, filter_settings = template_settings( - template_id, self.filter_list, self.list_type, self.filter_type + template_id, self.filter_target.filter_list, self.filter_target.list_type, self.filter_target.filter_type ) except BadArgument as e: # The interaction object is necessary to send an ephemeral message. await interaction.response.send_message(f":x: {e}", ephemeral=True) @@ -359,27 +399,107 @@ async def _remove_override(self, interaction: Interaction, select: discord.ui.Se def copy(self) -> FilterEditView: """Create a copy of this view.""" return FilterEditView( - self.filter_list, - self.list_type, - self.filter_type, - self.content, - self.description, + FilterTarget( + self.filter_target.filter_list, + self.filter_target.list_type, + self.filter_target.filter_type, + ), + FilterContent( + self.filter_content.content, + self.filter_content.description, + ), self.settings_overrides, self.filter_settings_overrides, - self.loaded_settings, - self.loaded_filter_settings, + self.loaded, self.author, self.embed, self.confirm_callback ) +def _parse_filter_list_setting( + setting: str, + value: str, + settings: dict, + filter_list: FilterList, + list_type: ListType, + loaded: LoadedTypes, +) -> None: + """Parse and validate a filter list setting, updating `settings` in place.""" + type_ = loaded.settings[setting][2] + try: + parsed_value = parse_value(value, type_) + if not repr_equals(parsed_value, filter_list[list_type].default(setting)): + settings[setting] = parsed_value + except (TypeError, ValueError) as e: + raise BadArgument(e) + + +def _parse_filter_setting( + setting: str, + value: str, + filter_settings: dict, + filter_type: type[Filter], + loaded: LoadedTypes, +) -> None: + """Parse and validate a filter-specific setting, updating `filter_settings` in place.""" + filter_name, filter_setting_name = setting.split("/", maxsplit=1) + if filter_name.lower() != filter_type.name.lower(): + raise BadArgument( + f"A setting for a {filter_name!r} filter was provided, but the filter name is {filter_type.name!r}" + ) + if filter_setting_name not in loaded.filter_settings[filter_type.name]: + raise BadArgument(f"{setting!r} is not a recognized setting.") + type_ = loaded.filter_settings[filter_type.name][filter_setting_name][2] + try: + parsed_value = parse_value(value, type_) + if not repr_equals(parsed_value, getattr(filter_type.extra_fields_type(), filter_setting_name)): + filter_settings[filter_setting_name] = parsed_value + except (TypeError, ValueError) as e: + raise BadArgument(e) + + +def _parse_settings( + raw_settings: dict, + filter_list: FilterList, + list_type: ListType, + filter_type: type[Filter], + loaded: LoadedTypes, +) -> tuple[dict, dict]: + """Parse and validate all settings, returning (list_settings, filter_settings).""" + settings = {} + filter_settings = {} + for setting, value in raw_settings.items(): + if setting in loaded.settings: + _parse_filter_list_setting(setting, value, settings, filter_list, list_type, loaded) + elif "/" not in setting: + raise BadArgument(f"{setting!r} is not a recognized setting.") + else: + _parse_filter_setting(setting, value, filter_settings, filter_type, loaded) + return settings, filter_settings + + +def _apply_template( + template: str, + settings: dict, + filter_settings: dict, + filter_list: FilterList, + list_type: ListType, + filter_type: type[Filter], +) -> tuple[dict, dict]: + """Apply template settings and merge with existing overrides.""" + try: + t_settings, t_filter_settings = template_settings(template, filter_list, list_type, filter_type) + except ValueError as e: + raise BadArgument(str(e)) + return t_settings | settings, t_filter_settings | filter_settings + + def description_and_settings_converter( filter_list: FilterList, list_type: ListType, filter_type: type[Filter], - loaded_settings: dict, - loaded_filter_settings: dict, + loaded: LoadedTypes, input_data: str ) -> tuple[str, dict[str, Any], dict[str, Any]]: """Parse a string representing a possible description and setting overrides, and validate the setting names.""" @@ -394,49 +514,15 @@ def description_and_settings_converter( if not SINGLE_SETTING_PATTERN.match(parsed[0]): description, *parsed = parsed - settings = {setting: value for setting, value in [part.split("=", maxsplit=1) for part in parsed]} # noqa: C416 - template = None - if "--template" in settings: - template = settings.pop("--template") + raw_settings = {setting: value for setting, value in [part.split("=", maxsplit=1) for part in parsed]} # noqa: C416 + template = raw_settings.pop("--template", None) + + settings, filter_settings = _parse_settings( + raw_settings, filter_list, list_type, filter_type, loaded + ) - filter_settings = {} - for setting, _ in list(settings.items()): - if setting in loaded_settings: # It's a filter list setting - type_ = loaded_settings[setting][2] - try: - parsed_value = parse_value(settings.pop(setting), type_) - if not repr_equals(parsed_value, filter_list[list_type].default(setting)): - settings[setting] = parsed_value - except (TypeError, ValueError) as e: - raise BadArgument(e) - elif "/" not in setting: - raise BadArgument(f"{setting!r} is not a recognized setting.") - else: # It's a filter setting - filter_name, filter_setting_name = setting.split("/", maxsplit=1) - if filter_name.lower() != filter_type.name.lower(): - raise BadArgument( - f"A setting for a {filter_name!r} filter was provided, but the filter name is {filter_type.name!r}" - ) - if filter_setting_name not in loaded_filter_settings[filter_type.name]: - raise BadArgument(f"{setting!r} is not a recognized setting.") - type_ = loaded_filter_settings[filter_type.name][filter_setting_name][2] - try: - parsed_value = parse_value(settings.pop(setting), type_) - if not repr_equals(parsed_value, getattr(filter_type.extra_fields_type(), filter_setting_name)): - filter_settings[filter_setting_name] = parsed_value - except (TypeError, ValueError) as e: - raise BadArgument(e) - - # Pull templates settings and apply them. if template is not None: - try: - t_settings, t_filter_settings = template_settings(template, filter_list, list_type, filter_type) - except ValueError as e: - raise BadArgument(str(e)) - else: - # The specified settings go on top of the template - settings = t_settings | settings - filter_settings = t_filter_settings | filter_settings + settings, filter_settings = _apply_template(template, settings, filter_settings, filter_list, list_type, filter_type) return description, settings, filter_settings diff --git a/bot/exts/filtering/_ui/filter_list.py b/bot/exts/filtering/_ui/filter_list.py index c652098e17..44c91b82b4 100644 --- a/bot/exts/filtering/_ui/filter_list.py +++ b/bot/exts/filtering/_ui/filter_list.py @@ -7,6 +7,7 @@ from pydis_core.site_api import ResponseCodeError from bot.exts.filtering._filter_lists import FilterList, ListType +from bot.exts.filtering._loaded_types import LoadedTypes from bot.exts.filtering._ui.ui import ( CustomCallbackSelect, EditBaseView, @@ -19,7 +20,7 @@ from bot.exts.filtering._utils import repr_equals, to_serializable -def settings_converter(loaded_settings: dict, input_data: str) -> dict[str, Any]: +def settings_converter(loaded: LoadedTypes, input_data: str) -> dict[str, Any]: """Parse a string representing settings, and validate the setting names.""" if not input_data: return {} @@ -34,10 +35,10 @@ def settings_converter(loaded_settings: dict, input_data: str) -> dict[str, Any] raise BadArgument("The settings provided are not in the correct format.") for setting in settings: - if setting not in loaded_settings: + if setting not in loaded.settings: raise BadArgument(f"{setting!r} is not a recognized setting.") - type_ = loaded_settings[setting][2] + type_ = loaded.settings[setting][2] try: parsed_value = parse_value(settings.pop(setting), type_) settings[setting] = parsed_value @@ -74,7 +75,7 @@ def __init__( list_name: str, list_type: ListType, settings: dict, - loaded_settings: dict, + loaded: LoadedTypes, author: User, embed: Embed, confirm_callback: Callable @@ -83,14 +84,14 @@ def __init__( self.list_name = list_name self.list_type = list_type self.settings = settings - self.loaded_settings = loaded_settings + self.loaded = loaded self.embed = embed self.confirm_callback = confirm_callback - self.settings_repr_dict = {name: to_serializable(value) for name, value in settings.items()} - populate_embed_from_dict(embed, self.settings_repr_dict) + settings_repr_dict = {name: to_serializable(value) for name, value in settings.items()} + populate_embed_from_dict(embed, settings_repr_dict) - self.type_per_setting_name = {setting: info[2] for setting, info in loaded_settings.items()} + self.type_per_setting_name = {setting: info[2] for setting, info in loaded.settings.items()} edit_select = CustomCallbackSelect( self._prompt_new_value, @@ -160,7 +161,7 @@ def copy(self) -> FilterListAddView: self.list_name, self.list_type, self.settings, - self.loaded_settings, + self.loaded, self.author, self.embed, self.confirm_callback @@ -175,7 +176,7 @@ def __init__( filter_list: FilterList, list_type: ListType, new_settings: dict, - loaded_settings: dict, + loaded: LoadedTypes, author: User, embed: Embed, confirm_callback: Callable @@ -184,14 +185,14 @@ def __init__( self.filter_list = filter_list self.list_type = list_type self.settings = new_settings - self.loaded_settings = loaded_settings + self.loaded = loaded self.embed = embed self.confirm_callback = confirm_callback - self.settings_repr_dict = build_filterlist_repr_dict(filter_list, list_type, new_settings) - populate_embed_from_dict(embed, self.settings_repr_dict) + settings_repr_dict = build_filterlist_repr_dict(filter_list, list_type, new_settings) + populate_embed_from_dict(embed, settings_repr_dict) - self.type_per_setting_name = {setting: info[2] for setting, info in loaded_settings.items()} + self.type_per_setting_name = {setting: info[2] for setting, info in loaded.settings.items()} edit_select = CustomCallbackSelect( self._prompt_new_value, @@ -220,11 +221,11 @@ async def cancel(self, interaction: Interaction, button: discord.ui.Button) -> N self.stop() def current_value(self, setting_name: str) -> Any: - """Get the current value stored for the setting or MISSING if none found.""" if setting_name in self.settings: return self.settings[setting_name] - if setting_name in self.settings_repr_dict: - return self.settings_repr_dict[setting_name] + settings_repr_dict = build_filterlist_repr_dict(self.filter_list, self.list_type, self.settings) + if setting_name in settings_repr_dict: + return settings_repr_dict[setting_name] return MISSING async def update_embed( @@ -268,7 +269,7 @@ def copy(self) -> FilterListEditView: self.filter_list, self.list_type, self.settings, - self.loaded_settings, + self.loaded, self.author, self.embed, self.confirm_callback diff --git a/bot/exts/filtering/_ui/search.py b/bot/exts/filtering/_ui/search.py index 352db00326..6b781f8c94 100644 --- a/bot/exts/filtering/_ui/search.py +++ b/bot/exts/filtering/_ui/search.py @@ -1,4 +1,5 @@ from collections.abc import Callable +from dataclasses import dataclass from typing import Any import discord @@ -7,7 +8,7 @@ from bot.exts.filtering._filter_lists import FilterList, ListType from bot.exts.filtering._filters.filter import Filter -from bot.exts.filtering._settings_types.settings_entry import SettingsEntry +from bot.exts.filtering._loaded_types import LoadedTypes from bot.exts.filtering._ui.filter import filter_overrides_for_ui from bot.exts.filtering._ui.ui import ( COMPONENT_TIMEOUT, @@ -20,11 +21,48 @@ ) +def _validate_and_process_setting( + setting: str, + raw_value: str, + settings: dict[str, Any], + filter_settings: dict[str, Any], + loaded: LoadedTypes, + filter_type: type[Filter] | None, +) -> type[Filter] | None: + """Validate and parse a single setting, mutating settings and filter_settings in place.""" + if setting in loaded.settings: + type_ = loaded.settings[setting][2] + try: + settings[setting] = parse_value(raw_value, type_) + except (TypeError, ValueError) as e: + raise BadArgument(e) + elif "/" not in setting: + raise BadArgument(f"{setting!r} is not a recognized setting.") + else: + filter_name, filter_setting_name = setting.split("/", maxsplit=1) + if not filter_type: + if filter_name in loaded.filters: + filter_type = loaded.filters[filter_name] + else: + raise BadArgument(f"There's no filter type named {filter_name!r}.") + if filter_name.lower() != filter_type.name.lower(): + raise BadArgument( + f"A setting for a {filter_name!r} filter was provided, " + f"but the filter name is {filter_type.name!r}" + ) + if filter_setting_name not in loaded.filter_settings[filter_type.name]: + raise BadArgument(f"{setting!r} is not a recognized setting.") + type_ = loaded.filter_settings[filter_type.name][filter_setting_name][2] + try: + filter_settings[filter_setting_name] = parse_value(settings.pop(setting), type_) + except (TypeError, ValueError) as e: + raise BadArgument(e) + return filter_type + + def search_criteria_converter( filter_lists: dict, - loaded_filters: dict, - loaded_settings: dict, - loaded_filter_settings: dict, + loaded: LoadedTypes, filter_type: type[Filter] | None, input_data: str ) -> tuple[dict[str, Any], dict[str, Any], type[Filter]]: @@ -47,42 +85,17 @@ def search_criteria_converter( filter_settings = {} for setting, _ in list(settings.items()): - if setting in loaded_settings: # It's a filter list setting - type_ = loaded_settings[setting][2] - try: - settings[setting] = parse_value(settings[setting], type_) - except (TypeError, ValueError) as e: - raise BadArgument(e) - elif "/" not in setting: - raise BadArgument(f"{setting!r} is not a recognized setting.") - else: # It's a filter setting - filter_name, filter_setting_name = setting.split("/", maxsplit=1) - if not filter_type: - if filter_name in loaded_filters: - filter_type = loaded_filters[filter_name] - else: - raise BadArgument(f"There's no filter type named {filter_name!r}.") - if filter_name.lower() != filter_type.name.lower(): - raise BadArgument( - f"A setting for a {filter_name!r} filter was provided, " - f"but the filter name is {filter_type.name!r}" - ) - if filter_setting_name not in loaded_filter_settings[filter_type.name]: - raise BadArgument(f"{setting!r} is not a recognized setting.") - type_ = loaded_filter_settings[filter_type.name][filter_setting_name][2] - try: - filter_settings[filter_setting_name] = parse_value(settings.pop(setting), type_) - except (TypeError, ValueError) as e: - raise BadArgument(e) - - # Pull templates settings and apply them. + filter_type = _validate_and_process_setting( + setting, settings[setting], settings, filter_settings, + loaded, filter_type, + ) + if template is not None: try: t_settings, t_filter_settings, filter_type = template_settings(template, filter_lists, filter_type) except ValueError as e: raise BadArgument(str(e)) else: - # The specified settings go on top of the template settings = t_settings | settings filter_settings = t_filter_settings | filter_settings @@ -145,9 +158,7 @@ def __init__( settings: dict[str, Any], filter_settings: dict[str, Any], loaded_filter_lists: dict[str, FilterList], - loaded_filters: dict[str, type[Filter]], - loaded_settings: dict[str, tuple[str, SettingsEntry, type]], - loaded_filter_settings: dict[str, dict[str, tuple[str, SettingsEntry, type]]], + loaded: LoadedTypes, author: discord.User | discord.Member, embed: discord.Embed, confirm_callback: Callable @@ -157,9 +168,7 @@ def __init__( self.settings = settings self.filter_settings = filter_settings self.loaded_filter_lists = loaded_filter_lists - self.loaded_filters = loaded_filters - self.loaded_settings = loaded_settings - self.loaded_filter_settings = loaded_filter_settings + self.loaded = loaded self.embed = embed self.confirm_callback = confirm_callback @@ -171,11 +180,11 @@ def __init__( settings_repr_dict = build_search_repr_dict(settings, filter_settings, filter_type) populate_embed_from_dict(embed, settings_repr_dict) - self.type_per_setting_name = {setting: info[2] for setting, info in loaded_settings.items()} + self.type_per_setting_name = {setting: info[2] for setting, info in loaded.settings.items()} if filter_type: self.type_per_setting_name.update({ f"{filter_type.name}/{name}": type_ - for name, (_, _, type_) in loaded_filter_settings.get(filter_type.name, {}).items() + for name, (_, _, type_) in loaded.filter_settings.get(filter_type.name, {}).items() }) add_select = CustomCallbackSelect( @@ -290,7 +299,7 @@ async def apply_template(self, template_id: str, embed_message: discord.Message, """Set any unset criteria with settings values from the given filter.""" try: settings, filter_settings, self.filter_type = template_settings( - template_id, self.loaded_filter_lists, self.filter_type + template_id, self.filter_resources.filter_lists, self.filter_type ) except BadArgument as e: # The interaction object is necessary to send an ephemeral message. await interaction.response.send_message(f":x: {e}", ephemeral=True) @@ -306,8 +315,8 @@ async def apply_template(self, template_id: str, embed_message: discord.Message, async def apply_filter_type(self, type_name: str, embed_message: discord.Message, interaction: Interaction) -> None: """Set a new filter type and reset any criteria for settings of the old filter type.""" - if type_name.lower() not in self.loaded_filters: - if type_name.lower()[:-1] not in self.loaded_filters: # In case the user entered the plural form. + if type_name.lower() not in self.loaded.filters: + if type_name.lower()[:-1] not in self.loaded.filters: # In case the user entered the plural form. await interaction.response.send_message(f":x: No such filter type {type_name!r}.", ephemeral=True) return type_name = type_name[:-1] @@ -316,7 +325,7 @@ async def apply_filter_type(self, type_name: str, embed_message: discord.Message if self.filter_type and type_name == self.filter_type.name: return - self.filter_type = self.loaded_filters[type_name] + self.filter_type = self.loaded.filters[type_name] self.filter_settings = {} self.embed.clear_fields() await embed_message.edit(embed=self.embed, view=self.copy()) @@ -329,9 +338,7 @@ def copy(self) -> SearchEditView: self.settings, self.filter_settings, self.loaded_filter_lists, - self.loaded_filters, - self.loaded_settings, - self.loaded_filter_settings, + self.loaded, self.author, self.embed, self.confirm_callback diff --git a/bot/exts/filtering/filtering.py b/bot/exts/filtering/filtering.py index 210ae3fb05..bbafbd9839 100644 --- a/bot/exts/filtering/filtering.py +++ b/bot/exts/filtering/filtering.py @@ -26,9 +26,10 @@ from bot.bot import Bot from bot.constants import BaseURLs, Channels, Guild, MODERATION_ROLES, Roles from bot.exts.backend.branding._repository import HEADERS, PARAMS -from bot.exts.filtering._filter_context import Event, FilterContext +from bot.exts.filtering._filter_context import Event, FilterContent, FilterContext, FilterSource from bot.exts.filtering._filter_lists import FilterList, ListType, ListTypeConverter, filter_list_types from bot.exts.filtering._filter_lists.filter_list import AtomicList +from bot.exts.filtering._loaded_types import LoadedTypes from bot.exts.filtering._filters.filter import Filter, UniqueFilter from bot.exts.filtering._settings import ActionSettings from bot.exts.filtering._settings_types.actions.infraction_and_notification import Infraction @@ -39,7 +40,7 @@ populate_embed_from_dict, ) from bot.exts.filtering._ui.filter_list import FilterListAddView, FilterListEditView, settings_converter -from bot.exts.filtering._ui.search import SearchEditView, search_criteria_converter +from bot.exts.filtering._ui.search import FilterResources, SearchEditView, search_criteria_converter from bot.exts.filtering._ui.ui import ( AlertView, ArgumentCompletionView, @@ -55,6 +56,7 @@ from bot.utils.channel import is_mod_channel from bot.utils.lock import lock_arg from bot.utils.message_cache import MessageCache +from dataclasses import dataclass, field log = get_logger(__name__) @@ -75,6 +77,13 @@ async def _extract_text_file_content(att: discord.Attachment) -> str: return f"{att.filename}: {first_n_lines}" +@dataclass +class LoadedFilterData: + settings: dict = field(default_factory=dict) + filters: dict = field(default_factory=dict) + filter_settings: dict = field(default_factory=dict) + + class Filtering(Cog): """Filtering and alerting for content posted on the server.""" @@ -93,9 +102,7 @@ def __init__(self, bot: Bot): self.delete_scheduler = scheduling.Scheduler(self.__class__.__name__) self.webhook: discord.Webhook | None = None - self.loaded_settings = {} - self.loaded_filters = {} - self.loaded_filter_settings = {} + self.loaded = LoadedTypes(filters={}, settings={}, filter_settings={}) self.message_cache = MessageCache(CACHE_SIZE, newest_first=True) @@ -159,7 +166,7 @@ def collect_loaded_types(self, example_list: AtomicList) -> None: """ # Get the filter types used by each filter list. for filter_list in self.filter_lists.values(): - self.loaded_filters.update({filter_type.name: filter_type for filter_type in filter_list.filter_types}) + self.loaded.filters.update({filter_type.name: filter_type for filter_type in filter_list.filter_types}) # Get the setting types used by each filter list. if self.filter_lists: @@ -174,24 +181,24 @@ def collect_loaded_types(self, example_list: AtomicList) -> None: if isinstance(setting_entry.description, str): # If it's a string, then the settings entry matches a single field in the DB, # and its name is the setting type's name attribute. - self.loaded_settings[setting_entry.name] = ( + self.loaded.settings[setting_entry.name] = ( setting_entry.description, setting_entry, type_hints[setting_entry.name] ) else: # Otherwise, the setting entry works with compound settings. - self.loaded_settings.update({ + self.loaded.settings.update({ subsetting: (description, setting_entry, type_hints[subsetting]) for subsetting, description in setting_entry.description.items() }) # Get the settings per filter as well. - for filter_name, filter_type in self.loaded_filters.items(): + for filter_name, filter_type in self.loaded.filters.items(): extra_fields_type = filter_type.extra_fields_type if not extra_fields_type: continue type_hints = get_type_hints(extra_fields_type) # A class var with a `_description` suffix is expected per field name. - self.loaded_filter_settings[filter_name] = { + self.loaded.filter_settings[filter_name] = { field_name: ( getattr(extra_fields_type, f"{field_name}_description", ""), extra_fields_type, @@ -285,13 +292,13 @@ async def on_message_edit(self, before: discord.Message, after: discord.Message) @Cog.listener() async def on_voice_state_update(self, member: discord.Member, *_) -> None: """Checks for bad words in usernames when users join, switch or leave a voice channel.""" - ctx = FilterContext(Event.NICKNAME, member, None, member.display_name, None) + ctx = FilterContext(FilterSource(Event.NICKNAME, member, None, None), FilterContent(member.display_name)) await self._check_bad_display_name(ctx) @Cog.listener() async def on_thread_create(self, thread: Thread) -> None: """Check for bad words in new thread names.""" - ctx = FilterContext(Event.THREAD_NAME, thread.owner, thread, thread.name, None) + ctx = FilterContext(FilterSource(Event.THREAD_NAME, thread.owner, thread, None), FilterContent(thread.name)) await self._check_bad_name(ctx) async def filter_snekbox_output( @@ -329,7 +336,7 @@ async def blocklist(self, ctx: Context) -> None: await ctx.send_help(ctx.command) @blocklist.command(name="list", aliases=("get",)) - async def bl_list(self, ctx: Context, list_name: str | None = None) -> None: + async def bl_list(self, ctx: Context, list_name: str | None=None) -> None: """List the contents of a specified blacklist.""" result = await self._resolve_list_type_and_name(ctx, ListType.DENY, list_name, exclude="list_type") if not result: @@ -345,7 +352,7 @@ async def bl_add( list_name: str | None, content: str, *, - description_and_settings: str | None = None + description_and_settings: str | None=None ) -> None: """ Add a blocked filter to the specified filter list. @@ -372,7 +379,7 @@ async def allowlist(self, ctx: Context) -> None: await ctx.send_help(ctx.command) @allowlist.command(name="list", aliases=("get",)) - async def al_list(self, ctx: Context, list_name: str | None = None) -> None: + async def al_list(self, ctx: Context, list_name: str | None=None) -> None: """List the contents of a specified whitelist.""" result = await self._resolve_list_type_and_name(ctx, ListType.ALLOW, list_name, exclude="list_type") if not result: @@ -388,7 +395,7 @@ async def al_add( list_name: str | None, content: str, *, - description_and_settings: str | None = None + description_and_settings: str | None=None ) -> None: """ Add an allowed filter to the specified filter list. @@ -409,7 +416,7 @@ async def al_add( # region: filter commands @commands.group(aliases=("filters", "f"), invoke_without_command=True) - async def filter(self, ctx: Context, id_: int | None = None) -> None: + async def filter(self, ctx: Context, id_: int | None=None) -> None: """ Group for managing filters. @@ -447,8 +454,8 @@ async def filter(self, ctx: Context, id_: int | None = None) -> None: async def f_list( self, ctx: Context, - list_type: ListTypeConverter | None = None, - list_name: str | None = None, + list_type: ListTypeConverter | None=None, + list_name: str | None=None, ) -> None: """List the contents of a specified list of filters.""" result = await self._resolve_list_type_and_name(ctx, list_type, list_name) @@ -462,14 +469,14 @@ async def f_list( async def f_describe(self, ctx: Context, filter_name: str | None) -> None: """Show a description of the specified filter, or a list of possible values if no name is specified.""" if not filter_name: - filter_names = [f"» {f}" for f in self.loaded_filters] + filter_names = [f"» {f}" for f in self.loaded.filters] embed = Embed(colour=Colour.blue()) embed.set_author(name="List of filter names") await LinePaginator.paginate(filter_names, ctx, embed, max_lines=10, empty=False) else: - filter_type = self.loaded_filters.get(filter_name) + filter_type = self.loaded.filters.get(filter_name) if not filter_type: - filter_type = self.loaded_filters.get(filter_name[:-1]) # A plural form or a typo. + filter_type = self.loaded.filters.get(filter_name[:-1]) # A plural form or a typo. if not filter_type: await ctx.send(f":x: There's no filter type named {filter_name!r}.") return @@ -487,7 +494,7 @@ async def f_add( list_name: str | None, content: str, *, - description_and_settings: str | None = None + description_and_settings: str | None=None ) -> None: """ Add a filter to the specified filter list. @@ -516,7 +523,7 @@ async def f_edit( noui: Literal["noui"] | None, filter_id: int, *, - description_and_settings: str | None = None + description_and_settings: str | None=None ) -> None: """ Edit a filter specified by its ID. @@ -542,8 +549,7 @@ async def f_edit( description, new_settings, new_filter_settings = description_and_settings_converter( filter_list, list_type, filter_type, - self.loaded_settings, - self.loaded_filter_settings, + self.loaded, description_and_settings ) @@ -574,16 +580,17 @@ async def f_edit( f"run `{constants.Bot.prefix}filterlist describe {list_type.name} {filter_list.name}`." )) + filter_target = filters_ui.FilterTarget(filter_list, list_type, filter_type) + filter_content = filters_ui.FilterContent(content, description) + type_per_setting_name = filters_ui.build_type_per_setting_name( + filter_type, self.loaded_data.settings, self.loaded_data.filter_settings + ) view = filters_ui.FilterEditView( - filter_list, - list_type, - filter_type, - content, - description, + filter_target, + filter_content, settings, filter_settings, - self.loaded_settings, - self.loaded_filter_settings, + self.loaded, ctx.author, embed, patch_func @@ -593,6 +600,7 @@ async def f_edit( @filter.command(name="delete", aliases=("d", "remove")) async def f_delete(self, ctx: Context, filter_id: int) -> None: """Delete the filter specified by its ID.""" + async def delete_list() -> None: """The actual removal routine.""" await bot.instance.api_client.delete(f"bot/filter/filters/{filter_id}") @@ -614,8 +622,8 @@ async def delete_list() -> None: async def setting(self, ctx: Context, setting_name: str | None) -> None: """Show a description of the specified setting, or a list of possible settings if no name is specified.""" if not setting_name: - settings_list = [f"» {setting_name}" for setting_name in self.loaded_settings] - for filter_name, filter_settings in self.loaded_filter_settings.items(): + settings_list = [f"» {setting_name}" for setting_name in self.loaded.settings] + for filter_name, filter_settings in self.loaded.filter_settings.items(): settings_list.extend(f"» {filter_name}/{setting}" for setting in filter_settings) embed = Embed(colour=Colour.blue()) embed.set_author(name="List of setting names") @@ -623,15 +631,15 @@ async def setting(self, ctx: Context, setting_name: str | None) -> None: else: # The setting is either in a SettingsEntry subclass, or a pydantic model. - setting_data = self.loaded_settings.get(setting_name) + setting_data = self.loaded.settings.get(setting_name) description = None if setting_data: description = setting_data[0] elif "/" in setting_name: # It's a filter specific setting. filter_name, filter_setting_name = setting_name.split("/", maxsplit=1) - if filter_name in self.loaded_filter_settings: - if filter_setting_name in self.loaded_filter_settings[filter_name]: - description = self.loaded_filter_settings[filter_name][filter_setting_name][0] + if filter_name in self.loaded.filter_settings: + if filter_setting_name in self.loaded.filter_settings[filter_name]: + description = self.loaded.filter_settings[filter_name][filter_setting_name][0] if description is None: await ctx.send(f":x: There's no setting type named {setting_name!r}.") return @@ -656,10 +664,10 @@ async def f_match( raise BadArgument("Please provide input.") if message: user = None if no_user else message.author - filter_ctx = FilterContext(Event.MESSAGE, user, message.channel, message.content, message, message.embeds) + filter_ctx = FilterContext(FilterSource(Event.MESSAGE, user, message.channel, message), FilterContent(message.content, message.embeds)) else: python_general = ctx.guild.get_channel(Channels.python_general) - filter_ctx = FilterContext(Event.MESSAGE, None, python_general, string, None) + filter_ctx = FilterContext(FilterSource(Event.MESSAGE, None, python_general, None), FilterContent(string)) _, _, triggers = await self._resolve_action(filter_ctx) lines = [] @@ -679,7 +687,7 @@ async def f_search( noui: Literal["noui"] | None, filter_type_name: str | None, *, - settings: str = "" + settings: str="" ) -> None: """ Find filters with the provided settings. The format is identical to that of the add and edit commands. @@ -690,9 +698,9 @@ async def f_search( filter_type = None if filter_type_name: filter_type_name = filter_type_name.lower() - filter_type = self.loaded_filters.get(filter_type_name) + filter_type = self.loaded.filters.get(filter_type_name) if not filter_type: - self.loaded_filters.get(filter_type_name[:-1]) # In case the user tried to specify the plural form. + self.loaded.filters.get(filter_type_name[:-1]) # In case the user tried to specify the plural form. # If settings were provided with no filter_type, discord.py will capture the first word as the filter type. if filter_type is None and filter_type_name is not None: if settings: @@ -703,9 +711,7 @@ async def f_search( settings, filter_settings, filter_type = search_criteria_converter( self.filter_lists, - self.loaded_filters, - self.loaded_settings, - self.loaded_filter_settings, + self.loaded, filter_type, settings ) @@ -715,14 +721,18 @@ async def f_search( return embed = Embed(colour=Colour.blue()) + filter_resources = FilterResources( + filter_lists=self.filter_lists, + filters=self.loaded_data.filters, + settings=self.loaded_data.settings, + filter_settings=self.loaded_data.filter_settings, + ) view = SearchEditView( filter_type, settings, filter_settings, self.filter_lists, - self.loaded_filters, - self.loaded_settings, - self.loaded_filter_settings, + self.loaded, ctx.author, embed, self._search_filters @@ -731,7 +741,7 @@ async def f_search( @filter.command(root_aliases=("compfilter", "compf")) async def compadd( - self, ctx: Context, list_name: str | None, content: str, *, description: str | None = "Phishing" + self, ctx: Context, list_name: str | None, content: str, *, description: str | None="Phishing" ) -> None: """Add a filter to detect a compromised account. Will apply the equivalent of a compban if triggered.""" result = await self._resolve_list_type_and_name(ctx, ListType.DENY, list_name, exclude="list_type") @@ -762,7 +772,7 @@ async def filterlist(self, ctx: Context) -> None: @filterlist.command(name="describe", aliases=("explain", "manual", "id")) async def fl_describe( - self, ctx: Context, list_type: ListTypeConverter | None = None, list_name: str | None = None + self, ctx: Context, list_type: ListTypeConverter | None=None, list_name: str | None=None ) -> None: """Show a description of the specified filter list, or a list of possible values if no values are provided.""" if not list_type and not list_name: @@ -812,13 +822,13 @@ async def fl_add(self, ctx: Context, list_type: ListTypeConverter, list_name: st embed = Embed(colour=Colour.blue()) embed.set_author(name=f"New Filter List - {list_description.title()}") - settings = {name: starting_value(value[2]) for name, value in self.loaded_settings.items()} + settings = {name: starting_value(value[2]) for name, value in self.loaded.settings.items()} view = FilterListAddView( list_name, list_type, settings, - self.loaded_settings, + self.loaded, ctx.author, embed, self._post_filter_list @@ -831,8 +841,8 @@ async def fl_edit( self, ctx: Context, noui: Literal["noui"] | None, - list_type: ListTypeConverter | None = None, - list_name: str | None = None, + list_type: ListTypeConverter | None=None, + list_name: str | None=None, *, settings: str | None ) -> None: @@ -848,7 +858,7 @@ async def fl_edit( if result is None: return list_type, filter_list = result - settings = settings_converter(self.loaded_settings, settings) + settings = settings_converter(self.loaded, settings) if noui: try: await self._patch_filter_list(ctx.message, filter_list, list_type, settings) @@ -864,7 +874,7 @@ async def fl_edit( filter_list, list_type, settings, - self.loaded_settings, + self.loaded, ctx.author, embed, self._patch_filter_list @@ -874,9 +884,10 @@ async def fl_edit( @filterlist.command(name="delete", aliases=("remove",)) @has_any_role(Roles.admins) async def fl_delete( - self, ctx: Context, list_type: ListTypeConverter | None = None, list_name: str | None = None + self, ctx: Context, list_type: ListTypeConverter | None=None, list_name: str | None=None ) -> None: """Remove the filter list and all of its filters from the database.""" + async def delete_list() -> None: """The actual removal routine.""" list_data = await bot.instance.api_client.get(f"bot/filter/filter_lists/{list_id}") @@ -1044,7 +1055,7 @@ async def _check_bad_name(self, ctx: FilterContext) -> FilterContext: return new_ctx async def _resolve_list_type_and_name( - self, ctx: Context, list_type: ListType | None = None, list_name: str | None = None, *, exclude: str = "" + self, ctx: Context, list_type: ListType | None=None, list_name: str | None=None, *, exclude: str="" ) -> tuple[ListType, FilterList] | None: """Prompt the user to complete the list type or list name if one of them is missing.""" if list_name is None: @@ -1111,7 +1122,7 @@ async def _add_filter( list_type: ListType, filter_list: FilterList, content: str, - description_and_settings: str | None = None + description_and_settings: str | None=None ) -> None: """Add a filter to the database.""" # Validations. @@ -1127,8 +1138,7 @@ async def _add_filter( filter_list, list_type, filter_type, - self.loaded_settings, - self.loaded_filter_settings, + self.loaded, description_and_settings ) @@ -1155,16 +1165,17 @@ async def _add_filter( f"run `{constants.Bot.prefix}filterlist describe {list_type.name} {filter_list.name}`." )) + filter_target = filters_ui.FilterTarget(filter_list, list_type, filter_type) + filter_content = filters_ui.FilterContent(content, description) + type_per_setting_name = filters_ui.build_type_per_setting_name( + filter_type, self.loaded_data.settings, self.loaded_data.filter_settings + ) view = filters_ui.FilterEditView( - filter_list, - list_type, - filter_type, - content, - description, + filter_target, + filter_content, settings, filter_settings, - self.loaded_settings, - self.loaded_filter_settings, + self.loaded, ctx.author, embed, self._post_new_filter @@ -1189,7 +1200,7 @@ def _identical_filters_message(content: str, filter_list: FilterList, list_type: @staticmethod async def _maybe_alert_auto_infraction( - filter_list: FilterList, list_type: ListType, filter_: Filter, old_filter: Filter | None = None + filter_list: FilterList, list_type: ListType, filter_: Filter, old_filter: Filter | None=None ) -> None: """If the filter is new and applies an auto-infraction, or was edited to apply a different one, log it.""" infraction_type = filter_.overrides[0].get("infraction_type") @@ -1439,7 +1450,7 @@ async def weekly_auto_infraction_report_task(self) -> None: async def send_weekly_auto_infraction_report( self, - channel: discord.TextChannel | discord.Thread | None = None, + channel: discord.TextChannel | discord.Thread | None=None, ) -> None: """ Send a list of auto-infractions added in the last 7 days to the specified channel. @@ -1462,7 +1473,7 @@ async def send_weekly_auto_infraction_report( for sublist in filter_list.values(): default_infraction_type = sublist.default("infraction_type") for filter_ in sublist.filters.values(): - if max(filter_.created_at, filter_.updated_at) < seven_days_ago: + if filter_.last_updated < seven_days_ago: continue infraction_type = filter_.overrides[0].get("infraction_type") if ( @@ -1474,7 +1485,7 @@ async def send_weekly_auto_infraction_report( # Nicely format the output so each filter list type is grouped lines = [f"**Auto-infraction filters added since {seven_days_ago.format('YYYY-MM-DD')}**"] for list_label, filters in found_filters.items(): - lines.append("\n".join([f"**{list_label.title()}**"]+[f"{filter_} ({infr})" for filter_, infr in filters])) + lines.append("\n".join([f"**{list_label.title()}**"] + [f"{filter_} ({infr})" for filter_, infr in filters])) if len(lines) == 1: lines.append("Nothing to show") diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index 4546fc14f3..23cc85628d 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -51,20 +51,21 @@ class DocCog(commands.Cog): """A set of commands for querying & displaying documentation.""" def __init__(self, bot: Bot): - # Contains URLs to documentation home pages. - # Used to calculate inventory diffs on refreshes and to display all currently stored inventories. - self.base_urls = {} self.bot = bot - self.doc_symbols: dict[str, DocItem] = {} # Maps symbol names to objects containing their metadata. - self.item_fetcher = _batch_parser.BatchParser() - # Maps a conflicting symbol name to a list of the new, disambiguated names created from conflicts with the name. - self.renamed_symbols = defaultdict(list) + self._inventory = SimpleNamespace( + base_urls={}, + doc_symbols={}, + renamed_symbols=defaultdict(list), + item_fetcher=_batch_parser.BatchParser(), + ) self.inventory_scheduler = Scheduler(self.__class__.__name__) - self.refresh_event = asyncio.Event() - self.refresh_event.set() - self.symbol_get_event = SharedEvent() + self.inventory_sync = SimpleNamespace( + refresh_event=asyncio.Event(), + symbol_get_event=SharedEvent(), + ) + self.inventory_sync.refresh_event.set() async def cog_load(self) -> None: """Refresh documentation inventory on cog initialization.""" @@ -81,7 +82,7 @@ def update_single(self, package_name: str, base_url: str, inventory: InventoryDi absolute paths that link to specific symbols * `package` is the content of a intersphinx inventory. """ - self.base_urls[package_name] = base_url + self._inventory.base_urls[package_name] = base_url for group, items in inventory.items(): for symbol_name, relative_doc_url in items: @@ -105,8 +106,8 @@ def update_single(self, package_name: str, base_url: str, inventory: InventoryDi sys.intern(relative_url_path), symbol_id, ) - self.doc_symbols[symbol_name] = doc_item - self.item_fetcher.add_item(doc_item) + self._inventory.doc_symbols[symbol_name] = doc_item + self._inventory.item_fetcher.add_item(doc_item) log.trace(f"Fetched inventory for {package_name}.") @@ -155,23 +156,23 @@ def ensure_unique_symbol_name(self, package_name: str, group_name: str, symbol_n If the existing symbol was renamed or there was no conflict, the returned name is equivalent to `symbol_name`. """ - if (item := self.doc_symbols.get(symbol_name)) is None: + if (item := self._inventory.doc_symbols.get(symbol_name)) is None: return symbol_name # There's no conflict so it's fine to simply use the given symbol name. def rename(prefix: str, *, rename_extant: bool = False) -> str: new_name = f"{prefix}.{symbol_name}" - if new_name in self.doc_symbols: + if new_name in self._inventory.doc_symbols: # If there's still a conflict, qualify the name further. if rename_extant: new_name = f"{item.package}.{item.group}.{symbol_name}" else: new_name = f"{package_name}.{group_name}.{symbol_name}" - self.renamed_symbols[symbol_name].append(new_name) + self._inventory.renamed_symbols[symbol_name].append(new_name) if rename_extant: # Instead of renaming the current symbol, rename the symbol with which it conflicts. - self.doc_symbols[new_name] = self.doc_symbols[symbol_name] + self._inventory.doc_symbols[new_name] = self._inventory.doc_symbols[symbol_name] return symbol_name return new_name @@ -196,15 +197,15 @@ def rename(prefix: str, *, rename_extant: bool = False) -> str: async def refresh_inventories(self) -> None: """Refresh internal documentation inventories.""" - self.refresh_event.clear() - await self.symbol_get_event.wait() + self.inventory_sync.refresh_event.clear() + await self.inventory_sync.symbol_get_event.wait() log.debug("Refreshing documentation inventory...") self.inventory_scheduler.cancel_all() - self.base_urls.clear() - self.doc_symbols.clear() - self.renamed_symbols.clear() - await self.item_fetcher.clear() + self._inventory.base_urls.clear() + self._inventory.doc_symbols.clear() + self._inventory.renamed_symbols.clear() + await self._inventory.item_fetcher.clear() coros = [ self.update_or_reschedule_inventory( @@ -213,7 +214,7 @@ async def refresh_inventories(self) -> None: ] await asyncio.gather(*coros) log.debug("Finished inventory refresh.") - self.refresh_event.set() + self.inventory_sync.refresh_event.set() def get_symbol_item(self, symbol_name: str) -> tuple[str, DocItem | None]: """ @@ -222,10 +223,10 @@ def get_symbol_item(self, symbol_name: str) -> tuple[str, DocItem | None]: If the doc item is not found directly from the passed in name and the name contains a space, the first word of the name will be attempted to be used to get the item. """ - doc_item = self.doc_symbols.get(symbol_name) + doc_item = self._inventory.doc_symbols.get(symbol_name) if doc_item is None and " " in symbol_name: symbol_name = symbol_name.split(maxsplit=1)[0] - doc_item = self.doc_symbols.get(symbol_name) + doc_item = self._inventory.doc_symbols.get(symbol_name) return symbol_name, doc_item @@ -241,7 +242,7 @@ async def get_symbol_markdown(self, doc_item: DocItem) -> str: if markdown is None: log.debug(f"Redis cache miss with {doc_item}.") try: - markdown = await self.item_fetcher.get_markdown(doc_item) + markdown = await self._inventory.item_fetcher.get_markdown(doc_item) except aiohttp.ClientError as e: log.warning(f"A network error has occurred when requesting parsing of {doc_item}.", exc_info=e) @@ -264,11 +265,11 @@ async def create_symbol_embed(self, symbol_name: str) -> discord.Embed | None: First check the DocRedisCache before querying the cog's `BatchParser`. """ log.trace(f"Building embed for symbol `{symbol_name}`") - if not self.refresh_event.is_set(): + if not self.inventory_sync.refresh_event.is_set(): log.debug("Waiting for inventories to be refreshed before processing item.") - await self.refresh_event.wait() + await self.inventory_sync.refresh_event.wait() # Ensure a refresh can't run in case of a context switch until the with block is exited - with self.symbol_get_event: + with self.inventory_sync.symbol_get_event: symbol_name, doc_item = self.get_symbol_item(symbol_name) if doc_item is None: log.debug("Symbol does not exist.") @@ -278,8 +279,8 @@ async def create_symbol_embed(self, symbol_name: str) -> discord.Embed | None: # Show all symbols with the same name that were renamed in the footer, # with a max of 200 chars. - if symbol_name in self.renamed_symbols: - renamed_symbols = ", ".join(self.renamed_symbols[symbol_name]) + if symbol_name in self._inventory.renamed_symbols: + renamed_symbols = ", ".join(self._inventory.renamed_symbols[symbol_name]) footer_text = textwrap.shorten("Similar names: " + renamed_symbols, 200, placeholder=" ...") else: footer_text = "" @@ -312,12 +313,12 @@ async def get_command(self, ctx: commands.Context, *, symbol_name: str | None) - """ if not symbol_name: inventory_embed = discord.Embed( - title=f"All inventories (`{len(self.base_urls)}` total)", + title=f"All inventories (`{len(self._inventory.base_urls)}` total)", colour=discord.Colour.blue() ) - lines = sorted(f"- [`{name}`]({url})" for name, url in self.base_urls.items()) - if self.base_urls: + lines = sorted(f"- [`{name}`]({url})" for name, url in self._inventory.base_urls.items()) + if self._inventory.base_urls: await LinePaginator.paginate(lines, ctx, inventory_embed, max_size=400, empty=False) else: @@ -418,10 +419,10 @@ async def delete_command(self, ctx: commands.Context, package_name: PackageName) @lock(NAMESPACE, COMMAND_LOCK_SINGLETON, raise_error=True) async def refresh_command(self, ctx: commands.Context) -> None: """Refresh inventories and show the difference.""" - old_inventories = set(self.base_urls) + old_inventories = set(self._inventory.base_urls) async with ctx.typing(): await self.refresh_inventories() - new_inventories = set(self.base_urls) + new_inventories = set(self._inventory.base_urls) if added := ", ".join(new_inventories - old_inventories): added = "+ " + added @@ -444,7 +445,7 @@ async def clear_cache_command( ) -> None: """Clear the persistent redis cache for `package`.""" if await doc_cache.delete(package_name): - await self.item_fetcher.stale_inventory_notifier.symbol_counter.delete(package_name) + await self._inventory.item_fetcher.stale_inventory_notifier.symbol_counter.delete(package_name) await ctx.send(f"Successfully cleared the cache for `{package_name}`.") else: await ctx.send("No keys matching the package found.") @@ -452,4 +453,4 @@ async def clear_cache_command( async def cog_unload(self) -> None: """Clear scheduled inventories, queued symbols and cleanup task on cog unload.""" self.inventory_scheduler.cancel_all() - await self.item_fetcher.clear() + await self._inventory.item_fetcher.clear() diff --git a/bot/exts/info/doc/_parsing.py b/bot/exts/info/doc/_parsing.py index b64f659671..8755d07ac3 100644 --- a/bot/exts/info/doc/_parsing.py +++ b/bot/exts/info/doc/_parsing.py @@ -134,6 +134,49 @@ def _truncate_signatures(signatures: Collection[str]) -> list[str] | Collection[ return formatted_signatures +def _truncate_without_boundary(result: str, truncate_index: int) -> str: + """ + Truncate `result` at a natural boundary when no Markdown element boundary is suitable. + + Removes incomplete codeblocks and finds the last occurrence of a preferred substring. + """ + force_truncated = result[:truncate_index] + if force_truncated.count("```") % 2: + force_truncated = force_truncated[:force_truncated.rfind("```")] + for string_ in ("\n\n", "\n", ". ", ", ", ",", " "): + cutoff = force_truncated.rfind(string_) + if cutoff != -1: + return force_truncated[:cutoff].strip(_TRUNCATE_STRIP_CHARACTERS) + "..." + return force_truncated.strip(_TRUNCATE_STRIP_CHARACTERS) + "..." + + +def _truncate_markdown_result(result: str, markdown_element_ends: list[int], max_lines: int) -> str: + """ + Truncate the rendered Markdown `result` to fit within `max_lines` newlines and embed limits. + + Uses `markdown_element_ends` to truncate at element boundaries when possible, + falling back to natural language boundaries otherwise. + """ + if not markdown_element_ends: + return "" + + newline_truncate_index = find_nth_occurrence(result, "\n", max_lines) + if newline_truncate_index is not None and newline_truncate_index < _MAX_DESCRIPTION_LENGTH - 3: + truncate_index = newline_truncate_index + else: + truncate_index = _MAX_DESCRIPTION_LENGTH - 3 + + if truncate_index >= markdown_element_ends[-1]: + return result.strip(string.whitespace) + + possible_truncation_indices = [cut for cut in markdown_element_ends if cut < truncate_index] + if not possible_truncation_indices: + return _truncate_without_boundary(result, truncate_index) + + markdown_truncate_index = possible_truncation_indices[-1] + return result[:markdown_truncate_index].strip(_TRUNCATE_STRIP_CHARACTERS) + "..." + + def _get_truncated_description( elements: Iterable[Tag | NavigableString], markdown_converter: DocMarkdownConverter, @@ -147,7 +190,7 @@ def _get_truncated_description( with the real string length limited to `_MAX_DESCRIPTION_LENGTH` to accommodate discord length limits. """ result = "" - markdown_element_ends = [] # Stores indices into `result` which point to the end boundary of each Markdown element. + markdown_element_ends = [] rendered_length = 0 tag_end_index = 0 @@ -170,46 +213,7 @@ def _get_truncated_description( else: break - if not markdown_element_ends: - return "" - - # Determine the "hard" truncation index. Account for the ellipsis placeholder for the max length. - newline_truncate_index = find_nth_occurrence(result, "\n", max_lines) - if newline_truncate_index is not None and newline_truncate_index < _MAX_DESCRIPTION_LENGTH - 3: - # Truncate based on maximum lines if there are more than the maximum number of lines. - truncate_index = newline_truncate_index - else: - # There are less than the maximum number of lines; truncate based on the max char length. - truncate_index = _MAX_DESCRIPTION_LENGTH - 3 - - # Nothing needs to be truncated if the last element ends before the truncation index. - if truncate_index >= markdown_element_ends[-1]: - return result.strip(string.whitespace) - - # Determine the actual truncation index. - possible_truncation_indices = [cut for cut in markdown_element_ends if cut < truncate_index] - if not possible_truncation_indices: - # In case there is no Markdown element ending before the truncation index, try to find a good cutoff point. - force_truncated = result[:truncate_index] - # If there is an incomplete codeblock, cut it out. - if force_truncated.count("```") % 2: - force_truncated = force_truncated[:force_truncated.rfind("```")] - # Search for substrings to truncate at, with decreasing desirability. - for string_ in ("\n\n", "\n", ". ", ", ", ",", " "): - cutoff = force_truncated.rfind(string_) - - if cutoff != -1: - truncated_result = force_truncated[:cutoff] - break - else: - truncated_result = force_truncated - - else: - # Truncate at the last Markdown element that comes before the truncation index. - markdown_truncate_index = possible_truncation_indices[-1] - truncated_result = result[:markdown_truncate_index] - - return truncated_result.strip(_TRUNCATE_STRIP_CHARACTERS) + "..." + return _truncate_markdown_result(result, markdown_element_ends, max_lines) def _create_markdown(signatures: list[str] | None, description: Iterable[Tag], url: str) -> str: diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 6d8b4f8758..df89d40e04 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -262,12 +262,9 @@ async def user_info(self, ctx: Context, user_or_message: MemberOrUser | Message embed = await self.create_user_embed(ctx, user, passed_as_message) await ctx.send(embed=embed) - async def create_user_embed(self, ctx: Context, user: MemberOrUser, passed_as_message: bool) -> Embed: - """Creates an embed containing information on the `user`.""" - on_server = bool(await get_or_fetch_member(ctx.guild, user.id)) - - created = time.format_relative(user.created_at) - + @staticmethod + def _build_embed_name(user: MemberOrUser, on_server: bool, passed_as_message: bool) -> str: + """Build the display name for the user embed title.""" name = str(user) if on_server and user.nick: name = f"{user.nick} ({name})" @@ -281,29 +278,47 @@ async def create_user_embed(self, ctx: Context, user: MemberOrUser, passed_as_me elif user.bot: name += f" {constants.Emojis.bot}" - badges = [] + return name + @staticmethod + def _build_user_badges(user: MemberOrUser) -> list: + """Collect the user's public flag badges.""" + badges = [] for badge, is_set in user.public_flags: if is_set and (emoji := getattr(constants.Emojis, f"badge_{badge}", None)): badges.append(emoji) + return badges + @staticmethod + def _build_membership_info(channel, user: MemberOrUser, on_server: bool) -> str: + """Build the membership information string for the embed.""" if on_server: if user.joined_at: joined = time.format_relative(user.joined_at) else: joined = "Unable to get join date" - # The 0 is for excluding the default @everyone role, - # and the -1 is for reversing the order of the roles to highest to lowest in hierarchy. roles = ", ".join(role.mention for role in user.roles[:0:-1]) membership = {"Joined": joined, "Verified": not user.pending, "Roles": roles or None} - if not is_mod_channel(ctx.channel): + if not is_mod_channel(channel): membership.pop("Verified") membership = textwrap.dedent("\n".join([f"{key}: {value}" for key, value in membership.items()])) else: membership = "The user is not a member of the server" + return membership + + async def create_user_embed(self, ctx: Context, user: MemberOrUser, passed_as_message: bool) -> Embed: + """Creates an embed containing information on the `user`.""" + on_server = bool(await get_or_fetch_member(ctx.guild, user.id)) + + created = time.format_relative(user.created_at) + + name = self._build_embed_name(user, on_server, passed_as_message) + badges = self._build_user_badges(user) + membership = self._build_membership_info(ctx.channel, user, on_server) + fields = [ ( "User information", diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py index 1f0a2dce4d..76105c73fc 100644 --- a/bot/exts/moderation/clean.py +++ b/bot/exts/moderation/clean.py @@ -382,6 +382,42 @@ async def _modlog_cleaned_messages( # endregion + @staticmethod + def _normalize_limits( + first_limit: CleanLimit | None, + second_limit: CleanLimit | None, + ) -> tuple[CleanLimit | None, CleanLimit | None]: + """Convert Message limits to datetime and ensure chronological order.""" + if isinstance(first_limit, Message): + first_limit = first_limit.created_at + if isinstance(second_limit, Message): + second_limit = second_limit.created_at + if first_limit and second_limit: + first_limit, second_limit = sorted([first_limit, second_limit]) + return first_limit, second_limit + + async def _send_clean_result( + self, + ctx: Context, + deleted_messages: list[Message], + log_url: str | None, + ) -> None: + """Send a success message about the clean operation to the user or mods.""" + if not log_url: + return + success_message = ( + f"{Emojis.ok_hand} Deleted {len(deleted_messages)} messages. " + f"A log of the deleted messages can be found here {log_url}." + ) + if is_mod_channel(ctx.channel): + try: + await ctx.reply(success_message) + except errors.HTTPException: + await ctx.send(success_message) + else: + if mods := self.bot.get_channel(Channels.mods): + await mods.send(f"{ctx.author.mention} {success_message}") + async def _clean_messages( self, ctx: Context, @@ -406,12 +442,7 @@ async def _clean_messages( deletion_channels = self._channels_set(channels, ctx, first_limit, second_limit) - if isinstance(first_limit, Message): - first_limit = first_limit.created_at - if isinstance(second_limit, Message): - second_limit = second_limit.created_at - if first_limit and second_limit: - first_limit, second_limit = sorted([first_limit, second_limit]) + first_limit, second_limit = self._normalize_limits(first_limit, second_limit) # Needs to be called after standardizing the input. predicate = self._build_predicate(first_limit, second_limit, bots_only, users, regex) @@ -447,18 +478,7 @@ async def _clean_messages( channels = deletion_channels log_url = await self._modlog_cleaned_messages(deleted_messages, channels, ctx) - success_message = ( - f"{Emojis.ok_hand} Deleted {len(deleted_messages)} messages. " - f"A log of the deleted messages can be found here {log_url}." - ) - if log_url and is_mod_channel(ctx.channel): - try: - await ctx.reply(success_message) - except errors.HTTPException: - await ctx.send(success_message) - elif log_url: - if mods := self.bot.get_channel(Channels.mods): - await mods.send(f"{ctx.author.mention} {success_message}") + await self._send_clean_result(ctx, deleted_messages, log_url) return log_url # region: Commands diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 2adcdb485e..f6345727d3 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -180,6 +180,146 @@ async def reapply_infraction( else: log.info(f"Re-applied {infraction['type']} to user {infraction['user']} upon rejoining.") + async def _attempt_dm( + self, + infraction: _utils.Infraction, + user: MemberOrUser, + user_reason: str | None, + ) -> tuple[str, str]: + """Try to DM the user about the infraction, returning (dm_result, dm_log_text).""" + if await _utils.notify_infraction(infraction, user, user_reason): + return ":incoming_envelope: ", "\nDM: Sent" + return f"{constants.Emojis.failmail} ", "\nDM: **Failed**" + + @staticmethod + def _format_infraction_data(infraction: _utils.Infraction) -> tuple: + """Extract and format basic infraction data.""" + return ( + infraction["type"], + _utils.INFRACTION_ICONS[infraction["type"]][0], + infraction["reason"], + infraction["id"], + infraction["jump_url"], + time.format_with_duration(infraction["expires_at"], infraction["last_applied"]), + ) + + async def _build_end_message( + self, + ctx: Context, + user: MemberOrUser, + id_: int, + reason: str | None, + infraction: _utils.Infraction, + ) -> str: + """Build the end message for the confirmation, based on channel type or actor.""" + if is_mod_channel(ctx.channel): + log.trace(f"Fetching total infraction count for {user}.") + infractions = await self.bot.api_client.get( + "bot/infractions", + params={"user__id": str(user.id)}, + ) + total = len(infractions) + return f" (#{id_} ; {total} infraction{ngettext('', 's', total)} total)" + + if infraction["actor"] == self.bot.user.id and reason: + log.trace(f"Infraction #{id_} actor is bot; including the reason in the confirmation message.") + return ( + f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})." + f"\n\nThe <@&{Roles.moderators}> have been alerted for review" + ) + + return "" + + async def _execute_action( + self, + ctx: Context, + action: Callable[[], Awaitable[None]], + infraction: _utils.Infraction, + user: MemberOrUser, + infr_type: str, + id_: int, + expiry: str | None, + confirm_msg: str, + expiry_msg: str, + log_title: str, + ) -> tuple: + """Execute the infraction action and return updated state.""" + log.trace(f"Running the infraction #{id_} application action.") + try: + await action() + if expiry: + self.schedule_expiration(infraction) + return confirm_msg, expiry_msg, None, log_title, False + except discord.HTTPException as e: + confirm_msg = ":x: failed to apply" + expiry_msg = "" + log_content = ctx.author.mention + log_title = "failed to apply" + log_msg = f"Failed to apply {' '.join(infr_type.split('_'))} infraction #{id_} to {user}" + if isinstance(e, discord.Forbidden): + log.warning(f"{log_msg}: bot lacks permissions.") + elif e.code == 10007 or e.status == 404: + log.info(f"Can't apply {infraction['type']} to user {infraction['user']} because user left from guild.") + else: + log.exception(log_msg) + return confirm_msg, expiry_msg, log_content, log_title, True + + async def _handle_failure_cleanup( + self, + id_: int, + infr_type: str, + confirm_msg: str, + log_title: str, + ) -> tuple[str, str]: + """Delete the infraction from the database when the application failed.""" + log.trace(f"Trying to delete infraction {id_} from database because applying infraction failed.") + try: + await self.bot.api_client.delete(f"bot/infractions/{id_}") + except ResponseCodeError as e: + confirm_msg += " and failed to delete" + log_title += " and failed to delete" + log.error(f"Deletion of {infr_type} infraction #{id_} failed with error code {e.status}.") + return confirm_msg, log_title + + async def _schedule_tidy_up( + self, + ctx: Context, + sent_msg: discord.Message, + infraction: _utils.Infraction, + id_: int, + ) -> None: + """Schedule deletion of the confirmation message and persist to Redis.""" + if infraction["actor"] == self.bot.user.id and not is_mod_channel(ctx.channel): + expire_message_time = datetime.now(UTC) + timedelta(hours=AUTOMATED_TIDY_UP_HOURS) + log.trace(f"Scheduling message tidy for infraction #{id_} in {AUTOMATED_TIDY_UP_HOURS} hours.") + self.tidy_up_scheduler.schedule_at( + expire_message_time, + sent_msg.id, + self._delete_infraction_message(ctx.channel.id, sent_msg.id), + ) + await self.messages_to_tidy.set( + f"{ctx.channel.id}:{sent_msg.id}", + expire_message_time.isoformat(), + ) + + @staticmethod + def _build_expiry_messages(infr_type: str, expiry: str | None) -> tuple[str, str]: + """Build the expiry message and log text based on infraction type.""" + if infr_type in ("kick", "note", "warning"): + expiry_msg = "" + else: + expiry_msg = f" until {expiry}" if expiry else " permanently" + + expiry_log_text = f"\nExpires: {expiry}" if expiry else "" + return expiry_msg, expiry_log_text + + @staticmethod + def _format_jump_url(jump_url: str | None) -> str: + """Format the jump URL for the mod-log embed.""" + if jump_url is None: + return "(Infraction issued in a ModMail channel.)" + return f"[Click here.]({jump_url})" + async def apply_infraction( self, ctx: Context, @@ -201,159 +341,52 @@ async def apply_infraction( Returns whether or not the infraction succeeded. """ - infr_type = infraction["type"] - icon = _utils.INFRACTION_ICONS[infr_type][0] - reason = infraction["reason"] - id_ = infraction["id"] - jump_url = infraction["jump_url"] - expiry = time.format_with_duration( - infraction["expires_at"], - infraction["last_applied"] - ) + infr_type, icon, reason, id_, jump_url, expiry = self._format_infraction_data(infraction) if user_reason is None: user_reason = reason log.trace(f"Applying {infr_type} infraction #{id_} to {user}.") - # Default values for the confirmation message and mod log. - confirm_msg = ":ok_hand: applied" - - # Specifying an expiry for a kick, note, or warning makes no sense. - if infr_type in ("kick", "note", "warning"): - expiry_msg = "" - else: - expiry_msg = f" until {expiry}" if expiry else " permanently" - - dm_result = "" - dm_log_text = "" - expiry_log_text = f"\nExpires: {expiry}" if expiry else "" - log_title = "applied" - log_content = None - failed = False + expiry_msg, expiry_log_text = self._build_expiry_messages(infr_type, expiry) + dm_result = dm_log_text = "" + confirm_msg, log_title, log_content, failed = ":ok_hand: applied", "applied", None, False - # DM the user about the infraction if it's not a shadow/hidden infraction. - # This needs to happen before we apply the infraction, as the bot cannot - # send DMs to user that it doesn't share a guild with. If we were to - # apply kick/ban infractions first, this would mean that we'd make it - # impossible for us to deliver a DM. See python-discord/bot#982. if not infraction["hidden"] and infr_type in {"ban", "kick"}: - if await _utils.notify_infraction(infraction, user, user_reason): - dm_result = ":incoming_envelope: " - dm_log_text = "\nDM: Sent" - else: - dm_result = f"{constants.Emojis.failmail} " - dm_log_text = "\nDM: **Failed**" - - end_msg = "" - if is_mod_channel(ctx.channel): - log.trace(f"Fetching total infraction count for {user}.") - - infractions = await self.bot.api_client.get( - "bot/infractions", - params={"user__id": str(user.id)} - ) - total = len(infractions) - end_msg = f" (#{id_} ; {total} infraction{ngettext('', 's', total)} total)" - elif infraction["actor"] == self.bot.user.id: - log.trace( - f"Infraction #{id_} actor is bot; including the reason in the confirmation message." - ) - if reason: - end_msg = ( - f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})." - f"\n\nThe <@&{Roles.moderators}> have been alerted for review" - ) + dm_result, dm_log_text = await self._attempt_dm(infraction, user, user_reason) + end_msg = await self._build_end_message(ctx, user, id_, reason, infraction) purge = infraction.get("purge", "") - # Execute the necessary actions to apply the infraction on Discord. if action: - log.trace(f"Running the infraction #{id_} application action.") - try: - await action() - if expiry: - # Schedule the expiration of the infraction. - self.schedule_expiration(infraction) - except discord.HTTPException as e: - # Accordingly display that applying the infraction failed. - # Don't use ctx.message.author; antispam only patches ctx.author. - confirm_msg = ":x: failed to apply" - expiry_msg = "" - log_content = ctx.author.mention - log_title = "failed to apply" - - log_msg = f"Failed to apply {' '.join(infr_type.split('_'))} infraction #{id_} to {user}" - if isinstance(e, discord.Forbidden): - log.warning(f"{log_msg}: bot lacks permissions.") - elif e.code == 10007 or e.status == 404: - log.info( - f"Can't apply {infraction['type']} to user {infraction['user']} because user left from guild." - ) - else: - log.exception(log_msg) - failed = True - + confirm_msg, expiry_msg, log_content, log_title, failed = await self._execute_action( + ctx, action, infraction, user, infr_type, id_, expiry, + confirm_msg, expiry_msg, log_title, + ) if not failed: infr_message = f" **{purge}{' '.join(infr_type.split('_'))}** to {user.mention}{expiry_msg}{end_msg}" - - # If we need to DM and haven't already tried to if not infraction["hidden"] and infr_type not in {"ban", "kick"}: - if await _utils.notify_infraction(infraction, user, user_reason): - dm_result = ":incoming_envelope: " - dm_log_text = "\nDM: Sent" - else: - dm_result = f"{constants.Emojis.failmail} " - dm_log_text = "\nDM: **Failed**" - if infr_type == "warning" and not ctx.channel.permissions_for(user).view_channel: - failed = True - log_title = "failed to apply" - additional_info += "\n*Failed to show the warning to the user*" - confirm_msg = (f":x: Failed to apply **warning** to {user.mention} " - "because DMing the user was unsuccessful") + dm_result, dm_log_text = await self._attempt_dm(infraction, user, user_reason) + if dm_log_text == "\nDM: **Failed**" and infr_type == "warning" and not ctx.channel.permissions_for(user).view_channel: + failed = True + log_title = "failed to apply" + additional_info += "\n*Failed to show the warning to the user*" + confirm_msg = (f":x: Failed to apply **warning** to {user.mention} " + "because DMing the user was unsuccessful") if failed: - log.trace(f"Trying to delete infraction {id_} from database because applying infraction failed.") - try: - await self.bot.api_client.delete(f"bot/infractions/{id_}") - except ResponseCodeError as e: - confirm_msg += " and failed to delete" - log_title += " and failed to delete" - log.error(f"Deletion of {infr_type} infraction #{id_} failed with error code {e.status}.") + confirm_msg, log_title = await self._handle_failure_cleanup(id_, infr_type, confirm_msg, log_title) infr_message = "" - # Send a confirmation message to the invoking context. log.trace(f"Sending infraction #{id_} confirmation message.") mentions = discord.AllowedMentions(users=[user], roles=False) sent_msg = await ctx.send(f"{dm_result}{confirm_msg}{infr_message}.", allowed_mentions=mentions) - # Only tidy up bot issued infractions in non-mod channels. - if infraction["actor"] == self.bot.user.id and not is_mod_channel(ctx.channel): - expire_message_time = datetime.now(UTC) + timedelta(hours=AUTOMATED_TIDY_UP_HOURS) - - log.trace(f"Scheduling message tidy for infraction #{id_} in {AUTOMATED_TIDY_UP_HOURS} hours.") + await self._schedule_tidy_up(ctx, sent_msg, infraction, id_) - # Schedule the message to be deleted after a certain period of time. - self.tidy_up_scheduler.schedule_at( - expire_message_time, - sent_msg.id, - self._delete_infraction_message(ctx.channel.id, sent_msg.id) - ) + jump_url = self._format_jump_url(jump_url) - # Persist to Redis to handle for bot restarts. - await self.messages_to_tidy.set( - f"{ctx.channel.id}:{sent_msg.id}", - expire_message_time.isoformat(), - ) - - if jump_url is None: - jump_url = "(Infraction issued in a ModMail channel.)" - else: - jump_url = f"[Click here.]({jump_url})" - - # Send a log message to the mod log. - # Don't use ctx.message.author for the actor; antispam only patches ctx.author. log.trace(f"Sending apply mod log for infraction #{id_}.") await send_log_message( self.bot, @@ -466,52 +499,23 @@ async def pardon_infraction( content=log_content, ) - async def deactivate_infraction( + async def _execute_pardon_action( self, infraction: _utils.Infraction, - pardon_reason: str | None = None, - *, - send_log: bool = True, - notify: bool = True - ) -> dict[str, str]: - """ - Deactivate an active infraction and return a dictionary of lines to send in a mod log. - - The infraction is removed from Discord, marked as inactive in the database, and has its - expiration task cancelled. - - If `pardon_reason` is None, then the database will not receive - appended text explaining why the infraction was pardoned. - - If `send_log` is True, a mod log is sent for the deactivation of the infraction. - - If `notify` is True, notify the user of the pardon via DM where applicable. - - Infractions of unsupported types will raise a ValueError. - """ - guild = self.bot.get_guild(constants.Guild.id) - mod_role = guild.get_role(constants.Roles.moderators) - user_id = infraction["user"] - actor = infraction["actor"] - type_ = infraction["type"] - id_ = infraction["id"] - - log.info(f"Marking infraction #{id_} as inactive (expired).") - - log_content = None - log_text = { - "Member": f"<@{user_id}>", - "Actor": f"<@{actor}>", - "Reason": infraction["reason"], - "Created": time.format_with_duration(infraction["inserted_at"], infraction["expires_at"]), - } - + notify: bool, + log_text: dict[str, str], + id_: int, + type_: str, + log_content: str | None, + mod_role: discord.Role, + ) -> tuple[dict[str, str], str | None]: + """Execute the pardon action and handle Discord API errors.""" try: log.trace("Awaiting the pardon action coroutine.") returned_log = await self._pardon_action(infraction, notify) if returned_log is not None: - log_text = {**log_text, **returned_log} # Merge the logs together + log_text = {**log_text, **returned_log} else: raise ValueError( f"Attempted to deactivate an unsupported infraction #{id_} ({type_})!" @@ -532,7 +536,14 @@ async def deactivate_infraction( log_text["Failure"] = f"HTTPException with status {e.status} and code {e.code}." log_content = mod_role.mention - # Check if the user is currently being watched by Big Brother. + return log_text, log_content + + async def _check_watch_status( + self, + user_id: int, + log_text: dict[str, str], + ) -> dict[str, str]: + """Check if the user is currently being watched by Big Brother.""" try: log.trace(f"Determining if user {user_id} is currently being watched by Big Brother.") @@ -550,15 +561,25 @@ async def deactivate_infraction( log.exception(f"Failed to fetch watch status for user {user_id}") log_text["Watching"] = "Unknown - failed to fetch watch status." + return log_text + + async def _deactivate_in_database( + self, + id_: int, + pardon_reason: str | None, + infraction: _utils.Infraction, + log_text: dict[str, str], + log_content: str | None, + mod_role: discord.Role, + ) -> tuple[dict[str, str], str | None]: + """Mark the infraction as inactive in the database.""" try: - # Mark infraction as inactive in the database. log.trace(f"Marking infraction #{id_} as inactive in the database.") data = {"active": False} if pardon_reason is not None: data["reason"] = "" - # Append pardon reason to infraction in database. if (punish_reason := infraction["reason"]) is not None: data["reason"] = punish_reason + " | " @@ -569,16 +590,61 @@ async def deactivate_infraction( json=data ) except ResponseCodeError as e: - log.exception(f"Failed to deactivate infraction #{id_} ({type_})") + log.exception(f"Failed to deactivate infraction #{id_} ({infraction['type']})") log_line = f"API request failed with code {e.status}." log_content = mod_role.mention - # Append to an existing failure message if possible if "Failure" in log_text: log_text["Failure"] += f" {log_line}" else: log_text["Failure"] = log_line + return log_text, log_content + + async def deactivate_infraction( + self, + infraction: _utils.Infraction, + pardon_reason: str | None = None, + *, + send_log: bool = True, + notify: bool = True + ) -> dict[str, str]: + """ + Deactivate an active infraction and return a dictionary of lines to send in a mod log. + + The infraction is removed from Discord, marked as inactive in the database, and has its + expiration task cancelled. + + If `pardon_reason` is None, then the database will not receive + appended text explaining why the infraction was pardoned. + + If `send_log` is True, a mod log is sent for the deactivation of the infraction. + + If `notify` is True, notify the user of the pardon via DM where applicable. + + Infractions of unsupported types will raise a ValueError. + """ + guild = self.bot.get_guild(constants.Guild.id) + mod_role = guild.get_role(constants.Roles.moderators) + user_id = infraction["user"] + actor = infraction["actor"] + type_ = infraction["type"] + id_ = infraction["id"] + + log.info(f"Marking infraction #{id_} as inactive (expired).") + + log_content = None + log_text = { + "Member": f"<@{user_id}>", + "Actor": f"<@{actor}>", + "Reason": infraction["reason"], + "Created": time.format_with_duration(infraction["inserted_at"], infraction["expires_at"]), + } + + log_text, log_content = await self._execute_pardon_action(infraction, notify, log_text, id_, type_, log_content, mod_role) + log_text = await self._check_watch_status(user_id, log_text) + log_text, log_content = await self._deactivate_in_database(id_, pardon_reason, infraction, log_text, log_content, mod_role) + # Cancel the expiration task. if infraction["expires_at"] is not None: self.scheduler.cancel(infraction["id"]) @@ -590,7 +656,6 @@ async def deactivate_infraction( user = self.bot.get_user(user_id) avatar = user.display_avatar.url if user else None - # Move reason to end so when reason is too long, this is not gonna cut out required items. log_text["Reason"] = log_text.pop("Reason") log.trace(f"Sending deactivation mod log for infraction #{id_}.") diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index d2d194696c..bc0f352c33 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -1,3 +1,4 @@ +import datetime import gettext import re import textwrap @@ -179,30 +180,80 @@ async def infraction_edit( infraction_id = infraction["id"] + if not await self._validate_infraction_edit_inputs(ctx, infraction, duration): + return + request_data = {} - confirm_messages = [] - log_text = "" + confirm_messages: list[str] = [] + + expiry = self._prepare_duration_update(request_data, confirm_messages, duration) + log_text = self._prepare_reason_update(request_data, confirm_messages, reason, infraction) + # Update the infraction + new_infraction = await self.bot.api_client.patch( + f"bot/infractions/{infraction_id}", + json=request_data, + ) + + # Get information about the infraction's user + user_id = new_infraction["user"] + user = await get_or_fetch_member(ctx.guild, user_id) + + log_text += await self._reschedule_infraction_expiry( + infraction, new_infraction, request_data, user, ctx, reason, expiry, + ) + + changes = " & ".join(confirm_messages) + await ctx.send(f":ok_hand: Updated infraction #{infraction_id}: {changes}") + + await self._send_infraction_edit_log( + ctx, new_infraction, user, user_id, infraction_id, log_text, + ) + + async def _validate_infraction_edit_inputs( + self, + ctx: Context, + infraction: Infraction, + duration: DurationOrExpiry | t.Literal["p", "permanent"] | None, + ) -> bool: + """Validate that the infraction can be edited, return False to abort the edit.""" if duration is not None and not infraction["active"]: - if (infr_type := infraction["type"]) in ("note", "warning"): - await ctx.send(f":x: Cannot edit the expiration of a {infr_type}.") + if infraction["type"] in ("note", "warning"): + await ctx.send(f":x: Cannot edit the expiration of a {infraction['type']}.") else: await ctx.send(":x: Cannot edit the expiration of an expired infraction.") - return + return False + return True + @staticmethod + def _prepare_duration_update( + request_data: dict, + confirm_messages: list[str], + duration: DurationOrExpiry | t.Literal["p", "permanent"] | None, + ) -> datetime.datetime | None: + """Prepare duration update data. Returns expiry if set, else None.""" + expiry = None if isinstance(duration, str): request_data["expires_at"] = None confirm_messages.append("marked as permanent") elif duration is not None: origin, expiry = unpack_duration(duration) - # Update `last_applied` if expiry changes. request_data["last_applied"] = origin.isoformat() request_data["expires_at"] = expiry.isoformat() - formatted_expiry = time.format_with_duration(expiry, origin) - confirm_messages.append(f"set to expire on {formatted_expiry}") + confirm_messages.append(f"set to expire on {time.format_with_duration(expiry, origin)}") else: confirm_messages.append("expiry unchanged") + return expiry + @staticmethod + def _prepare_reason_update( + request_data: dict, + confirm_messages: list[str], + reason: str | None, + infraction: Infraction, + ) -> str: + """Prepare reason update data. Returns log text.""" + log_text = "" if reason: request_data["reason"] = reason confirm_messages.append("set a new reason") @@ -212,27 +263,26 @@ async def infraction_edit( """.rstrip() else: confirm_messages.append("reason unchanged") + return log_text - # Update the infraction - new_infraction = await self.bot.api_client.patch( - f"bot/infractions/{infraction_id}", - json=request_data, - ) - - # Get information about the infraction's user - user_id = new_infraction["user"] - user = await get_or_fetch_member(ctx.guild, user_id) - - # Re-schedule infraction if the expiration has been updated + async def _reschedule_infraction_expiry( + self, + infraction: dict, + new_infraction: dict, + request_data: dict, + user: discord.Member | None, + ctx: Context, + reason: str | None, + expiry: datetime.datetime | None, + ) -> str: + """Cancel old scheduled task and schedule new expiration if needed.""" + log_text = "" if "expires_at" in request_data: - # A scheduled task should only exist if the old infraction wasn't permanent if infraction["expires_at"]: - self.infractions_cog.scheduler.cancel(infraction_id) + self.infractions_cog.scheduler.cancel(infraction["id"]) - # If the infraction was not marked as permanent, schedule a new expiration task if request_data["expires_at"]: self.infractions_cog.schedule_expiration(new_infraction) - # Timeouts are handled by Discord itself, so we need to edit the expiry in Discord as well if user and infraction["type"] == "timeout": capped, duration = _utils.cap_timeout_duration(expiry) if capped: @@ -243,10 +293,18 @@ async def infraction_edit( Previous expiry: {time.until_expiration(infraction['expires_at'])} New expiry: {time.until_expiration(new_infraction['expires_at'])} """.rstrip() + return log_text - changes = " & ".join(confirm_messages) - await ctx.send(f":ok_hand: Updated infraction #{infraction_id}: {changes}") - + async def _send_infraction_edit_log( + self, + ctx: Context, + new_infraction: dict, + user: discord.Member | None, + user_id: int, + infraction_id: int, + log_text: str, + ) -> None: + """Send a log message for the edited infraction.""" if user: user_text = messages.format_user(user) thumbnail = user.display_avatar.url diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index ebd156d4be..983fb3c821 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -112,17 +112,44 @@ async def on_guild_channel_update(self, before: GUILD_CHANNEL, after: GuildChann return diff = DeepDiff(before, after) - changes = [] - done = [] diff_values = diff.get("values_changed", {}) diff_values.update(diff.get("type_changes", {})) + changes = self._process_channel_changes(diff_values) + + if not changes: + return + + message = "" + + for item in sorted(changes): + message += f"{Emojis.bullet} {item}\n" + + if after.category: + message = f"**{after.category}/#{after.name} (`{after.id}`)**\n{message}" + else: + message = f"**#{after.name}** (`{after.id}`)\n{message}" + + await send_log_message( + self.bot, + Icons.hash_blurple, + Colour.og_blurple(), + "Channel updated", + message + ) + + @staticmethod + def _process_channel_changes(diff_values: dict) -> list[str]: + """Process channel diff values into a list of change descriptions.""" + changes = [] + done = [] + for key, value in diff_values.items(): - if not key: # Not sure why, but it happens + if not key: continue - key = key[5:] # Remove "root." prefix + key = key[5:] if "[" in key: key = key.split("[", 1)[0] @@ -139,33 +166,11 @@ async def on_guild_channel_update(self, before: GUILD_CHANNEL, after: GuildChann new = value["new_value"] old = value["old_value"] - # Discord does not treat consecutive backticks ("``") as an empty inline code block, so the markdown - # formatting is broken when `new` and/or `old` are empty values. "None" is used for these cases so - # formatting is preserved. changes.append(f"**{key.title()}:** `{old or 'None'}` **→** `{new or 'None'}`") done.append(key) - if not changes: - return - - message = "" - - for item in sorted(changes): - message += f"{Emojis.bullet} {item}\n" - - if after.category: - message = f"**{after.category}/#{after.name} (`{after.id}`)**\n{message}" - else: - message = f"**#{after.name}** (`{after.id}`)\n{message}" - - await send_log_message( - self.bot, - Icons.hash_blurple, - Colour.og_blurple(), - "Channel updated", - message - ) + return changes @Cog.listener() async def on_guild_role_create(self, role: discord.Role) -> None: diff --git a/bot/exts/moderation/watchchannels/_watchchannel.py b/bot/exts/moderation/watchchannels/_watchchannel.py index 44c0be2a7e..7f5d8a43e5 100644 --- a/bot/exts/moderation/watchchannels/_watchchannel.py +++ b/bot/exts/moderation/watchchannels/_watchchannel.py @@ -38,6 +38,37 @@ class MessageHistory: message_count: int = 0 +@dataclass +class WatchChannelConfig: + """Configuration for a watch channel.""" + + bot: Bot + destination: int + webhook_id: int + api_endpoint: str + api_default_params: dict + logger: CustomLogger + disable_header: bool = False + + +@dataclass +class MessageQueueState: + """State for the message consumption queue.""" + + consume_task: asyncio.Task | None = None + message_queue: defaultdict | None = None + consumption_queue: dict | None = None + message_history: MessageHistory | None = None + + def __post_init__(self) -> None: + if self.message_queue is None: + self.message_queue = defaultdict(lambda: defaultdict(deque)) + if self.consumption_queue is None: + self.consumption_queue = {} + if self.message_history is None: + self.message_history = MessageHistory() + + class WatchChannel(metaclass=CogABCMeta): """ABC with functionality for relaying users' messages to a certain channel.""" @@ -53,33 +84,38 @@ def __init__( *, disable_header: bool = False ) -> None: - self.bot = bot - - self.destination = destination # E.g., Channels.big_brother - self.webhook_id = webhook_id # E.g., Webhooks.big_brother - self.api_endpoint = api_endpoint # E.g., 'bot/infractions' - self.api_default_params = api_default_params # E.g., {'active': 'true', 'type': 'watch'} - self.log = logger # Logger of the child cog for a correct name in the logs - - self._consume_task = None + self.config = WatchChannelConfig( + bot=bot, + destination=destination, + webhook_id=webhook_id, + api_endpoint=api_endpoint, + api_default_params=api_default_params, + logger=logger, + disable_header=disable_header, + ) + self.queue_state = MessageQueueState() self.watched_users = {} - self.message_queue = defaultdict(lambda: defaultdict(deque)) - self.consumption_queue = {} - self.retries = 5 - self.retry_delay = 10 self.channel = None self.webhook = None - self.message_history = MessageHistory() - self.disable_header = disable_header + + @property + def bot(self) -> Bot: + """Return the bot instance from config.""" + return self.config.bot + + @property + def log(self) -> CustomLogger: + """Return the logger from config.""" + return self.config.logger @property def consuming_messages(self) -> bool: """Checks if a consumption task is currently running.""" - if self._consume_task is None: + if self.queue_state.consume_task is None: return False - if self._consume_task.done(): - exc = self._consume_task.exception() + if self.queue_state.consume_task.done(): + exc = self.queue_state.consume_task.exception() if exc: self.log.exception( "The message queue consume task has failed with:", @@ -94,14 +130,14 @@ async def cog_load(self) -> None: await self.bot.wait_until_guild_available() try: - self.channel = await get_or_fetch_channel(self.bot, self.destination) + self.channel = await get_or_fetch_channel(self.bot, self.config.destination) except HTTPException: - self.log.exception(f"Failed to retrieve the text channel with id `{self.destination}`") + self.log.exception(f"Failed to retrieve the text channel with id `{self.config.destination}`") try: - self.webhook = await self.bot.fetch_webhook(self.webhook_id) + self.webhook = await self.bot.fetch_webhook(self.config.webhook_id) except discord.HTTPException: - self.log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`") + self.log.exception(f"Failed to fetch webhook with id `{self.config.webhook_id}`") if self.channel is None or self.webhook is None: self.log.error("Failed to start the watch channel; unloading the cog.") @@ -149,7 +185,7 @@ async def fetch_user_cache(self) -> bool: This function returns `True` if the update succeeded. """ try: - data = await self.bot.api_client.get(self.api_endpoint, params=self.api_default_params) + data = await self.bot.api_client.get(self.config.api_endpoint, params=self.config.api_default_params) except ResponseCodeError as err: self.log.exception("Failed to fetch the watched users from the API", exc_info=err) return False @@ -167,10 +203,10 @@ async def on_message(self, msg: Message) -> None: """Queues up messages sent by watched users.""" if msg.author.id in self.watched_users: if not self.consuming_messages: - self._consume_task = scheduling.create_task(self.consume_messages()) + self.queue_state.consume_task = scheduling.create_task(self.consume_messages()) self.log.trace(f"Received message: {msg.content} ({len(msg.attachments)} attachments)") - self.message_queue[msg.author.id][msg.channel.id].append(msg) + self.queue_state.message_queue[msg.author.id][msg.channel.id].append(msg) async def consume_messages(self, delay_consumption: bool = True) -> None: """Consumes the message queues to log watched users' messages.""" @@ -181,11 +217,11 @@ async def consume_messages(self, delay_consumption: bool = True) -> None: self.log.trace("Started consuming the message queue") # If the previous consumption Task failed, first consume the existing comsumption_queue - if not self.consumption_queue: - self.consumption_queue = self.message_queue.copy() - self.message_queue.clear() + if not self.queue_state.consumption_queue: + self.queue_state.consumption_queue = self.queue_state.message_queue.copy() + self.queue_state.message_queue.clear() - for user_id, channel_queues in self.consumption_queue.items(): + for user_id, channel_queues in self.queue_state.consumption_queue.items(): for channel_queue in channel_queues.values(): while channel_queue: msg = channel_queue.popleft() @@ -196,11 +232,11 @@ async def consume_messages(self, delay_consumption: bool = True) -> None: else: self.log.trace(f"Not consuming message {msg.id} as user {user_id} is no longer watched.") - self.consumption_queue.clear() + self.queue_state.consumption_queue.clear() - if self.message_queue: + if self.queue_state.message_queue: self.log.trace("Channel queue not empty: Continuing consuming queues") - self._consume_task = scheduling.create_task(self.consume_messages(delay_consumption=False)) + self.queue_state.consume_task = scheduling.create_task(self.consume_messages(delay_consumption=False)) else: self.log.trace("Done consuming messages.") @@ -226,11 +262,11 @@ async def relay_message(self, msg: Message, watch_info: dict) -> None: limit = BigBrotherConfig.header_message_limit if ( - msg.author.id != self.message_history.last_author - or msg.channel.id != self.message_history.last_channel - or self.message_history.message_count >= limit + msg.author.id != self.queue_state.message_history.last_author + or msg.channel.id != self.queue_state.message_history.last_channel + or self.queue_state.message_history.message_count >= limit ): - self.message_history = MessageHistory(last_author=msg.author.id, last_channel=msg.channel.id) + self.queue_state.message_history = MessageHistory(last_author=msg.author.id, last_channel=msg.channel.id) await self.send_header(msg, watch_info) @@ -269,11 +305,11 @@ async def relay_message(self, msg: Message, watch_info: dict) -> None: exc_info=exc ) - self.message_history.message_count += 1 + self.queue_state.message_history.message_count += 1 async def send_header(self, msg: Message, watch_info: dict) -> None: """Sends a header embed with information about the relayed messages to the watch channel.""" - if self.disable_header: + if self.config.disable_header: return guild = self.bot.get_guild(GuildConfig.id) @@ -372,7 +408,7 @@ def _remove_user(self, user_id: int) -> None: async def cog_unload(self) -> None: """Takes care of unloading the cog and canceling the consumption task.""" self.log.trace("Unloading the cog") - if self._consume_task and not self._consume_task.done(): + if self.queue_state.consume_task and not self.queue_state.consume_task.done(): def done_callback(task: asyncio.Task) -> None: """Send exception when consuming task have been cancelled.""" try: @@ -382,5 +418,5 @@ def done_callback(task: asyncio.Task) -> None: f"The consume task of {type(self).__name__} was canceled. Messages may be lost." ) - self._consume_task.add_done_callback(done_callback) - self._consume_task.cancel() + self.queue_state.consume_task.add_done_callback(done_callback) + self.queue_state.consume_task.cancel() diff --git a/bot/exts/moderation/watchchannels/bigbrother.py b/bot/exts/moderation/watchchannels/bigbrother.py index 7af8c7152b..bf9166c45e 100644 --- a/bot/exts/moderation/watchchannels/bigbrother.py +++ b/bot/exts/moderation/watchchannels/bigbrother.py @@ -106,7 +106,7 @@ async def apply_watch(self, ctx: Context, user: MemberOrUser, reason: str) -> No msg = f":white_check_mark: Messages sent by {user.mention} will now be relayed to Big Brother." history = await self.bot.api_client.get( - self.api_endpoint, + self.config.api_endpoint, params={ "user__id": str(user.id), "active": "false", @@ -133,10 +133,10 @@ async def apply_unwatch(self, ctx: Context, user: MemberOrUser, reason: str, sen `ctx`. """ active_watches = await self.bot.api_client.get( - self.api_endpoint, + self.config.api_endpoint, params=ChainMap( {"user__id": str(user.id)}, - self.api_default_params, + self.config.api_default_params, ) ) if active_watches: @@ -144,7 +144,7 @@ async def apply_unwatch(self, ctx: Context, user: MemberOrUser, reason: str, sen [infraction] = active_watches await self.bot.api_client.patch( - f"{self.api_endpoint}/{infraction['id']}", + f"{self.config.api_endpoint}/{infraction['id']}", json={"active": False} ) diff --git a/bot/exts/utils/internal.py b/bot/exts/utils/internal.py index ea27a5f503..1f018f0bfd 100644 --- a/bot/exts/utils/internal.py +++ b/bot/exts/utils/internal.py @@ -5,6 +5,7 @@ import textwrap import traceback from collections import Counter +from dataclasses import dataclass, field from io import StringIO from typing import Any @@ -21,18 +22,22 @@ log = get_logger(__name__) +class _EvalState: + """Encapsula o estado do REPL eval para reduzir atributos de Internal.""" + def __init__(self): + self.env = {} + self.ln = 0 + self.stdout = StringIO() + + 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.eval_state = _EvalState() - self.socket_since = arrow.utcnow() - self.socket_event_total = 0 - self.socket_events = Counter() + self.socket_stats = SocketStats(since=arrow.utcnow()) if DEBUG_MODE: self.eval.add_check(is_owner().predicate) @@ -40,110 +45,87 @@ def __init__(self, bot: Bot): @Cog.listener() async def on_socket_event_type(self, event_type: str) -> None: """When a websocket event is received, increase our counters.""" - self.socket_event_total += 1 - self.socket_events[event_type] += 1 + self.socket_stats.event_total += 1 + self.socket_stats.events[event_type] += 1 - def _format(self, inp: str, out: Any) -> tuple[str, discord.Embed | None]: - """Format the eval output into a string & attempt to format it into an Embed.""" - self._ = out - - res = "" - - # Erase temp input we made + def _format_input_display(self, inp: str) -> str: + """Format the input code display (the ``In [X]:`` / ``...:`` lines).""" 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 + res = "" for i, line in enumerate(lines): if i == 0: - # Start dialog - start = f"In [{self.ln}]: " - + start = f"In [{self.eval_state.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) + start = "...: ".rjust(len(str(self.eval_state.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() + return res + + def _format(self, inp: str, out: Any) -> tuple[str, discord.Embed | None]: + """Format the eval output into a string & attempt to format it into an Embed.""" + self._ = out + + res = self._format_input_display(inp) + + self.eval_state.stdout.seek(0) + text = self.eval_state.stdout.read() + self.eval_state.stdout.close() + self.eval_state.stdout = StringIO() if text: res += (text + "\n") + return self._format_output_display(out, res) + + def _format_output_display(self, out: Any, res: str) -> tuple[str, discord.Embed | None]: + """Format the eval output value into a string and optional Embed.""" if out is None: - # No output, return the input statement return (res, None) - res += f"Out[{self.ln}]: " + res += f"Out[{self.eval_state.ln}]: " if isinstance(out, discord.Embed): - # We made an embed? Send that as embed res += "" - res = (res, out) + return (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) and out.startswith("Traceback (most recent call last):\n"): + 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") + if isinstance(out, str): + pretty = out + else: + pretty = pprint.pformat(out, compact=True, width=60) - pretty = ("\n".join(li[:3]) # First 3 lines - + "\n ...\n" # Ellipsis to indicate removed lines - + "\n".join(li[-3:])) # last 3 lines + if pretty != str(out): + res += "\n" - # Add the output - res += pretty - res = (res, None) + if pretty.count("\n") > 20: + li = pretty.split("\n") + pretty = ("\n".join(li[:3]) + + "\n ...\n" + + "\n".join(li[-3:])) - return res # Return (text, embed) + res += pretty + return (res, None) async def _eval(self, ctx: Context, code: str) -> discord.Message | None: """Eval the input code string & send an embed to the invoking context.""" - self.ln += 1 + self.eval_state.ln += 1 if code.startswith("exit"): - self.ln = 0 - self.env = {} + self.eval_state.ln = 0 + self.eval_state.env = {} return await ctx.send("```Reset history!```") env = { @@ -159,25 +141,25 @@ async def _eval(self, ctx: Context, code: str) -> discord.Message | None: "contextlib": contextlib } - self.env.update(env) + self.eval_state.env.update(env) # Ignore this code, it works code_ = """ async def func(): # (None,) -> Any try: - with contextlib.redirect_stdout(self.stdout): + with contextlib.redirect_stdout(self.eval_state.stdout): {} if '_' in locals(): if inspect.isawaitable(_): _ = await _ return _ finally: - self.env.update(locals()) + self.eval_state.env.update(locals()) """.format(textwrap.indent(code, " ")) try: - exec(code_, self.env) # noqa: S102 - func = self.env["func"] + exec(code_, self.eval_state.env) # noqa: S102 + func = self.eval_state.env["func"] res = await func() except Exception: @@ -246,9 +228,9 @@ async def eval(self, ctx: Context, *, code: str) -> None: @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 = (arrow.utcnow() - self.socket_since).total_seconds() + running_s = (arrow.utcnow() - self.socket_stats.since).total_seconds() - per_s = self.socket_event_total / running_s + per_s = self.socket_stats.event_total / running_s stats_embed = discord.Embed( title="WebSocket statistics", @@ -256,7 +238,7 @@ async def socketstats(self, ctx: Context) -> None: color=discord.Color.og_blurple() ) - for event_type, count in self.socket_events.most_common(25): + for event_type, count in self.socket_stats.events.most_common(25): stats_embed.add_field(name=event_type, value=f"{count:,}", inline=True) await ctx.send(embed=stats_embed) diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index 2f4fc0166e..272eb6328e 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -110,68 +110,107 @@ async def zen( zen_lines = ZEN_OF_PYTHON.splitlines() - # Prioritize checking for an index or slice + if await self._handle_zen_slice_or_index(ctx, search_value, zen_lines, embed): + return + + if await self._handle_zen_exact_word(ctx, search_value, zen_lines, embed): + return + + await self._handle_zen_fuzzy_search(ctx, search_value, zen_lines, embed) + + async def _handle_zen_slice_or_index( + self, + ctx: Context, + search_value: str, + zen_lines: list[str], + embed: Embed, + ) -> bool: + """Handle index/slice syntax for the Zen of Python command.""" match = re.match( r"(?P-?\d++(?!:))|(?P(?:-\d+)|\d*):(?:(?P(?:-\d+)|\d*)(?::(?P(?:-\d+)|\d*))?)?", search_value.split(" ")[0], ) - if match: - if match.group("index"): - index = int(match.group("index")) - if not (-19 <= index <= 18): - raise BadArgument("Please provide an index between -19 and 18.") - embed.title += f" (line {index % 19}):" - embed.description = zen_lines[index] - await ctx.send(embed=embed) - return - - start_index = int(match.group("start")) if match.group("start") else None - end_index = int(match.group("end")) if match.group("end") else None - step_size = int(match.group("step")) if match.group("step") else 1 - - if step_size == 0: - raise BadArgument("Step size must not be 0.") - - lines = zen_lines[start_index:end_index:step_size] - if not lines: - raise BadArgument("Slice returned 0 lines.") - - if len(lines) == 1: - embed.title += f" (line {zen_lines.index(lines[0])}):" - embed.description = lines[0] - await ctx.send(embed=embed) - elif lines == zen_lines: - embed.title += ", by Tim Peters" - await ctx.send(embed=embed) - elif len(lines) == 19: - embed.title += f" (step size {step_size}):" - embed.description = "\n".join(lines) - await ctx.send(embed=embed) + if not match: + return False + + if match.group("index"): + index = int(match.group("index")) + if not (-19 <= index <= 18): + raise BadArgument("Please provide an index between -19 and 18.") + embed.title += f" (line {index % 19}):" + embed.description = zen_lines[index] + await ctx.send(embed=embed) + return True + + start_index = int(match.group("start")) if match.group("start") else None + end_index = int(match.group("end")) if match.group("end") else None + step_size = int(match.group("step")) if match.group("step") else 1 + + if step_size == 0: + raise BadArgument("Step size must not be 0.") + + lines = zen_lines[start_index:end_index:step_size] + if not lines: + raise BadArgument("Slice returned 0 lines.") + + await self._send_zen_slice_result(ctx, zen_lines, embed, lines, step_size) + return True + + async def _send_zen_slice_result( + self, + ctx: Context, + zen_lines: list[str], + embed: Embed, + lines: list[str], + step_size: int, + ) -> None: + """Send the embed for a slice result.""" + if len(lines) == 1: + embed.title += f" (line {zen_lines.index(lines[0])}):" + embed.description = lines[0] + elif lines == zen_lines: + embed.title += ", by Tim Peters" + elif len(lines) == 19: + embed.title += f" (step size {step_size}):" + embed.description = "\n".join(lines) + else: + if step_size != 1: + step_message = f", step size {step_size}" else: - if step_size != 1: - step_message = f", step size {step_size}" - else: - step_message = "" - first_position = zen_lines.index(lines[0]) - second_position = zen_lines.index(lines[-1]) - if first_position > second_position: - (first_position, second_position) = (second_position, first_position) - embed.title += f" (lines {first_position}-{second_position}{step_message}):" - embed.description = "\n".join(lines) - await ctx.send(embed=embed) - return + step_message = "" + first_position = zen_lines.index(lines[0]) + second_position = zen_lines.index(lines[-1]) + if first_position > second_position: + first_position, second_position = second_position, first_position + embed.title += f" (lines {first_position}-{second_position}{step_message}):" + embed.description = "\n".join(lines) + await ctx.send(embed=embed) - # Try to handle first exact word due difflib.SequenceMatched may use some other similar word instead - # exact word. + async def _handle_zen_exact_word( + self, + ctx: Context, + search_value: str, + zen_lines: list[str], + embed: Embed, + ) -> bool: + """Try to find an exact word match in the Zen of Python lines.""" for i, line in enumerate(zen_lines): for word in line.split(): if word.lower() == search_value.lower(): embed.title += f" (line {i}):" embed.description = line await ctx.send(embed=embed) - return + return True + return False - # handle if it's a search string and not exact word + async def _handle_zen_fuzzy_search( + self, + ctx: Context, + search_value: str, + zen_lines: list[str], + embed: Embed, + ) -> None: + """Find the best fuzzy match for the search value in the Zen of Python.""" matcher = difflib.SequenceMatcher(None, search_value.lower()) best_match = "" @@ -181,9 +220,6 @@ async def zen( for index, line in enumerate(zen_lines): matcher.set_seq2(line.lower()) - # the match ratio needs to be adjusted because, naturally, - # longer lines will have worse ratios than shorter lines when - # fuzzy searching for keywords. this seems to work okay. adjusted_ratio = (len(line) - 5) ** 0.5 * matcher.ratio() if adjusted_ratio > best_ratio: diff --git a/bot/utils/time.py b/bot/utils/time.py index fcc39d7500..dace0bd695 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -207,6 +207,14 @@ def humanize_delta( if max_units <= 0: raise ValueError("max_units must be positive.") + return _build_humanized_string(delta, precision, max_units) + + +def _build_humanized_string( + delta: relativedelta, + precision: _Precision, + max_units: int, +) -> str: units = ( ("years", delta.years), ("months", delta.months), @@ -216,7 +224,6 @@ def humanize_delta( ("seconds", delta.seconds), ) - # Add the time units that are >0, but stop at precision or max_units. time_strings = [] unit_count = 0 for unit, value in units: @@ -227,12 +234,10 @@ def humanize_delta( if unit == precision or unit_count >= max_units: break - # Add the 'and' between the last two units, if necessary. if len(time_strings) > 1: time_strings[-1] = f"{time_strings[-2]} and {time_strings[-1]}" del time_strings[-2] - # If nothing has been found, just make the value 0 precision, e.g. `0 days`. if not time_strings: humanized = _stringify_time_unit(0, precision) else: diff --git a/pyproject.toml b/pyproject.toml index beb96859a7..bf9bd17693 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ dependencies = [ "tenacity==9.1.2", "tldextract==5.3.0", "yarl==1.22.0", + "codecarbon>=3.2.8", ] name = "bot" version = "1.0.1" diff --git a/tests/bot/exts/filtering/test_discord_token_filter.py b/tests/bot/exts/filtering/test_discord_token_filter.py index 1cb9e16fac..724c898107 100644 --- a/tests/bot/exts/filtering/test_discord_token_filter.py +++ b/tests/bot/exts/filtering/test_discord_token_filter.py @@ -5,7 +5,7 @@ import arrow -from bot.exts.filtering._filter_context import Event, FilterContext +from bot.exts.filtering._filter_context import Event, FilterContent, FilterContext, FilterSource from bot.exts.filtering._filters.unique import discord_token from bot.exts.filtering._filters.unique.discord_token import DiscordTokenFilter, Token from tests.helpers import MockBot, MockMember, MockMessage, MockTextChannel, autospec @@ -32,7 +32,7 @@ def setUp(self): member = MockMember(id=123) channel = MockTextChannel(id=345) - self.ctx = FilterContext(Event.MESSAGE, member, channel, "", self.msg) + self.ctx = FilterContext(FilterSource(Event.MESSAGE, member, channel, self.msg), FilterContent("")) def test_extract_user_id_valid(self): """Should consider user IDs valid if they decode into an integer ID.""" diff --git a/tests/bot/exts/filtering/test_extension_filter.py b/tests/bot/exts/filtering/test_extension_filter.py index 67a503b306..c9b7f1669a 100644 --- a/tests/bot/exts/filtering/test_extension_filter.py +++ b/tests/bot/exts/filtering/test_extension_filter.py @@ -4,7 +4,7 @@ import arrow from bot.constants import Channels -from bot.exts.filtering._filter_context import Event, FilterContext +from bot.exts.filtering._filter_context import Event, FilterContent, FilterContext, FilterSource from bot.exts.filtering._filter_lists import extension from bot.exts.filtering._filter_lists.extension import ExtensionsList from bot.exts.filtering._filter_lists.filter_list import ListType @@ -39,7 +39,7 @@ def setUp(self): self.message = MockMessage() member = MockMember(id=123) channel = MockTextChannel(id=345) - self.ctx = FilterContext(Event.MESSAGE, member, channel, "", self.message) + self.ctx = FilterContext(FilterSource(Event.MESSAGE, member, channel, self.message), FilterContent("")) @patch("bot.instance", BOT) async def test_message_with_allowed_attachment(self): diff --git a/tests/bot/exts/filtering/test_settings_entries.py b/tests/bot/exts/filtering/test_settings_entries.py index f12b2caa55..a6c0f65324 100644 --- a/tests/bot/exts/filtering/test_settings_entries.py +++ b/tests/bot/exts/filtering/test_settings_entries.py @@ -1,6 +1,6 @@ import unittest -from bot.exts.filtering._filter_context import Event, FilterContext +from bot.exts.filtering._filter_context import Event, FilterContent, FilterContext, FilterSource from bot.exts.filtering._settings_types.actions.infraction_and_notification import ( Infraction, InfractionAndNotification, @@ -19,7 +19,7 @@ def setUp(self) -> None: member = MockMember(id=123) channel = MockTextChannel(id=345) message = MockMessage(author=member, channel=channel) - self.ctx = FilterContext(Event.MESSAGE, member, channel, "", message) + self.ctx = FilterContext(FilterSource(Event.MESSAGE, member, channel, message), FilterContent("")) def test_role_bypass_is_off_for_user_without_roles(self): """The role bypass should trigger when a user has no roles.""" diff --git a/tests/bot/exts/filtering/test_token_filter.py b/tests/bot/exts/filtering/test_token_filter.py index 03fa6b4b9e..0feea70a89 100644 --- a/tests/bot/exts/filtering/test_token_filter.py +++ b/tests/bot/exts/filtering/test_token_filter.py @@ -2,7 +2,7 @@ import arrow -from bot.exts.filtering._filter_context import Event, FilterContext +from bot.exts.filtering._filter_context import Event, FilterContent, FilterContext, FilterSource from bot.exts.filtering._filters.token import TokenFilter from tests.helpers import MockMember, MockMessage, MockTextChannel @@ -14,7 +14,7 @@ def setUp(self) -> None: member = MockMember(id=123) channel = MockTextChannel(id=345) message = MockMessage(author=member, channel=channel) - self.ctx = FilterContext(Event.MESSAGE, member, channel, "", message) + self.ctx = FilterContext(FilterSource(Event.MESSAGE, member, channel, message), FilterContent("")) async def test_token_filter_triggers(self): """The filter should evaluate to True only if its token is found in the context content.""" diff --git a/uv.lock b/uv.lock index 7e47fa3c3d..70741e2910 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,11 @@ version = 1 revision = 3 requires-python = "==3.14.*" +resolution-markers = [ + "sys_platform == 'win32'", + "sys_platform == 'emscripten'", + "sys_platform != 'emscripten' and sys_platform != 'win32'", +] [options] prerelease-mode = "allow" @@ -89,6 +94,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -185,6 +199,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f6/22/91616fe707a5c5510de2cac9b046a30defe7007ba8a0c04f9c08f27df312/audioop_lts-0.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:b492c3b040153e68b9fdaff5913305aaaba5bb433d8a7f73d5cf6a64ed3cc1dd", size = 25206, upload-time = "2025-08-05T16:43:16.444Z" }, ] +[[package]] +name = "authlib" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "joserfc" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, +] + [[package]] name = "beautifulsoup4" version = "4.14.2" @@ -206,6 +233,7 @@ dependencies = [ { name = "aiohttp" }, { name = "arrow" }, { name = "beautifulsoup4" }, + { name = "codecarbon" }, { name = "deepdiff" }, { name = "emoji" }, { name = "feedparser" }, @@ -242,6 +270,7 @@ requires-dist = [ { name = "aiohttp", specifier = "==3.13.4" }, { name = "arrow", specifier = "==1.4.0" }, { name = "beautifulsoup4", specifier = "==4.14.2" }, + { name = "codecarbon", specifier = ">=3.2.8" }, { name = "deepdiff", specifier = "==9.0.0" }, { name = "emoji", specifier = "==2.15.0" }, { name = "feedparser", specifier = "==6.0.12" }, @@ -349,6 +378,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + +[[package]] +name = "codecarbon" +version = "3.2.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "arrow" }, + { name = "authlib" }, + { name = "click" }, + { name = "joserfc" }, + { name = "nvidia-ml-py" }, + { name = "pandas" }, + { name = "prometheus-client" }, + { name = "psutil" }, + { name = "py-cpuinfo" }, + { name = "pycountry" }, + { name = "pydantic" }, + { name = "questionary" }, + { name = "rapidfuzz" }, + { name = "requests" }, + { name = "rich" }, + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/a8/b2a7b999071cb52b75c5584575706524690c917bf1a57bb7b523ce0efb8c/codecarbon-3.2.8.tar.gz", hash = "sha256:8ac67b801db8385d7baf4af9248c245b805302e83c65feaa278ebcd426da86eb", size = 425274, upload-time = "2026-06-07T21:02:00.805Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/1c/95b9e4fa5f88a013a271ade052b676b9f7f052b1596220f596ad9374e269/codecarbon-3.2.8-py3-none-any.whl", hash = "sha256:639be4d7f10a74addb2b44fb8197da52b4cbaa444b0bc570633456c311dd1cf5", size = 384615, upload-time = "2026-06-07T21:01:59.143Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -393,6 +461,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/04/642c1d8a448ae5ea1369eac8495740a79eb4e581a9fb0cbdce56bbf56da1/coverage-7.11.0-py3-none-any.whl", hash = "sha256:4b7589765348d78fb4e5fb6ea35d07564e387da2fc5efff62e0222971f155f68", size = 207761, upload-time = "2025-10-15T15:15:06.439Z" }, ] +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, +] + [[package]] name = "deepdiff" version = "9.0.0" @@ -589,6 +707,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "joserfc" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/90/25cb27518750218e4f850be63d8bbb2343efaad1c01c3571aaa4b3c33bd7/joserfc-1.7.1.tar.gz", hash = "sha256:77d0b76514879c68c6f433bc5b7357a4ab72008ff1e33d8379fd11d72bd8ca81", size = 233181, upload-time = "2026-06-08T07:21:33.412Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/00/fa62404c3e347f946faa13aa21085205f9cc06ad17671e37f81a51662ae8/joserfc-1.7.1-py3-none-any.whl", hash = "sha256:b3e3d655612e2e1ef67b2600f2f420e12e537b020208fab1761fad647319c164", size = 70423, upload-time = "2026-06-08T07:21:32.001Z" }, +] + [[package]] name = "lupa" version = "2.7" @@ -675,6 +805,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2b/a0/9b916c68c0e57752c07f8f64b30138d9d4059dbeb27b90274dedbea128ff/lxml-6.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:26dd9f57ee3bd41e7d35b4c98a2ffd89ed11591649f421f0ec19f67d50ec67ac", size = 3817120, upload-time = "2026-04-18T04:32:15.803Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + [[package]] name = "markdownify" version = "1.2.0" @@ -688,6 +830,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/e2/7af643acb4cae0741dffffaa7f3f7c9e7ab4046724543ba1777c401d821c/markdownify-1.2.0-py3-none-any.whl", hash = "sha256:48e150a1c4993d4d50f282f725c0111bd9eb25645d41fa2f543708fd44161351", size = 15561, upload-time = "2025-08-09T17:44:14.074Z" }, ] +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "mslex" version = "1.3.0" @@ -751,6 +902,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, ] +[[package]] +name = "numpy" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/05/3d27272d30698dc0ecb7fdfaa41ad70303b444f81722bb99bce1d818638a/numpy-2.5.0.tar.gz", hash = "sha256:5a129578019311b6e56bdd714250f19b518f7dceeeb8d1af5490f4942d3f891c", size = 20652461, upload-time = "2026-06-21T20:57:51.95Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/ad/abc44aaceaf7b17ee1edde2bbb4458da591bc79574cffff50c4bb35f00d1/numpy-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f27582c55ba4c750b7c58c8faf021d2cd9324a662b466229db8a417b41368af9", size = 16783807, upload-time = "2026-06-21T20:57:06.253Z" }, + { url = "https://files.pythonhosted.org/packages/5d/39/b72e168daf9c00fb20c9fc996d00437ccecdef3102387775d29d7a62576d/numpy-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:28e7137057d551e4a83c4ae414e3451f50568409db7569aacc7f9811ee06a446", size = 11765215, upload-time = "2026-06-21T20:57:08.547Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a0/8400a9c0e3625182347593f5e1f57da9a617a534794805c8df5518154ddc/numpy-2.5.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e1da54b53e75cd9fcfc23efcc7edab2c6aecf97b6037566d8a0fe804af8ec57c", size = 5324493, upload-time = "2026-06-21T20:57:11.012Z" }, + { url = "https://files.pythonhosted.org/packages/f6/8c/0d104deaa0401c93395a629ec902891618a2eff76d19229139cb5a887bfc/numpy-2.5.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:694d8f74e156f7fd01179f1aa8faa2f648ab6ae0f70b6c3fe57a03249aea2303", size = 6645211, upload-time = "2026-06-21T20:57:12.919Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d9/4a4a628c812750363786afc3d33492709a5cd64b215469c16b0f6c7bb811/numpy-2.5.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a7569a7b53c77716f036bb28cb1c91f166a26ec7d9502cd1e4bdfe502fdec22", size = 15166004, upload-time = "2026-06-21T20:57:14.717Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5e/2a902317d7fc4aa93236e80c932662dadfc459b323d758329e01775125e1/numpy-2.5.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39a0433bd4086ebd462960cf375e19195bb07b53dc1d87dd5fcf47ad78576f03", size = 16650797, upload-time = "2026-06-21T20:57:16.906Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a0/a0090e6329f4ca5992c07847bb579c5259a19953dc57255bb08793142ffb/numpy-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:929f0c79ac38bcbd7154fe631dc907abfeddbcc5027a896bd1f7767323271e7a", size = 16524647, upload-time = "2026-06-21T20:57:19.165Z" }, + { url = "https://files.pythonhosted.org/packages/5e/7d/6caf27734c42b65837e7461ed0dbbd6b6fc835060c9714ec59d673bb383a/numpy-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cc4f247a47bbf070bfd70be53ccdcf47b800af563535e7bbe172322197c30e21", size = 18411841, upload-time = "2026-06-21T20:57:21.638Z" }, + { url = "https://files.pythonhosted.org/packages/13/dc/26edadbd812536769a82c2e9e002234e33feb5da43061d47a044f6d309b7/numpy-2.5.0-cp314-cp314-win32.whl", hash = "sha256:5dc71423499fab3f46f7a7201155ade1669ea101f2f429d332df9e72f8161731", size = 6106361, upload-time = "2026-06-21T20:57:23.844Z" }, + { url = "https://files.pythonhosted.org/packages/f2/9e/4dd1459282229a72d92dece2ae9138e5cac94a72263a7ceb48f37434c925/numpy-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:ebb81d9d5443e0309d6c54894c3fbed74ad7da0714352a67b6d773cd189eae73", size = 12551749, upload-time = "2026-06-21T20:57:25.945Z" }, + { url = "https://files.pythonhosted.org/packages/05/a7/6bc6384c080b86c7f6c85c5bc5b540b24f4f679cd144791d99574e90d462/numpy-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3b94d0d0deceebfad3e67ae5c0e5eb87371e8f7a0581cd04a779928c2450cf1e", size = 10617072, upload-time = "2026-06-21T20:57:28.175Z" }, + { url = "https://files.pythonhosted.org/packages/86/6b/4a2b71d66ada5608ae02b63f150dfad520f6940721cb7f029ad270befc0e/numpy-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:22f3d43e362d650bc39db1f17851302874a148ca95ba6981c1dfb5fa6862f35b", size = 11881067, upload-time = "2026-06-21T20:57:30.104Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b2/d365eb40a20efb49d67e9feb90494ed8511282ee1f5fa16006675c65397d/numpy-2.5.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:243563efb4cd7528a264567e9fd206c87826457322521d06206a00bfa316c927", size = 5440290, upload-time = "2026-06-21T20:57:32.193Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5e/e9c03188de5f9b767e46a8fe988bcfd3efad066a4a3fda8b9cb11a93f895/numpy-2.5.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:84881d825ca75249b189bbee875fcfe3238aa5c479e6100893cda566e8e86826", size = 6748371, upload-time = "2026-06-21T20:57:33.933Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1d/68c186a38a5027bae2c4ddd5ea681fdaf8b4d30fb7301def6d8ad270390f/numpy-2.5.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cda12aa4779d42b8771180aba759c96f527d43446d8f380ab59e2b35e8489efd", size = 15214643, upload-time = "2026-06-21T20:57:35.677Z" }, + { url = "https://files.pythonhosted.org/packages/8c/67/73f67b7c7e20635baae9c4c3ead4ae7326a005900297a6110971abd62eb5/numpy-2.5.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c0121101093d2bd74981b10f8837d78e794a8ff57834eb27179f49e1ba11ac6", size = 16690128, upload-time = "2026-06-21T20:57:38.159Z" }, + { url = "https://files.pythonhosted.org/packages/eb/05/d4c1fb0c46d02a27d6b2b8b319a78c90937acec8631c1641874670b31e6f/numpy-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d371c92cfa09da00022f501ab67fafaea813d752eb30ac44336d45b1e5b0268a", size = 16577902, upload-time = "2026-06-21T20:57:40.447Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1d/771c797d50fa26e4888989cccf1d50ee51f530d4e455ad2692dcb64fa711/numpy-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9990713e9c38154c6861e7547f1e3fc7a87e75ff09bab24ef1cc81d81c2835e9", size = 18452814, upload-time = "2026-06-21T20:57:42.875Z" }, + { url = "https://files.pythonhosted.org/packages/e8/46/52fc0d2a68d7643f0f149eeea5a5d8ea2a3507056ac8afa83c9212606e8b/numpy-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:edadfbd4794b1086c0d822f81863e8a68fc129d132fd0bb9e31e955d7fbbbdb7", size = 6253168, upload-time = "2026-06-21T20:57:45.101Z" }, + { url = "https://files.pythonhosted.org/packages/2a/be/6c8d1118b5f13b2881dc095d5b345de19c6638b8959c17409b6eff84c8aa/numpy-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f7e5fa4382967ae6548bd2f174219afb908e294b0d5f625af01166edd5f7d9aa", size = 12736286, upload-time = "2026-06-21T20:57:46.935Z" }, + { url = "https://files.pythonhosted.org/packages/fd/6a/d3a169aaf8536cf228d56a09e04bcb713a2fe4410d4e2105b9419b5a9c89/numpy-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:016623417bb330d719d579daf2d6b9a01ddc52e41a9ed61a47f39fde46dcd865", size = 10686451, upload-time = "2026-06-21T20:57:49.313Z" }, +] + +[[package]] +name = "nvidia-ml-py" +version = "13.610.43" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/b5/a8fbc356f768fa5c9cfd646668fd7d34bf55bdd1c6e20754642a64d930d4/nvidia_ml_py-13.610.43.tar.gz", hash = "sha256:65437eb73d68d0c62c931ca4d45038472faff03bd0b8729abba4b899f70d60f2", size = 52109, upload-time = "2026-06-01T18:54:08.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/45/caa600acfab94560807a20a64b5830d2cd3c3202b7f1328644d70b7d6bd8/nvidia_ml_py-13.610.43-py3-none-any.whl", hash = "sha256:f13c72698edef492f985cc225f14faafe68ae065a2e407f45bdf6f4b9b43fde8", size = 53163, upload-time = "2026-06-01T18:54:07.704Z" }, +] + [[package]] name = "orderly-set" version = "5.5.0" @@ -769,6 +958,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "pandas" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/54/effdcc3c0ff7a08037889200e148ebe94c16c4f653be078c7b3675955df1/pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac", size = 10336065, upload-time = "2026-05-11T18:53:41.099Z" }, + { url = "https://files.pythonhosted.org/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f", size = 9926101, upload-time = "2026-05-11T18:53:43.515Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb", size = 10457553, upload-time = "2026-05-11T18:53:46.394Z" }, + { url = "https://files.pythonhosted.org/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a", size = 10914065, upload-time = "2026-05-11T18:53:49.134Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360", size = 11459188, upload-time = "2026-05-11T18:53:52.527Z" }, + { url = "https://files.pythonhosted.org/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76", size = 11982966, upload-time = "2026-05-11T18:53:55.043Z" }, + { url = "https://files.pythonhosted.org/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5", size = 9876755, upload-time = "2026-05-11T18:53:58.067Z" }, + { url = "https://files.pythonhosted.org/packages/2a/af/33c469653b0ba03b50c3a98192d4c07f0c75c66b263ceb097fce0ee97d31/pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977", size = 9198658, upload-time = "2026-05-11T18:54:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fa/b8c257bd76b8bd060c3a9151c1fca05e9b9c5e3af5d0f549c0356f6d143d/pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04", size = 10787242, upload-time = "2026-05-11T18:54:03.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/eb/f19206ffb0bf1919002969aa448b4702c6594845156a6f8050674855aac3/pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6", size = 10436369, upload-time = "2026-05-11T18:54:06.311Z" }, + { url = "https://files.pythonhosted.org/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c", size = 10358306, upload-time = "2026-05-11T18:54:09.085Z" }, + { url = "https://files.pythonhosted.org/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028", size = 10758394, upload-time = "2026-05-11T18:54:11.956Z" }, + { url = "https://files.pythonhosted.org/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d", size = 11375717, upload-time = "2026-05-11T18:54:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a", size = 11828897, upload-time = "2026-05-11T18:54:17.146Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/e7d20dea247a3e6dc0bd8a6953854afbedc03951def4e7371e05e7263e25/pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1", size = 10900855, upload-time = "2026-05-11T18:54:19.72Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" }, +] + [[package]] name = "platformdirs" version = "4.5.0" @@ -803,6 +1021,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" }, ] +[[package]] +name = "prometheus-client" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/fb/d9aa83ffe43ce1f19e557c0971d04b90561b0cfd50762aafb01968285553/prometheus_client-0.25.0.tar.gz", hash = "sha256:5e373b75c31afb3c86f1a52fa1ad470c9aace18082d39ec0d2f918d11cc9ba28", size = 86035, upload-time = "2026-04-09T19:53:42.359Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/9b/d4b1e644385499c8346fa9b622a3f030dce14cd6ef8a1871c221a17a67e7/prometheus_client-0.25.0-py3-none-any.whl", hash = "sha256:d5aec89e349a6ec230805d0df882f3807f74fd6c1a2fa86864e3c2279059fed1", size = 64154, upload-time = "2026-04-09T19:53:41.324Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + [[package]] name = "propcache" version = "0.4.1" @@ -857,6 +1096,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/d7/7831438e6c3ebbfa6e01a927127a6cb42ad3ab844247f3c5b96bea25d73d/psutil-6.1.1-cp37-abi3-win_amd64.whl", hash = "sha256:f35cfccb065fff93529d2afb4a2e89e363fe63ca1e4a5da22b603a85833c2649", size = 254444, upload-time = "2024-12-19T18:22:11.335Z" }, ] +[[package]] +name = "py-cpuinfo" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/a8/d832f7293ebb21690860d2e01d8115e5ff6f2ae8bbdc953f0eb0fa4bd2c7/py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690", size = 104716, upload-time = "2022-10-25T20:38:06.303Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5", size = 22335, upload-time = "2022-10-25T20:38:27.636Z" }, +] + [[package]] name = "pycares" version = "4.11.0" @@ -894,6 +1142,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/20/c0c5cfcf89725fe533b27bc5f714dc4efa8e782bf697c36f9ddf04ba975d/pycares-4.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:afc6503adf8b35c21183b9387be64ca6810644ef54c9ef6c99d1d5635c01601b", size = 119690, upload-time = "2025-09-09T15:17:59.809Z" }, ] +[[package]] +name = "pycountry" +version = "26.2.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/1d/061b9e7a48b85cfd69f33c33d2ef784a531c359399ad764243399673c8f5/pycountry-26.2.16.tar.gz", hash = "sha256:5b6027d453fcd6060112b951dd010f01f168b51b4bf8a1f1fc8c95c8d94a0801", size = 7711342, upload-time = "2026-02-17T03:42:52.367Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/42/7703bd45b62fecd44cd7d3495423097e2f7d28bc2e99e7c1af68892ab157/pycountry-26.2.16-py3-none-any.whl", hash = "sha256:115c4baf7cceaa30f59a4694d79483c9167dbce7a9de4d3d571c5f3ea77c305a", size = 8044600, upload-time = "2026-02-17T03:42:49.777Z" }, +] + [[package]] name = "pycparser" version = "2.23" @@ -1107,6 +1364,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "questionary" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, +] + [[package]] name = "rapidfuzz" version = "3.14.1" @@ -1211,6 +1480,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/d5/de8f089119205a09da657ed4784c584ede8381a0ce6821212a6d4ca47054/requests_file-3.0.1-py2.py3-none-any.whl", hash = "sha256:d0f5eb94353986d998f80ac63c7f146a307728be051d4d1cd390dbdb59c10fa2", size = 4514, upload-time = "2025-10-20T18:56:41.184Z" }, ] +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + [[package]] name = "ruff" version = "0.14.2" @@ -1256,6 +1538,15 @@ version = "1.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/9e/bd/3704a8c3e0942d711c1299ebf7b9091930adae6675d7c8f476a7ce48653c/sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9", size = 5750, upload-time = "2010-08-24T14:33:52.445Z" } +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -1365,6 +1656,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, ] +[[package]] +name = "typer" +version = "0.26.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/ed/ef06584ccdd5c410df0837951ecd7e15d9a6144ea1bd4c73cecab1a89891/typer-0.26.7.tar.gz", hash = "sha256:e314a34c617e419c091b2830dda3ea1f257134ff593061a8f5b9717ab8dddb3a", size = 201709, upload-time = "2026-06-03T07:18:06.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/2201973529af2c954de0bb725323c3aaed6d7f0ceee8f550dec9185df013/typer-0.26.7-py3-none-any.whl", hash = "sha256:5c87cfbc5d34491c5346ebf49c23e18d56ccb863268d3a8d592b26087c2f5e58", size = 122456, upload-time = "2026-06-03T07:18:05.732Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -1418,6 +1724,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" }, ] +[[package]] +name = "wcwidth" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/b4/51fe890511f0f242d07cb1ebe6a5b6db417262b9d2568b460347c57d95cc/wcwidth-0.8.1.tar.gz", hash = "sha256:faf5b4a5366a72dc49cad48cdf21f52bdf63bdda995178e483ba247ff79089b9", size = 1466072, upload-time = "2026-06-08T05:57:23.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/6e/95b0e537de1f4d4301f76f944642c6da50d1511cc7b3d64dc418a66c7509/wcwidth-0.8.1-py3-none-any.whl", hash = "sha256:f453740b1e4a4f3291faa37944c555d71056c4da08d59809b307ef4feba695c8", size = 323092, upload-time = "2026-06-08T05:57:21.413Z" }, +] + [[package]] name = "yarl" version = "1.22.0"