Skip to content

Commit c4619de

Browse files
author
douglasessousa
committed
refactor: refactor for code smells too many branches and too many statements
1 parent de2e5c4 commit c4619de

13 files changed

Lines changed: 257 additions & 177 deletions

File tree

Lines changed: 110 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
from __future__ import annotations
2+
13
import typing
24
from collections.abc import Callable, Coroutine, Iterable
3-
from dataclasses import dataclass, field, replace
5+
from dataclasses import dataclass, field, replace as dataclass_replace
46
from enum import Enum, auto
57

68
import discord
@@ -25,60 +27,124 @@ class Event(Enum):
2527

2628

2729
@dataclass
28-
class FilterContext:
29-
"""A dataclass containing the information that should be filtered, and output information of the filtering."""
30-
31-
# Input context
32-
event: Event # The type of event
33-
author: User | Member | None # Who triggered the event
34-
channel: TextChannel | VoiceChannel | StageChannel | Thread | DMChannel | None # The channel involved
35-
content: str | Iterable # What actually needs filtering. The Iterable type depends on the filter list.
36-
message: Message | None # The message involved
37-
embeds: list[Embed] = field(default_factory=list) # Any embeds involved
38-
attachments: list[discord.Attachment | FileAttachment] = field(default_factory=list) # Any attachments sent.
30+
class FilterSource:
31+
"""The source/sender metadata for a filtering context."""
32+
33+
event: Event
34+
author: User | Member | None
35+
channel: TextChannel | VoiceChannel | StageChannel | Thread | DMChannel | None
36+
message: Message | None
3937
before_message: Message | None = None
4038
message_cache: MessageCache | None = None
41-
# Output context
42-
dm_content: str = "" # The content to DM the invoker
43-
dm_embed: str = "" # The embed description to DM the invoker
44-
send_alert: bool = False # Whether to send an alert for the moderators
45-
alert_content: str = "" # The content of the alert
46-
alert_embeds: list[Embed] = field(default_factory=list) # Any embeds to add to the alert
47-
action_descriptions: list[str] = field(default_factory=list) # What actions were taken
48-
matches: list[str] = field(default_factory=list) # What exactly was found
49-
notification_domain: str = "" # A domain to send the user for context
50-
filter_info: dict[Filter, str] = field(default_factory=dict) # Additional info from a filter.
51-
messages_deletion: bool = False # Whether the messages were deleted. Can't upload deletion log otherwise.
52-
blocked_exts: set[str] = field(default_factory=set) # Any extensions blocked (used for snekbox)
53-
potential_phish: dict[FilterList, set[str]] = field(default_factory=dict)
54-
# Additional actions to perform
39+
40+
41+
@dataclass
42+
class FilterContent:
43+
"""The content being filtered."""
44+
45+
content: str | Iterable
46+
embeds: list[Embed] = field(default_factory=list)
47+
attachments: list[discord.Attachment | FileAttachment] = field(default_factory=list)
48+
49+
50+
@dataclass
51+
class FilterNotifications:
52+
"""DM and alert content produced by filtering."""
53+
54+
dm_content: str = ""
55+
dm_embed: str = ""
56+
send_alert: bool = False
57+
alert_content: str = ""
58+
alert_embeds: list[Embed] = field(default_factory=list)
59+
notification_domain: str = ""
60+
action_descriptions: list[str] = field(default_factory=list)
61+
62+
63+
@dataclass
64+
class FilterActions:
65+
"""Side effects and deletion metadata produced by filtering."""
66+
5567
additional_actions: list[Callable[[FilterContext], Coroutine]] = field(default_factory=list)
56-
related_messages: set[Message] = field(default_factory=set) # Deletion will include these.
68+
messages_deletion: bool = False
69+
related_messages: set[Message] = field(default_factory=set)
5770
related_channels: set[TextChannel | Thread | DMChannel] = field(default_factory=set)
58-
uploaded_attachments: dict[int, list[str]] = field(default_factory=dict) # Message ID to attachment URLs.
59-
upload_deletion_logs: bool = True # Whether it's allowed to upload deletion logs.
71+
uploaded_attachments: dict[int, list[str]] = field(default_factory=dict)
72+
upload_deletion_logs: bool = True
73+
74+
75+
@dataclass
76+
class FilterResults:
77+
"""Filter match results and tracking data."""
78+
79+
matches: list[str] = field(default_factory=list)
80+
filter_info: dict[Filter, str] = field(default_factory=dict)
81+
blocked_exts: set[str] = field(default_factory=set)
82+
potential_phish: dict[FilterList, set[str]] = field(default_factory=dict)
83+
6084

61-
def __post_init__(self):
62-
# If it's in the context of a DM channel, self.channel won't be None, but self.channel.guild will.
63-
self.in_guild = self.channel is None or self.channel.guild is not None
85+
class FilterContext:
86+
"""A context object containing the information that should be filtered, and output information of the filtering.
87+
88+
Attributes are delegated to sub-objects for organization:
89+
- ``source``: event, author, channel, message, before_message, message_cache
90+
- ``content``: content, embeds, attachments
91+
- ``notifications``: dm_content, dm_embed, send_alert, alert_content, alert_embeds, notification_domain, action_descriptions
92+
- ``actions``: additional_actions, messages_deletion, related_messages, related_channels, uploaded_attachments, upload_deletion_logs
93+
- ``results``: matches, filter_info, blocked_exts, potential_phish
94+
"""
95+
96+
def __init__(self, source, content, notifications=None, actions=None, results=None):
97+
self._source = source
98+
self._content = content
99+
self._notifications = notifications or FilterNotifications()
100+
self._actions = actions or FilterActions()
101+
self._results = results or FilterResults()
102+
self.in_guild = source.channel is None or source.channel.guild is not None
103+
104+
def __getattr__(self, name):
105+
for obj in (self._source, self._content, self._notifications, self._actions, self._results):
106+
if hasattr(obj, name):
107+
return getattr(obj, name)
108+
raise AttributeError(f"'{type(self).__name__}' has no attribute '{name}'")
109+
110+
def __setattr__(self, name, value):
111+
if name.startswith('_') or name == 'in_guild':
112+
object.__setattr__(self, name, value)
113+
return
114+
for obj in (self._source, self._content, self._notifications, self._actions, self._results):
115+
if hasattr(obj, name):
116+
setattr(obj, name, value)
117+
return
118+
object.__setattr__(self, name, value)
64119

65120
@classmethod
66121
def from_message(
67122
cls, event: Event, message: Message, before: Message | None = None, cache: MessageCache | None = None
68123
) -> FilterContext:
69124
"""Create a filtering context from the attributes of a message."""
70-
return cls(
71-
event,
72-
message.author,
73-
message.channel,
74-
message.content,
75-
message,
76-
message.embeds,
77-
message.attachments,
78-
before,
79-
cache
80-
)
125+
source = FilterSource(event, message.author, message.channel, message, before, cache)
126+
content = FilterContent(message.content, message.embeds, message.attachments)
127+
return cls(source, content)
81128

82129
def replace(self, **changes) -> FilterContext:
83130
"""Return a new context object assigning new values to the specified fields."""
84-
return replace(self, **changes)
131+
sub_objects = {
132+
'_source': self._source,
133+
'_content': self._content,
134+
'_notifications': self._notifications,
135+
'_actions': self._actions,
136+
'_results': self._results,
137+
}
138+
sub_changes = {}
139+
for key, value in changes.items():
140+
for attr_name, obj in sub_objects.items():
141+
if hasattr(obj, key):
142+
sub_changes.setdefault(attr_name, {})[key] = value
143+
break
144+
return FilterContext(
145+
source=dataclass_replace(self._source, **sub_changes.get('_source', {})),
146+
content=dataclass_replace(self._content, **sub_changes.get('_content', {})),
147+
notifications=dataclass_replace(self._notifications, **sub_changes.get('_notifications', {})),
148+
actions=dataclass_replace(self._actions, **sub_changes.get('_actions', {})),
149+
results=dataclass_replace(self._results, **sub_changes.get('_results', {})),
150+
)

bot/exts/filtering/_filter_lists/antispam.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from pydis_core.utils import scheduling
1414
from pydis_core.utils.logging import get_logger
1515

16-
from bot.exts.filtering._filter_context import FilterContext
16+
from bot.exts.filtering._filter_context import FilterContent, FilterContext, FilterSource
1717
from bot.exts.filtering._filter_lists.filter_list import ListType, SubscribingAtomicList, UniquesListBase
1818
from bot.exts.filtering._filters.antispam import antispam_filter_types
1919
from bot.exts.filtering._filters.filter import Filter, UniqueFilter
@@ -158,7 +158,7 @@ async def send_alert(self, antispam_list: AntispamList) -> None:
158158
return
159159

160160
ctx, *other_contexts = self.contexts
161-
new_ctx = FilterContext(ctx.event, ctx.author, ctx.channel, ctx.content, ctx.message)
161+
new_ctx = FilterContext(FilterSource(ctx.event, ctx.author, ctx.channel, ctx.message), FilterContent(ctx.content))
162162
all_descriptions_counts = Counter(reduce(
163163
add, (other_ctx.action_descriptions for other_ctx in other_contexts), ctx.action_descriptions
164164
))

bot/exts/filtering/_filters/filter.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from abc import ABC, abstractmethod
2+
from dataclasses import dataclass
23
from typing import Any
34

45
import arrow
@@ -9,6 +10,13 @@
910
from bot.exts.filtering._utils import FieldRequiring
1011

1112

13+
@dataclass
14+
class FilterTimestamps:
15+
"""Timestamps for when a filter was created and last updated."""
16+
created_at: arrow.Arrow
17+
updated_at: arrow.Arrow
18+
19+
1220
class Filter(FieldRequiring):
1321
"""
1422
A class representing a filter.
@@ -27,8 +35,10 @@ def __init__(self, filter_data: dict, defaults: Defaults | None = None):
2735
self.id = filter_data["id"]
2836
self.content = filter_data["content"]
2937
self.description = filter_data["description"]
30-
self.created_at = arrow.get(filter_data["created_at"])
31-
self.updated_at = arrow.get(filter_data["updated_at"])
38+
self.timestamps = FilterTimestamps(
39+
created_at=arrow.get(filter_data["created_at"]),
40+
updated_at=arrow.get(filter_data["updated_at"]),
41+
)
3242
self.actions, self.validations = create_settings(filter_data["settings"], defaults=defaults)
3343
if self.extra_fields_type:
3444
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]]:
5060

5161
return settings, filter_settings
5262

63+
@property
64+
def last_updated(self) -> arrow.Arrow:
65+
"""The most recent time this filter was created or updated."""
66+
return max(self.timestamps.created_at, self.timestamps.updated_at)
67+
5368
@abstractmethod
5469
async def triggered_on(self, ctx: FilterContext) -> bool:
5570
"""Search for the filter's content within a given context."""
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from dataclasses import dataclass
2+
from typing import TYPE_CHECKING
3+
4+
if TYPE_CHECKING:
5+
from bot.exts.filtering._filters.filter import Filter
6+
from bot.exts.filtering._settings_types.settings_entry import SettingsEntry
7+
8+
9+
@dataclass
10+
class LoadedTypes:
11+
"""Container for loaded type metadata used across the filtering UI."""
12+
13+
filters: dict[str, type["Filter"]]
14+
settings: dict[str, tuple[str, "SettingsEntry", type]]
15+
filter_settings: dict[str, dict[str, tuple[str, "SettingsEntry", type]]]

bot/exts/filtering/_ui/filter.py

Lines changed: 17 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from bot.exts.filtering._filter_lists.filter_list import FilterList, ListType
1212
from bot.exts.filtering._filters.filter import Filter
13+
from bot.exts.filtering._loaded_types import LoadedTypes
1314
from bot.exts.filtering._ui.ui import (
1415
COMPONENT_TIMEOUT,
1516
CustomCallbackSelect,
@@ -124,8 +125,7 @@ def __init__(
124125
description: str | None,
125126
settings_overrides: dict,
126127
filter_settings_overrides: dict,
127-
loaded_settings: dict,
128-
loaded_filter_settings: dict,
128+
loaded: LoadedTypes,
129129
author: User,
130130
embed: Embed,
131131
confirm_callback: Callable
@@ -138,8 +138,7 @@ def __init__(
138138
self.description = description
139139
self.settings_overrides = settings_overrides
140140
self.filter_settings_overrides = filter_settings_overrides
141-
self.loaded_settings = loaded_settings
142-
self.loaded_filter_settings = loaded_filter_settings
141+
self.loaded = loaded
143142
self.embed = embed
144143
self.confirm_callback = confirm_callback
145144

@@ -148,10 +147,10 @@ def __init__(
148147
)
149148
populate_embed_from_dict(embed, all_settings_repr_dict)
150149

151-
self.type_per_setting_name = {setting: info[2] for setting, info in loaded_settings.items()}
150+
self.type_per_setting_name = {setting: info[2] for setting, info in loaded.settings.items()}
152151
self.type_per_setting_name.update({
153152
f"{filter_type.name}/{name}": type_
154-
for name, (_, _, type_) in loaded_filter_settings.get(filter_type.name, {}).items()
153+
for name, (_, _, type_) in loaded.filter_settings.get(filter_type.name, {}).items()
155154
})
156155

157156
add_select = CustomCallbackSelect(
@@ -381,8 +380,7 @@ def copy(self) -> FilterEditView:
381380
self.description,
382381
self.settings_overrides,
383382
self.filter_settings_overrides,
384-
self.loaded_settings,
385-
self.loaded_filter_settings,
383+
self.loaded,
386384
self.author,
387385
self.embed,
388386
self.confirm_callback
@@ -395,10 +393,10 @@ def _parse_filter_list_setting(
395393
settings: dict,
396394
filter_list: FilterList,
397395
list_type: ListType,
398-
loaded_settings: dict,
396+
loaded: LoadedTypes,
399397
) -> None:
400398
"""Parse and validate a filter list setting, updating `settings` in place."""
401-
type_ = loaded_settings[setting][2]
399+
type_ = loaded.settings[setting][2]
402400
try:
403401
parsed_value = parse_value(value, type_)
404402
if not repr_equals(parsed_value, filter_list[list_type].default(setting)):
@@ -412,17 +410,17 @@ def _parse_filter_setting(
412410
value: str,
413411
filter_settings: dict,
414412
filter_type: type[Filter],
415-
loaded_filter_settings: dict,
413+
loaded: LoadedTypes,
416414
) -> None:
417415
"""Parse and validate a filter-specific setting, updating `filter_settings` in place."""
418416
filter_name, filter_setting_name = setting.split("/", maxsplit=1)
419417
if filter_name.lower() != filter_type.name.lower():
420418
raise BadArgument(
421419
f"A setting for a {filter_name!r} filter was provided, but the filter name is {filter_type.name!r}"
422420
)
423-
if filter_setting_name not in loaded_filter_settings[filter_type.name]:
421+
if filter_setting_name not in loaded.filter_settings[filter_type.name]:
424422
raise BadArgument(f"{setting!r} is not a recognized setting.")
425-
type_ = loaded_filter_settings[filter_type.name][filter_setting_name][2]
423+
type_ = loaded.filter_settings[filter_type.name][filter_setting_name][2]
426424
try:
427425
parsed_value = parse_value(value, type_)
428426
if not repr_equals(parsed_value, getattr(filter_type.extra_fields_type(), filter_setting_name)):
@@ -436,19 +434,18 @@ def _parse_settings(
436434
filter_list: FilterList,
437435
list_type: ListType,
438436
filter_type: type[Filter],
439-
loaded_settings: dict,
440-
loaded_filter_settings: dict,
437+
loaded: LoadedTypes,
441438
) -> tuple[dict, dict]:
442439
"""Parse and validate all settings, returning (list_settings, filter_settings)."""
443440
settings = {}
444441
filter_settings = {}
445442
for setting, value in raw_settings.items():
446-
if setting in loaded_settings:
447-
_parse_filter_list_setting(setting, value, settings, filter_list, list_type, loaded_settings)
443+
if setting in loaded.settings:
444+
_parse_filter_list_setting(setting, value, settings, filter_list, list_type, loaded)
448445
elif "/" not in setting:
449446
raise BadArgument(f"{setting!r} is not a recognized setting.")
450447
else:
451-
_parse_filter_setting(setting, value, filter_settings, filter_type, loaded_filter_settings)
448+
_parse_filter_setting(setting, value, filter_settings, filter_type, loaded)
452449
return settings, filter_settings
453450

454451

@@ -472,8 +469,7 @@ def description_and_settings_converter(
472469
filter_list: FilterList,
473470
list_type: ListType,
474471
filter_type: type[Filter],
475-
loaded_settings: dict,
476-
loaded_filter_settings: dict,
472+
loaded: LoadedTypes,
477473
input_data: str
478474
) -> tuple[str, dict[str, Any], dict[str, Any]]:
479475
"""Parse a string representing a possible description and setting overrides, and validate the setting names."""
@@ -492,7 +488,7 @@ def description_and_settings_converter(
492488
template = raw_settings.pop("--template", None)
493489

494490
settings, filter_settings = _parse_settings(
495-
raw_settings, filter_list, list_type, filter_type, loaded_settings, loaded_filter_settings
491+
raw_settings, filter_list, list_type, filter_type, loaded
496492
)
497493

498494
if template is not None:

0 commit comments

Comments
 (0)