From 2d9a80525d9f63c14da99d75b7518c6843c5ff62 Mon Sep 17 00:00:00 2001 From: Truman Mulholland Date: Mon, 16 Sep 2024 19:23:23 +1200 Subject: [PATCH 1/6] Stricter typing with mypy --- .github/workflows/main.yml | 12 +++ dynamo/_evt_policy.py | 12 ++- dynamo/bot.py | 59 +++++++------ dynamo/extensions/cogs/dev.py | 32 ++++--- dynamo/extensions/cogs/events.py | 18 ++-- dynamo/extensions/cogs/general.py | 51 +++++++----- dynamo/extensions/cogs/help.py | 22 ++--- dynamo/launcher.py | 7 +- dynamo/utils/cache.py | 133 +++++++++++++----------------- dynamo/utils/context.py | 4 +- dynamo/utils/helper.py | 5 +- dynamo/utils/identicon.py | 15 ++-- dynamo/utils/spotify.py | 10 +-- dynamo/utils/transformer.py | 18 ---- dynamo/utils/wrappers.py | 17 ++-- poetry.lock | 98 +++++++++++++++++++++- pyproject.toml | 47 ++++------- 17 files changed, 321 insertions(+), 239 deletions(-) delete mode 100644 dynamo/utils/transformer.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 074a645..ec2bc28 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -42,3 +42,15 @@ jobs: cache: 'poetry' - run: poetry install --with=dev - run: poetry run pytest tests --asyncio-mode=strict -n logical + + type-checks: + name: Type check with mypy + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: 'poetry' + - run: poetry install --with=dev + - run: poetry run mypy dynamo diff --git a/dynamo/_evt_policy.py b/dynamo/_evt_policy.py index 89f2068..9059660 100644 --- a/dynamo/_evt_policy.py +++ b/dynamo/_evt_policy.py @@ -3,22 +3,20 @@ def get_event_loop_policy() -> asyncio.AbstractEventLoopPolicy: - policy = asyncio.DefaultEventLoopPolicy - if sys.platform in ("win32", "cygwin", "cli"): try: import winloop except ImportError: - policy = asyncio.WindowsSelectorEventLoopPolicy + return asyncio.WindowsSelectorEventLoopPolicy() else: - policy = winloop.EventLoopPolicy + return winloop.EventLoopPolicy() else: try: - import uvloop + import uvloop # type: ignore except ImportError: pass else: - policy = uvloop.EventLoopPolicy + return uvloop.EventLoopPolicy() # type: ignore - return policy() + return asyncio.DefaultEventLoopPolicy() diff --git a/dynamo/bot.py b/dynamo/bot.py index 0c8fea5..d75e6e4 100644 --- a/dynamo/bot.py +++ b/dynamo/bot.py @@ -1,8 +1,8 @@ from __future__ import annotations import logging -from collections.abc import AsyncGenerator, Coroutine, Generator -from typing import Any +from collections.abc import AsyncGenerator, Generator +from typing import Any, Generic, TypeVar, cast import aiohttp import discord @@ -27,12 +27,21 @@ Quantum entanglement. """ +CogT = TypeVar("CogT", bound=commands.Cog) +CommandT = TypeVar( + "CommandT", + bound=commands.Command[Any, ..., Any] | app_commands.AppCommand | commands.HybridCommand, +) + + +class VersionableTree(app_commands.CommandTree["Dynamo"], Generic[CommandT]): + application_commands: dict[int | None, list[app_commands.AppCommand]] + cache: dict[int | None, dict[CommandT | str, str]] -class VersionableTree(app_commands.CommandTree["Dynamo"]): - def __init__(self, *args: tuple[Any, ...], **kwargs: dict[str, Any]) -> None: + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) - self.application_commands: dict[int | None, list[app_commands.AppCommand]] = {} - self.cache: dict[int | None, dict[app_commands.AppCommand | commands.HybridCommand | str, str]] = {} + self.application_commands = {} + self.cache = {} async def get_hash(self, tree: app_commands.CommandTree[Dynamo]) -> bytes: """Get the hash of the command tree. @@ -57,9 +66,7 @@ async def get_hash(self, tree: app_commands.CommandTree[Dynamo]) -> bytes: return xxhash.xxh64_digest(msgspec.msgpack.encode(payload), seed=0) # See: https://gist.github.com/LeoCx1000/021dc52981299b95ea7790416e4f5ca4#file-mentionable_tree-py - async def sync( - self, *, guild: discord.abc.Snowflake | None = None - ) -> Coroutine[Any, Any, list[app_commands.AppCommand]]: + async def sync(self, *, guild: discord.abc.Snowflake | None = None) -> list[app_commands.AppCommand]: result = await super().sync(guild=guild) guild_id = guild.id if guild else None self.application_commands[guild_id] = result @@ -80,10 +87,7 @@ async def get_or_fetch_commands(self, guild: discord.abc.Snowflake | None = None return await self.fetch_commands(guild=guild) async def find_mention_for( - self, - command: app_commands.AppCommand | commands.HybridCommand | str, - *, - guild: discord.abc.Snowflake | None = None, + self, command: CommandT | str, *, guild: discord.abc.Snowflake | None = None ) -> str | None: guild_id = guild.id if guild else None try: @@ -99,7 +103,7 @@ async def find_mention_for( if check_global and not _command: _command = discord.utils.get(self.walk_commands(), qualified_name=command) else: - _command = command + _command = cast(app_commands.Command, command) if not _command: return None @@ -130,14 +134,14 @@ def _walk_children( async def walk_mentions( self, *, guild: discord.abc.Snowflake | None = None - ) -> AsyncGenerator[tuple[app_commands.Command, str], None, None]: + ) -> AsyncGenerator[tuple[app_commands.Command, str], None]: for command in self._walk_children(self.get_commands(guild=guild, type=discord.AppCommandType.chat_input)): - mention = await self.find_mention_for(command, guild=guild) + mention = await self.find_mention_for(cast(CommandT, command), guild=guild) if mention: yield command, mention if guild and self.fallback_to_global is True: for command in self._walk_children(self.get_commands(guild=None, type=discord.AppCommandType.chat_input)): - mention = await self.find_mention_for(command, guild=guild) + mention = await self.find_mention_for(cast(CommandT, command), guild=guild) if mention: yield command, mention else: @@ -157,11 +161,11 @@ def _prefix_callable(bot: Dynamo, msg: discord.Message) -> list[str]: class Dynamo(commands.AutoShardedBot): session: aiohttp.ClientSession user: discord.ClientUser + context: Context logging_handler: Any bot_app_info: discord.AppInfo - tree: VersionableTree - def __init__(self, session: aiohttp.ClientSession, *args: tuple[Any, ...], **kwargs: dict[str, Any]) -> None: + def __init__(self, connector: aiohttp.TCPConnector, session: aiohttp.ClientSession) -> None: self.session = session allowed_mentions = discord.AllowedMentions(roles=False, everyone=False, users=True) intents = discord.Intents( @@ -172,7 +176,7 @@ def __init__(self, session: aiohttp.ClientSession, *args: tuple[Any, ...], **kwa presences=True, ) super().__init__( - *args, + connector=connector, command_prefix=_prefix_callable, description=description, pm_help=None, @@ -183,7 +187,6 @@ def __init__(self, session: aiohttp.ClientSession, *args: tuple[Any, ...], **kwa intents=intents, enable_debug_events=True, tree_cls=VersionableTree, - **kwargs, ) async def setup_hook(self) -> None: @@ -213,13 +216,17 @@ async def setup_hook(self) -> None: fp.seek(0) fp.write(tree_hash) + @property + def tree(self) -> VersionableTree: + return self.tree + @property def owner(self) -> discord.User: return self.bot_app_info.owner @property def dev_guild(self) -> discord.Guild: - return discord.Object(id=681408104495448088, type=discord.Guild) + return cast(discord.Guild, discord.Object(id=681408104495448088, type=discord.Guild)) async def start(self, token: str, *, reconnect: bool = True) -> None: return await super().start(token, reconnect=reconnect) @@ -234,7 +241,11 @@ async def on_ready(self) -> None: log.info("Ready: %s (ID: %s)", self.user, self.user.id) - async def get_context( - self, origin: discord.Interaction | discord.Message, /, *, cls: type[Context] = Context + async def get_context( # type: ignore + self, + origin: discord.Message | discord.Interaction[Dynamo], + /, + *, + cls: type[Context] = Context, ) -> Context: return await super().get_context(origin, cls=cls) diff --git a/dynamo/extensions/cogs/dev.py b/dynamo/extensions/cogs/dev.py index f8559e5..0070e96 100644 --- a/dynamo/extensions/cogs/dev.py +++ b/dynamo/extensions/cogs/dev.py @@ -8,7 +8,8 @@ from dynamo.bot import Dynamo from dynamo.utils.cache import cached_functions -from dynamo.utils.context import Status +from dynamo.utils.context import Context, Status +from dynamo.utils.converter import GuildConverter from dynamo.utils.helper import ROOT, get_cog log = logging.getLogger(__name__) @@ -25,11 +26,16 @@ class Dev(commands.GroupCog, group_name="dev"): def __init__(self, bot: Dynamo) -> None: self.bot: Dynamo = bot - async def cog_check(self, ctx: commands.Context) -> bool: + async def cog_check(self, ctx: commands.Context) -> bool: # type: ignore[override] return await self.bot.is_owner(ctx.author) @commands.hybrid_group(invoke_without_command=True, name="sync", aliases=("s",)) - async def sync(self, ctx: commands.Context, guild_id: int | None, copy: bool = False) -> None: + async def sync( + self, + ctx: commands.Context, + guild: discord.Guild = commands.param(converter=GuildConverter, default=None, displayed_name="guild_id"), + copy: bool = False, + ) -> None: """Sync slash commands Parameters @@ -39,8 +45,6 @@ async def sync(self, ctx: commands.Context, guild_id: int | None, copy: bool = F copy: bool Copy global commands to the specified guild. (Default: False) """ - guild: discord.Guild = discord.Object(id=guild_id, type=discord.Guild) if guild_id else ctx.guild - if copy: self.bot.tree.copy_global_to(guild=guild) @@ -54,7 +58,11 @@ async def sync_global(self, ctx: commands.Context) -> None: await ctx.send(f"Successfully synced {len(commands)} commands") @sync.command(name="clear", aliases=("c",)) - async def clear_commands(self, ctx: commands.Context, guild_id: int | None) -> None: + async def clear_commands( + self, + ctx: Context, + guild: discord.Guild = commands.param(converter=GuildConverter, default=None, displayed_name="guild_id"), + ) -> None: """Clear all slash commands Parameters @@ -65,8 +73,6 @@ async def clear_commands(self, ctx: commands.Context, guild_id: int | None) -> N if not await ctx.prompt("Are you sure you want to clear all commands?"): return - guild: discord.Guild | None = discord.Object(id=guild_id, type=discord.Guild) if guild_id else None - self.bot.tree.clear_commands(guild=guild) await ctx.send("Successfully cleared all commands") @@ -125,7 +131,7 @@ async def reload_or_load_extension(self, module: str) -> None: await self.bot.load_extension(module) @_reload.command(name="all") - async def _reload_all(self, ctx: commands.Context) -> None: + async def _reload_all(self, ctx: Context) -> None: """Reload all cogs""" confirm = await ctx.prompt("Are you sure you want to reload all cogs?") if not confirm: @@ -143,16 +149,16 @@ async def _reload_all(self, ctx: commands.Context) -> None: log.exception("Failed to reload %s", module) log.debug("Reloaded %d/%d utilities", len(utils_modules), len(all_utils)) - extensions = self.bot.extensions.copy() - statuses: set[tuple[Status, str]] = [] + extensions = set(self.bot.extensions) + statuses: set[tuple[Status, str]] = set() for ext in extensions: try: await self.reload_or_load_extension(ext) except commands.ExtensionError: log.exception("Failed to reload extension %s", ext) - statuses.append((Status.FAILURE, ext)) + statuses.add((Status.FAILURE, ext)) else: - statuses.append((Status.SUCCESS, ext)) + statuses.add((Status.SUCCESS, ext)) success_count = sum(1 for status, _ in statuses if status == Status.SUCCESS) log.debug("Reloaded %d/%d extensions", success_count, len(extensions)) diff --git a/dynamo/extensions/cogs/events.py b/dynamo/extensions/cogs/events.py index 12c33ae..d2fc4fd 100644 --- a/dynamo/extensions/cogs/events.py +++ b/dynamo/extensions/cogs/events.py @@ -1,4 +1,5 @@ import logging +from typing import Any import discord from discord.ext import commands @@ -10,12 +11,12 @@ class Dropdown(discord.ui.Select): - def __init__(self, events: list[discord.ScheduledEvent]) -> None: + def __init__(self, events: list[discord.ScheduledEvent], *args: Any, **kwargs: Any) -> None: self.events: list[discord.ScheduledEvent] = events options = [discord.SelectOption(label=e.name, value=str(e.id), description="An event") for e in events] - super().__init__(placeholder="Select an event", min_values=1, max_values=1, options=options) + super().__init__(*args, placeholder="Select an event", min_values=1, max_values=1, options=options, **kwargs) async def callback(self, interaction: discord.Interaction) -> None: if (event := next((e for e in self.events if str(e.id) == self.values[0]), None)) is None: @@ -26,9 +27,9 @@ async def callback(self, interaction: discord.Interaction) -> None: class DropdownView(discord.ui.View): - def __init__(self, events: list[discord.ScheduledEvent]) -> None: + def __init__(self, events: list[discord.ScheduledEvent], *args: Any, **kwargs: Any) -> None: super().__init__() - self.add_item(Dropdown(events)) + self.add_item(Dropdown(events, *args, **kwargs)) @async_lru_cache() @@ -52,7 +53,7 @@ class Events(commands.Cog, name="Events"): def __init__(self, bot: Dynamo) -> None: self.bot: Dynamo = bot - async def cog_check(self, ctx: commands.Context) -> bool: + def cog_check(self, ctx: commands.Context) -> bool: return ctx.guild is not None @commands.hybrid_command(name="event") @@ -66,7 +67,7 @@ async def event(self, ctx: commands.Context, event: int | None) -> None: """ if event is not None: try: - ev = await ctx.guild.fetch_scheduled_event(event) + ev = await ctx.guild.fetch_scheduled_event(event) # type: ignore except discord.NotFound: await ctx.send(f"No event with id: {event}", ephemeral=True) return @@ -74,12 +75,11 @@ async def event(self, ctx: commands.Context, event: int | None) -> None: await ctx.send(interested, ephemeral=True) return - if not (events := await fetch_events(ctx.guild)): + if not (events := await fetch_events(ctx.guild)): # type: ignore await ctx.send("No events found!", ephemeral=True) return view = DropdownView(events) - view.message = await ctx.send("Select an event", ephemeral=True, view=view) - await view.wait() + await ctx.send("Select an event", ephemeral=True, view=view) async def setup(bot: Dynamo) -> None: diff --git a/dynamo/extensions/cogs/general.py b/dynamo/extensions/cogs/general.py index e7c1111..fdeb834 100644 --- a/dynamo/extensions/cogs/general.py +++ b/dynamo/extensions/cogs/general.py @@ -1,5 +1,6 @@ import logging from io import BytesIO +from typing import cast from urllib.parse import urlparse import discord @@ -7,24 +8,24 @@ from dynamo.bot import Dynamo from dynamo.utils.context import Context +from dynamo.utils.converter import MemberTransformer from dynamo.utils.format import human_join from dynamo.utils.helper import derive_seed from dynamo.utils.identicon import Identicon, get_colors, identicon_buffer, seed_from_time from dynamo.utils.spotify import SpotifyCard, fetch_album_cover from dynamo.utils.time import human_timedelta -from dynamo.utils.transformer import MemberTransformer log = logging.getLogger(__name__) -def embed_from_user(user: discord.Member | discord.User) -> discord.Embed: +def embed_from_user(user: discord.Member | discord.User | discord.ClientUser) -> discord.Embed: e = discord.Embed(color=user.color) e.set_footer(text=f"ID: {user.id}") avatar = user.display_avatar.with_static_format("png") e.set_author(name=str(user), icon_url=avatar.url) if not user.bot: e.add_field(name="Account Created", value=f"") - if not isinstance(user, discord.ClientUser): + if not isinstance(user, (discord.ClientUser, discord.User)) and user.joined_at: e.add_field(name="Joined Server", value=f"") e.set_image(url=avatar.url) return e @@ -71,32 +72,34 @@ async def user(self, ctx: commands.Context, user: discord.Member | discord.User await ctx.send(embed=embed_from_user(user or ctx.author), ephemeral=True) @commands.hybrid_command(name="identicon", aliases=("i", "idt")) - async def identicon(self, ctx: commands.Context, seed: MemberTransformer = None) -> None: + async def identicon( + self, + ctx: commands.Context, + seed: discord.Member | str = commands.param(converter=MemberTransformer, default=None), + ) -> None: """Generate an identicon from a user or string Parameters ---------- - seed: str | discord.User | discord.Member + seed: MemberTransformer | None The seed to use. Random seed if empty. """ - if not seed: - seed = seed_from_time() + seed_to_use: discord.Member | str | int = seed if seed is not None else seed_from_time() + if isinstance(seed_to_use, str) and (parsed := urlparse(seed_to_use)).scheme and parsed.netloc: + seed_to_use = (parsed.netloc + parsed.path).replace("/", "-") - if isinstance(seed, str) and (parsed := urlparse(seed)).scheme and parsed.netloc: - seed = (parsed.netloc + parsed.path).replace("/", "-") + display_name = seed_to_use if (isinstance(seed_to_use, (str, int))) else seed_to_use.display_name - display_name = seed if (isinstance(seed, (str, int))) else seed.display_name + fname: str | int = seed_to_use if isinstance(seed_to_use, (str, int)) else seed_to_use.id + seed_to_use = derive_seed(fname) + fg, bg = get_colors(seed=seed_to_use) - fname = seed if isinstance(seed, (str, int)) else seed.id - seed = derive_seed(fname) - fg, bg = get_colors(seed=seed) - - idt_bytes = await identicon_buffer(Identicon(5, fg, bg, 0.4, seed)) + idt_bytes = await identicon_buffer(Identicon(5, fg, bg, 0.4, seed_to_use)) log.debug("Identicon generated for %s", fname) file = discord.File(BytesIO(idt_bytes), filename=f"{fname}.png") cmd_mention = await self.bot.tree.find_mention_for("general identicon", guild=ctx.guild) - prefix = self.bot.prefixes.get(ctx.guild.id, ["d!", "d?"])[0] + prefix = self.bot.prefixes.get(ctx.guild.id, ["d!", "d?"])[0] # type: ignore[union-attr] description = f"**Command:**\n{cmd_mention} {display_name}\n{prefix}identicon {display_name}" e = discord.Embed(title=display_name, description=description, color=discord.Color.from_rgb(*fg.as_tuple())) @@ -104,22 +107,26 @@ async def identicon(self, ctx: commands.Context, seed: MemberTransformer = None) await ctx.send(embed=e, file=file) @commands.hybrid_command(name="spotify", aliases=("sp", "applemusic")) - async def spotify(self, ctx: Context, user: discord.Member | None = None) -> None: + async def spotify(self, ctx: Context, user: discord.Member | discord.User | None = None) -> None: """Generate a spotify card for a track""" if user is None: user = ctx.author if user.bot: - return None + return + + user = cast(discord.Member, user) activity: discord.Spotify | None = next((a for a in user.activities if isinstance(a, discord.Spotify)), None) if activity is None: - return await ctx.send("User is not listening to Spotify.") + await ctx.send("User is not listening to Spotify.") + return card = SpotifyCard() album_cover: bytes | None = await fetch_album_cover(activity.album_cover_url, self.bot.session) if album_cover is None: - return await ctx.send("Failed to fetch album cover.") + await ctx.send("Failed to fetch album cover.") + return color = activity.color.to_rgb() @@ -127,7 +134,7 @@ async def spotify(self, ctx: Context, user: discord.Member | None = None) -> Non name=activity.title, artists=activity.artists, color=color, - album=BytesIO(album_cover), + album=album_cover, duration=activity.duration, end=activity.end, ) @@ -143,7 +150,7 @@ async def spotify(self, ctx: Context, user: discord.Member | None = None) -> Non ) embed.set_footer(text=f"Requested by {ctx.author!s}", icon_url=ctx.author.display_avatar.url) embed.set_image(url=f"attachment://{fname}") - return await ctx.send(embed=embed, file=file) + await ctx.send(embed=embed, file=file) async def setup(bot: Dynamo) -> None: diff --git a/dynamo/extensions/cogs/help.py b/dynamo/extensions/cogs/help.py index 082e90b..cb38ee1 100644 --- a/dynamo/extensions/cogs/help.py +++ b/dynamo/extensions/cogs/help.py @@ -17,18 +17,19 @@ class HelpEmbed(discord.Embed): - def __init__(self, color: discord.Color = discord.Color.dark_embed(), **kwargs: dict[str, Any]): + def __init__(self, color: discord.Color = discord.Color.dark_embed(), **kwargs: Any): super().__init__(**kwargs) text = ( "Use help [command] or help [category] for more information" "\nRequired parameters: | Optional parameters: [optional]" ) self.set_footer(text=text) - self.color = color + # Assign to self.colour which aliases to self.color + self.colour = color class DynamoHelp(commands.HelpCommand): - def __init__(self): + def __init__(self) -> None: super().__init__( command_attrs={ "help": "The help command for the bot", @@ -37,10 +38,10 @@ def __init__(self): ) self.blacklisted = ["help_command"] - async def send(self, **kwargs: dict[str, Any]) -> None: + async def send(self, **kwargs: Any) -> None: await self.get_destination().send(**kwargs) - async def send_bot_help(self, mapping: Mapping[CogT, list[commands.Command[CogT, T, P]]]) -> None: + async def send_bot_help(self, mapping: Mapping[CogT, list[commands.Command[CogT, P, T]]]) -> None: ctx = self.context embed = HelpEmbed(title=f"{ctx.me.display_name} Help") embed.set_thumbnail(url=ctx.me.display_avatar) @@ -61,7 +62,7 @@ async def send_bot_help(self, mapping: Mapping[CogT, list[commands.Command[CogT, await self.send(embed=embed) - async def send_command_help(self, command: commands.Command[CogT, T, P]) -> None: + async def send_command_help(self, command: commands.Command[CogT, P, T]) -> None: signature = self.get_command_signature(command) embed = HelpEmbed(title=signature, description=f"```{command.help or "No help found..."}```") @@ -84,8 +85,8 @@ async def send_help_embed( self, title: str, description: str, - commands: list[commands.Command], - aliases: list[str] | None = None, + commands: set[commands.Command[CogT, P, T]], + aliases: list[str] | tuple[str] | None = None, category: str | None = None, ) -> None: embed = HelpEmbed(title=title, description=description or "No help found...") @@ -113,7 +114,7 @@ async def send_group_help(self, group: commands.Group) -> None: async def send_cog_help(self, cog: commands.Cog) -> None: title = cog.qualified_name or "No" await self.send_help_embed( - f"{title} Category", f"```{cog.description or 'No description'}```", cog.get_commands() + f"{title} Category", f"```{cog.description or 'No description'}```", set(cog.get_commands()) ) @@ -125,8 +126,9 @@ def __init__(self, bot: Dynamo) -> None: help_command.cog = self bot.help_command = help_command - def cog_unload(self) -> None: + async def cog_unload(self) -> None: self.bot.help_command = self._original_help_command + await super().cog_unload() async def setup(bot: Dynamo) -> None: diff --git a/dynamo/launcher.py b/dynamo/launcher.py index 0f567da..d921ef4 100644 --- a/dynamo/launcher.py +++ b/dynamo/launcher.py @@ -29,7 +29,8 @@ def get_version() -> str: parent_dir = resolve_path_with_links(Path(__file__).parent.parent, True) with Path.open(parent_dir / "pyproject.toml") as f: data = toml.load(f) - return data["tool"]["poetry"]["version"] + version: str = data["tool"]["poetry"]["version"] + return version class RemoveNoise(logging.Filter): @@ -94,8 +95,10 @@ def run_bot() -> None: async def entrypoint() -> None: try: + if not (token := _get_token()): + return async with bot: - await bot.start(_get_token()) + await bot.start(token) finally: if not bot.is_closed(): await bot.close() diff --git a/dynamo/utils/cache.py b/dynamo/utils/cache.py index 36645a8..3878764 100644 --- a/dynamo/utils/cache.py +++ b/dynamo/utils/cache.py @@ -1,15 +1,13 @@ +from __future__ import annotations + import asyncio from collections import OrderedDict from dataclasses import dataclass -from functools import wraps -from typing import Awaitable, Callable, ParamSpec, TypeVar +from typing import Any, Awaitable, Callable, Coroutine, Generic, ParamSpec, Protocol, TypeVar, cast P = ParamSpec("P") R = TypeVar("R") -# Awaitable[R] is essentially Coroutine[..., ..., R] -A = TypeVar("A", bound=Callable[P, Awaitable[R]]) - @dataclass(slots=True) class CacheInfo: @@ -71,23 +69,67 @@ class CacheKey: args: tuple kwargs: frozenset - def __post_init__(self) -> None: - """Convert kwargs to a frozenset for hashing.""" - object.__setattr__(self, "kwargs", frozenset(self.kwargs.items())) - def __hash__(self) -> int: """Compute a hash value for the cache key.""" return hash((self.func, self.args, self.kwargs)) def __repr__(self) -> str: """Return a string representation of the cache key.""" - return f"{self.__qualname__}(func={self.func.__name__}, args={self.args}, kwargs={self.kwargs})" + return f"func={self.func.__name__}, args={self.args}, kwargs={self.kwargs}" + + +_cached: dict[Callable[..., Awaitable[Any]], AsyncCacheable] = {} + + +class Cacheable(Protocol[P]): + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Awaitable[R]: ... + def cache_info(self) -> CacheInfo: ... + def cache_clear(self, *args: P.args, **kwargs: P.kwargs) -> bool: ... + def cache_clear_all(self) -> None: ... + + +class AsyncCacheable(Generic[P, R]): + def __init__(self, func: Callable[P, Awaitable[R]], maxsize: int = 128) -> None: + self.func = func + self.cache: OrderedDict[CacheKey, asyncio.Task[Any]] = OrderedDict() + self.info = CacheInfo(maxsize=maxsize) + + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Awaitable[R]: + key = CacheKey(self.func, args, frozenset(kwargs.items())) + + if key in self.cache: + self.info.hits += 1 + self.cache.move_to_end(key) + return self.cache[key] + + self.info.misses += 1 + task: asyncio.Task[Any] = asyncio.create_task(cast(Coroutine[Any, Any, Any], self.func(*args, **kwargs))) + self.cache[key] = task + self.info.currsize = len(self.cache) + + if self.info.currsize > self.info.maxsize: + self.cache.popitem(last=False) + + return task + + def cache_info(self) -> CacheInfo: + return self.info + def cache_clear(self, *args: P.args, **kwargs: P.kwargs) -> bool: + key = CacheKey(self.func, args, frozenset(kwargs.items())) + try: + self.cache.pop(key) + self.info.currsize -= 1 + except KeyError: + return False + return True -_cached: dict[asyncio.Task[R], CacheInfo] = {} + def cache_clear_all(self) -> None: + self.cache.clear() + self.info.clear() -def async_lru_cache(maxsize: int = 128) -> Callable[[A], asyncio.Task[R]]: +def async_lru_cache(maxsize: int = 128) -> Callable[[Callable[P, Awaitable[R]]], AsyncCacheable[P, R]]: """ Decorator to create an async LRU cache of results. @@ -113,67 +155,10 @@ def async_lru_cache(maxsize: int = 128) -> Callable[[A], asyncio.Task[R]]: :func:`functools.lru_cache` """ - def decorator(func: A) -> A: - cache: OrderedDict[CacheKey, asyncio.Task[R]] = OrderedDict() - info: CacheInfo = CacheInfo(maxsize=maxsize) - - @wraps(func) - def wrapper(*args: P.args, **kwargs: P.kwargs) -> asyncio.Task[R]: - if (key := CacheKey(func, args, kwargs)) in cache: - info.hits += 1 - cache.move_to_end(key) - return cache[key] - - info.misses += 1 - cache[key] = task = asyncio.create_task(func(*args, **kwargs)) - info.currsize = len(cache) - - if info.currsize > maxsize: - cache.popitem(last=False) - - return task - - def cache_info() -> CacheInfo: - """Return the current cache statistics.""" - return info - - def cache_clear(func: A, *args: P.args, **kwargs: P.kwargs) -> bool: - """ - Clear a specific cache entry. - - Parameters - ---------- - func : A - The function associated with the cache entry. - *args : P.args - Positional arguments of the cache entry. - **kwargs : P.kwargs - Keyword arguments of the cache entry. - - Returns - ------- - bool - True if the entry was found and cleared, False otherwise. - """ - try: - cache.pop(CacheKey(func, args, kwargs)) - except KeyError: - return False - info.currsize -= 1 - return True - - def cache_clear_all() -> None: - """Clear all cache entries and reset statistics.""" - cache.clear() - info.clear() - - wrapper.cache_info = cache_info - wrapper.cache_clear = cache_clear - wrapper.cache_clear_all = cache_clear_all - - _cached[wrapper] = info - - return wrapper + def decorator(func: Callable[P, Awaitable[R]]) -> AsyncCacheable[P, R]: + cached = AsyncCacheable(func, maxsize) + _cached[func] = cached + return cached return decorator @@ -187,4 +172,4 @@ def cached_functions() -> str: str A string containing the names of all cached functions and their cache info. """ - return "\n".join([f"{func.__name__}: {info}" for func, info in _cached.items()]) + return "\n".join([f"{func.__name__}: {cacheable.cache_info()}" for func, cacheable in _cached.items()]) diff --git a/dynamo/utils/context.py b/dynamo/utils/context.py index 4cb0a5e..d3c5b67 100644 --- a/dynamo/utils/context.py +++ b/dynamo/utils/context.py @@ -42,7 +42,7 @@ def __init__(self, *, timeout: float, author_id: int, delete_after: bool) -> Non async def interaction_check(self, interaction: discord.Interaction) -> bool: """Check if the interaction is from the author of the view""" - return interaction.user and interaction.user.id == self.author_id + return bool(interaction.user and interaction.user.id == self.author_id) @discord.ui.button(label="Confirm", style=discord.ButtonStyle.green) async def confirm(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: @@ -69,7 +69,7 @@ class Context(commands.Context): command: commands.Command[Any, ..., Any] bot: Dynamo - def __init__(self, **kwargs: dict[str, Any]) -> None: + def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) @property diff --git a/dynamo/utils/helper.py b/dynamo/utils/helper.py index 9c67eed..f54127f 100644 --- a/dynamo/utils/helper.py +++ b/dynamo/utils/helper.py @@ -65,7 +65,7 @@ def valid_token(token: str) -> bool: return bool(pattern.match(token)) -def derive_seed(precursor: int | str | None = None) -> int: +def derive_seed(precursor: int | str) -> int: """Generate a seed from integer, integer-like (i.e discord snowflake) or string Parameters @@ -80,8 +80,7 @@ def derive_seed(precursor: int | str | None = None) -> int: """ if isinstance(precursor, int): precursor = str(precursor) - precursor = precursor.encode() - hashed = int.from_bytes(precursor + hashlib.sha256(precursor).digest(), byteorder="big") + hashed = int.from_bytes(precursor.encode() + hashlib.sha256(precursor.encode()).digest(), byteorder="big") return hashed # noqa: RET504 needs to be assigned as a var to work properly diff --git a/dynamo/utils/identicon.py b/dynamo/utils/identicon.py index dd90f9a..01191b9 100644 --- a/dynamo/utils/identicon.py +++ b/dynamo/utils/identicon.py @@ -5,7 +5,7 @@ import time from dataclasses import dataclass, field from io import BytesIO -from typing import Annotated, Self, TypeVar +from typing import Annotated, Self import numpy as np from PIL import Image @@ -15,8 +15,7 @@ # 0.0 = same color COLOR_THRESHOLD = 0.4 -D = TypeVar("D", bound=np.generic) -ArrayRGB = Annotated[np.ndarray[D], tuple[int, int, int]] +ArrayRGB = Annotated[np.ndarray, tuple[int, int, int]] def _clamp(value: int, upper: int) -> int: @@ -29,7 +28,7 @@ class RGB: g: int b: int - def __post_init__(self): + def __post_init__(self) -> None: """Clamp the RGB values to the range [0, 255]""" self.r = _clamp(self.r, 255) self.g = _clamp(self.g, 255) @@ -144,7 +143,7 @@ def make_color(rng: np.random.Generator) -> RGB: RGB The color generated from the seed """ - colors: tuple[int, int, int] = tuple(int(x) for x in rng.integers(low=0, high=256, size=3)) + colors: tuple[int, ...] = tuple(int(x) for x in rng.integers(low=0, high=256, size=3)) return RGB(*colors) @@ -181,7 +180,7 @@ class Identicon: fg_weight: float seed: int - rng: np.random.Generator | None = field(default=None, init=False, repr=False) + rng: np.random.Generator = field(default_factory=np.random.default_rng, init=False, repr=False) def __post_init__(self) -> None: object.__setattr__(self, "rng", np.random.default_rng(seed=self.seed)) @@ -201,8 +200,8 @@ def icon(self) -> ArrayRGB: def reflect(matrix: np.ndarray) -> ArrayRGB: return np.hstack((matrix, np.fliplr(matrix))) - def __eq__(self, other: Identicon) -> bool: - return self.__hash__() == other.__hash__() + def __eq__(self, other: object) -> bool: + return isinstance(other, Identicon) and self.__hash__() == other.__hash__() def __hash__(self) -> int: return hash(frozenset((self.size, self.fg.as_tuple(), self.bg.as_tuple(), self.fg_weight, self.seed))) diff --git a/dynamo/utils/spotify.py b/dynamo/utils/spotify.py index a143a11..9bdf34c 100644 --- a/dynamo/utils/spotify.py +++ b/dynamo/utils/spotify.py @@ -135,7 +135,7 @@ async def draw( name: str, artists: list[str], color: tuple[int, int, int], - album: BytesIO, + album: bytes, duration: datetime.timedelta | None = None, end: datetime.datetime | None = None, ) -> tuple[BytesIO, str]: @@ -175,7 +175,7 @@ def _draw( name: str, artists: list[str], color: tuple[int, int, int], - album: BytesIO, + album: bytes, duration: datetime.timedelta | None = None, end: datetime.datetime | None = None, ) -> tuple[BytesIO, str]: @@ -256,7 +256,7 @@ def _draw( def _draw_static_elements( self, - draw: ImageDraw.Draw, + draw: ImageDraw.ImageDraw, image: Image.Image, artists: list[str], artist_font: ImageFont.FreeTypeFont, @@ -280,7 +280,7 @@ def _draw_static_elements( # Draw Spotify logo image.paste(spotify_logo, (self.logo_x, self.logo_y), spotify_logo) - def _draw_track_bar(self, draw: ImageDraw.Draw, progress: float, duration: datetime.timedelta): + def _draw_track_bar(self, draw: ImageDraw.ImageDraw, progress: float, duration: datetime.timedelta) -> None: """Draw the duration and progress bar of a given track. Parameters @@ -343,7 +343,7 @@ def _draw_text_scroll( Image.Image A frame of the text scrolling """ - text_width, text_height = font.getbbox(text)[2:] + text_width, text_height = (round(x) for x in font.getbbox(text)[2:]) if text_width <= width: # If text fits, yield a single frame with the full text diff --git a/dynamo/utils/transformer.py b/dynamo/utils/transformer.py deleted file mode 100644 index da0a5cb..0000000 --- a/dynamo/utils/transformer.py +++ /dev/null @@ -1,18 +0,0 @@ -import discord -from discord import app_commands -from discord.ext import commands - - -class MemberTransformer(commands.MemberConverter, app_commands.Transformer): - async def convert(self, ctx: commands.Context, argument: str) -> discord.Member | str: - try: - return await super().convert(ctx, argument) - except (commands.BadArgument, commands.CommandError): - return argument - - async def transform(self, interaction: discord.Interaction, value: str) -> discord.Member | str: - return value - - @property - def type(self) -> discord.AppCommandOptionType: - return discord.AppCommandOptionType.user diff --git a/dynamo/utils/wrappers.py b/dynamo/utils/wrappers.py index 384396c..06cb6f0 100644 --- a/dynamo/utils/wrappers.py +++ b/dynamo/utils/wrappers.py @@ -2,25 +2,24 @@ import logging import time from functools import wraps -from typing import Awaitable, Callable, ParamSpec, TypeVar, overload +from typing import Any, Awaitable, Callable, ParamSpec, TypeVar, overload log = logging.getLogger(__name__) -R = TypeVar("R") P = ParamSpec("P") -F = TypeVar("F", bound=Callable[P, R]) -A = TypeVar("A", bound=Callable[P, Awaitable[R]]) +F = TypeVar("F", bound=Callable[P, Any]) +A = TypeVar("A", bound=Callable[P, Awaitable[Any]]) @overload -def timer(func: F) -> F: ... +def timer(func: F) -> F: ... # Sync @overload -def timer(func: A) -> A: ... -def timer(func: F | A) -> F | A: +def timer(func: A) -> A: ... # Async +def timer(func: Callable[P, Any]) -> Callable[P, Any]: """Timer wrapper for functions""" @wraps(func) - async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> Any: start = time.perf_counter() result = await func(*args, **kwargs) end = time.perf_counter() @@ -28,7 +27,7 @@ async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> R: return result @wraps(func) - def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> Any: start = time.perf_counter() result = func(*args, **kwargs) end = time.perf_counter() diff --git a/poetry.lock b/poetry.lock index 88ec444..4294c26 100644 --- a/poetry.lock +++ b/poetry.lock @@ -450,13 +450,13 @@ license = ["ukkonen"] [[package]] name = "idna" -version = "3.9" +version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" files = [ - {file = "idna-3.9-py3-none-any.whl", hash = "sha256:69297d5da0cc9281c77efffb4e730254dd45943f45bbfb461de5991713989b1e"}, - {file = "idna-3.9.tar.gz", hash = "sha256:e5c5dafde284f26e9e0f28f6ea2d6400abd5ca099864a67f576f3981c6476124"}, + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, ] [package.extras] @@ -626,6 +626,63 @@ files = [ {file = "multidict-6.1.0.tar.gz", hash = "sha256:22ae2ebf9b0c69d206c003e2f6a914ea33f0a932d4aa16f236afc049d9958f4a"}, ] +[[package]] +name = "mypy" +version = "1.11.2" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mypy-1.11.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d42a6dd818ffce7be66cce644f1dff482f1d97c53ca70908dff0b9ddc120b77a"}, + {file = "mypy-1.11.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:801780c56d1cdb896eacd5619a83e427ce436d86a3bdf9112527f24a66618fef"}, + {file = "mypy-1.11.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41ea707d036a5307ac674ea172875f40c9d55c5394f888b168033177fce47383"}, + {file = "mypy-1.11.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6e658bd2d20565ea86da7d91331b0eed6d2eee22dc031579e6297f3e12c758c8"}, + {file = "mypy-1.11.2-cp310-cp310-win_amd64.whl", hash = "sha256:478db5f5036817fe45adb7332d927daa62417159d49783041338921dcf646fc7"}, + {file = "mypy-1.11.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:75746e06d5fa1e91bfd5432448d00d34593b52e7e91a187d981d08d1f33d4385"}, + {file = "mypy-1.11.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a976775ab2256aadc6add633d44f100a2517d2388906ec4f13231fafbb0eccca"}, + {file = "mypy-1.11.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd953f221ac1379050a8a646585a29574488974f79d8082cedef62744f0a0104"}, + {file = "mypy-1.11.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:57555a7715c0a34421013144a33d280e73c08df70f3a18a552938587ce9274f4"}, + {file = "mypy-1.11.2-cp311-cp311-win_amd64.whl", hash = "sha256:36383a4fcbad95f2657642a07ba22ff797de26277158f1cc7bd234821468b1b6"}, + {file = "mypy-1.11.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e8960dbbbf36906c5c0b7f4fbf2f0c7ffb20f4898e6a879fcf56a41a08b0d318"}, + {file = "mypy-1.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:06d26c277962f3fb50e13044674aa10553981ae514288cb7d0a738f495550b36"}, + {file = "mypy-1.11.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e7184632d89d677973a14d00ae4d03214c8bc301ceefcdaf5c474866814c987"}, + {file = "mypy-1.11.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3a66169b92452f72117e2da3a576087025449018afc2d8e9bfe5ffab865709ca"}, + {file = "mypy-1.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:969ea3ef09617aff826885a22ece0ddef69d95852cdad2f60c8bb06bf1f71f70"}, + {file = "mypy-1.11.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:37c7fa6121c1cdfcaac97ce3d3b5588e847aa79b580c1e922bb5d5d2902df19b"}, + {file = "mypy-1.11.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4a8a53bc3ffbd161b5b2a4fff2f0f1e23a33b0168f1c0778ec70e1a3d66deb86"}, + {file = "mypy-1.11.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ff93107f01968ed834f4256bc1fc4475e2fecf6c661260066a985b52741ddce"}, + {file = "mypy-1.11.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:edb91dded4df17eae4537668b23f0ff6baf3707683734b6a818d5b9d0c0c31a1"}, + {file = "mypy-1.11.2-cp38-cp38-win_amd64.whl", hash = "sha256:ee23de8530d99b6db0573c4ef4bd8f39a2a6f9b60655bf7a1357e585a3486f2b"}, + {file = "mypy-1.11.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:801ca29f43d5acce85f8e999b1e431fb479cb02d0e11deb7d2abb56bdaf24fd6"}, + {file = "mypy-1.11.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:af8d155170fcf87a2afb55b35dc1a0ac21df4431e7d96717621962e4b9192e70"}, + {file = "mypy-1.11.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7821776e5c4286b6a13138cc935e2e9b6fde05e081bdebf5cdb2bb97c9df81d"}, + {file = "mypy-1.11.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:539c570477a96a4e6fb718b8d5c3e0c0eba1f485df13f86d2970c91f0673148d"}, + {file = "mypy-1.11.2-cp39-cp39-win_amd64.whl", hash = "sha256:3f14cd3d386ac4d05c5a39a51b84387403dadbd936e17cb35882134d4f8f0d24"}, + {file = "mypy-1.11.2-py3-none-any.whl", hash = "sha256:b499bc07dbdcd3de92b0a8b29fdf592c111276f6a12fe29c30f6c417dd546d12"}, + {file = "mypy-1.11.2.tar.gz", hash = "sha256:7f9993ad3e0ffdc95c2a14b66dee63729f021968bff8ad911867579c65d13a79"}, +] + +[package.dependencies] +mypy-extensions = ">=1.0.0" +typing-extensions = ">=4.6.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + [[package]] name = "nodeenv" version = "1.9.1" @@ -1064,6 +1121,39 @@ files = [ {file = "truststore-0.9.2.tar.gz", hash = "sha256:a1dee0d0575ff22d2875476343783a5d64575419974e228f3248772613c3d993"}, ] +[[package]] +name = "types-python-dateutil" +version = "2.9.0.20240906" +description = "Typing stubs for python-dateutil" +optional = false +python-versions = ">=3.8" +files = [ + {file = "types-python-dateutil-2.9.0.20240906.tar.gz", hash = "sha256:9706c3b68284c25adffc47319ecc7947e5bb86b3773f843c73906fd598bc176e"}, + {file = "types_python_dateutil-2.9.0.20240906-py3-none-any.whl", hash = "sha256:27c8cc2d058ccb14946eebcaaa503088f4f6dbc4fb6093d3d456a49aef2753f6"}, +] + +[[package]] +name = "types-toml" +version = "0.10.8.20240310" +description = "Typing stubs for toml" +optional = false +python-versions = ">=3.8" +files = [ + {file = "types-toml-0.10.8.20240310.tar.gz", hash = "sha256:3d41501302972436a6b8b239c850b26689657e25281b48ff0ec06345b8830331"}, + {file = "types_toml-0.10.8.20240310-py3-none-any.whl", hash = "sha256:627b47775d25fa29977d9c70dc0cbab3f314f32c8d8d0c012f2ef5de7aaec05d"}, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + [[package]] name = "virtualenv" version = "20.26.4" @@ -1324,4 +1414,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "daa5aab0a2d6fa22e9c5ee374a427c74e7811118394798bf5bd8963a382c4176" +content-hash = "529fa5f2266b2aecd5136eec38631cb8151a1b786c302ee50a6011f94c96237c" diff --git a/pyproject.toml b/pyproject.toml index 9c450e9..ae2940f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,8 @@ base2048 = "^0.1.3" numpy = "^2.1.0" pillow = "^10.4.0" truststore = "^0.9.2" +types-toml = "^0.10.8.20240310" +types-python-dateutil = "^2.9.0.20240906" [tool.poetry.scripts] dynamo = "dynamo.launcher:main" @@ -27,40 +29,27 @@ pre-commit = "^3.8.0" pytest = "^8.3.2" hypothesis = "^6.112.0" pytest-asyncio = "^0.24.0" -pytest-xdist = {extras = ["psutil"], version = "^3.6.1"} +pytest-xdist = { extras = ["psutil"], version = "^3.6.1" } +mypy = "^1.11.2" [tool.ruff] line-length = 120 [tool.ruff.lint] -select = [ - "F", "E", "I", "UP", "YTT", "ANN", "S", "BLE", "B", "A", "COM", "C4", "DTZ", - "EM", "ISC", "G", "INP", "PIE", "T20", "Q003", "RSE", "RET", "SIM", "TID", "PTH", - "ERA", "PD", "PLC", "PLE", "PLR", "PLW", "TRY", "NPY", "RUF", "ASYNC", -] -ignore = [ - "RUF001", # ambiguous characters not something I want to enforce here. - "G002", # erroneous issue with %-logging when logging can be confiured for % logging - "S101", # use of assert here is a known quantity, blame typing memes - "PLR2004", # Magic value comparison - "PLC0105", # no co-naming style - "SIM105", # supressable exception - "C90", # mccabe complexity - "ANN101", # missing "Self" annotation, self is implicit - "ANN202", # implied return fine sometimes - "ANN204", # special method return types - "ANN401", # Any return - "PLR0912", # too many branches - "PLR0913", # number of function arguments - "PLR0915", - "UP007", # "Use | For Union" doesn't account for typevar tuple unpacking. - "COM812", # ruff format suggested - "ISC001", # ruff format suggested - "B008", - "ASYNC109", # timeout is not awaited -] -unfixable = [ - "ERA" # I don't want anything erroneously detected deleted by this. +select = ["F", "E", "I", "UP", "YTT", "ANN", "S", "BLE", "B", "A", "COM", "C4", "DTZ", "EM", "ISC", "G", "INP", "PIE", "T20", "Q003", "RSE", "RET", "SIM", "TID", "PTH", "ERA", "PD", "PLC", "PLE", "PLR", "PLW", "TRY", "NPY", "RUF", "ASYNC"] +ignore = ["RUF001", "G002", "S101", "PLR2004", "PLC0105", "SIM105", "C90", "ANN101", "ANN202", "ANN204", "ANN401", "PLR0912", "PLR0913", "PLR0915", "UP007", "COM812", "ISC001", "B008", "ASYNC109"] +unfixable = ["ERA"] + +[tool.mypy] +exclude = ["tests"] +disallow_untyped_defs = true +disallow_any_unimported = true +no_implicit_optional = true +check_untyped_defs = true +warn_return_any = true +warn_unused_ignores = true +disable_error_code = [ + "arg-type", # Annoying false positive for command decorators ] [tool.pytest.ini_options] From be5e5739795b824c8e35339a0d4fc267685f8cb6 Mon Sep 17 00:00:00 2001 From: Truman Mulholland Date: Mon, 16 Sep 2024 19:24:38 +1200 Subject: [PATCH 2/6] Fix error in workflow --- .github/workflows/main.yml | 2 ++ dynamo/utils/converter.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 dynamo/utils/converter.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ec2bc28..9f8081d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -48,6 +48,8 @@ jobs: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 + - name: Install poetry + run: pipx install poetry - uses: actions/setup-python@v5 with: python-version: '3.12' diff --git a/dynamo/utils/converter.py b/dynamo/utils/converter.py new file mode 100644 index 0000000..6579da8 --- /dev/null +++ b/dynamo/utils/converter.py @@ -0,0 +1,31 @@ +from typing import Any + +import discord +from discord import app_commands +from discord.ext import commands + + +class MemberTransformer(commands.MemberConverter, app_commands.Transformer): + async def convert(self, ctx: commands.Context, argument: Any) -> discord.Member | Any: + try: + return await super().convert(ctx, argument) + except commands.MemberNotFound: + return argument + + async def transform(self, interaction: discord.Interaction, value: Any) -> discord.Member | Any: + return value + + @property + def type(self) -> discord.AppCommandOptionType: + return discord.AppCommandOptionType.user + + +class GuildConverter(commands.GuildConverter): + """Convert an argument to a guild. If not found, return the current guild. If there's no guild at all, + return the argument.""" + + async def convert(self, ctx: commands.Context, argument: Any) -> discord.Guild | Any: + try: + return await super().convert(ctx, argument) + except commands.GuildNotFound: + return ctx.guild or argument From d9fdf1b51234b326cbdc2f023c03b788e48a7ae6 Mon Sep 17 00:00:00 2001 From: Truman Mulholland Date: Mon, 16 Sep 2024 19:26:49 +1200 Subject: [PATCH 3/6] Specify config file --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9f8081d..3030156 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -55,4 +55,4 @@ jobs: python-version: '3.12' cache: 'poetry' - run: poetry install --with=dev - - run: poetry run mypy dynamo + - run: poetry run mypy dynamo --config-file ./pyproject.toml From ec74aa6df2a7b69b53dea28fb1fc34bca00894cf Mon Sep 17 00:00:00 2001 From: Truman Mulholland Date: Mon, 16 Sep 2024 19:33:36 +1200 Subject: [PATCH 4/6] Verbose --- .github/workflows/main.yml | 2 +- pyproject.toml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3030156..f92cba0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -55,4 +55,4 @@ jobs: python-version: '3.12' cache: 'poetry' - run: poetry install --with=dev - - run: poetry run mypy dynamo --config-file ./pyproject.toml + - run: poetry run mypy dynamo --config-file ./pyproject.toml --verbose diff --git a/pyproject.toml b/pyproject.toml index ae2940f..681e471 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ no_implicit_optional = true check_untyped_defs = true warn_return_any = true warn_unused_ignores = true +ignore_missing_imports = true disable_error_code = [ "arg-type", # Annoying false positive for command decorators ] From edd725b2ebeef05ec28b38d75dc0df93763e89e7 Mon Sep 17 00:00:00 2001 From: Truman Mulholland Date: Mon, 16 Sep 2024 19:38:20 +1200 Subject: [PATCH 5/6] Attempt fix with tag removal --- .github/workflows/main.yml | 2 +- dynamo/_evt_policy.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f92cba0..21e308a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -55,4 +55,4 @@ jobs: python-version: '3.12' cache: 'poetry' - run: poetry install --with=dev - - run: poetry run mypy dynamo --config-file ./pyproject.toml --verbose + - run: poetry run mypy --pretty --config-file ./pyproject.toml dynamo diff --git a/dynamo/_evt_policy.py b/dynamo/_evt_policy.py index 9059660..9e6e5ac 100644 --- a/dynamo/_evt_policy.py +++ b/dynamo/_evt_policy.py @@ -13,10 +13,10 @@ def get_event_loop_policy() -> asyncio.AbstractEventLoopPolicy: else: try: - import uvloop # type: ignore + import uvloop except ImportError: pass else: - return uvloop.EventLoopPolicy() # type: ignore + return uvloop.EventLoopPolicy() # type: ignore[no-any-return] return asyncio.DefaultEventLoopPolicy() From e04c8829622920e8c0483f5674a14f496fd39d48 Mon Sep 17 00:00:00 2001 From: Truman Mulholland Date: Mon, 16 Sep 2024 19:41:53 +1200 Subject: [PATCH 6/6] Ignore evt policy --- dynamo/_evt_policy.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dynamo/_evt_policy.py b/dynamo/_evt_policy.py index 9e6e5ac..cac09d6 100644 --- a/dynamo/_evt_policy.py +++ b/dynamo/_evt_policy.py @@ -17,6 +17,6 @@ def get_event_loop_policy() -> asyncio.AbstractEventLoopPolicy: except ImportError: pass else: - return uvloop.EventLoopPolicy() # type: ignore[no-any-return] + return uvloop.EventLoopPolicy() return asyncio.DefaultEventLoopPolicy() diff --git a/pyproject.toml b/pyproject.toml index 681e471..c42601e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,7 @@ ignore = ["RUF001", "G002", "S101", "PLR2004", "PLC0105", "SIM105", "C90", "ANN1 unfixable = ["ERA"] [tool.mypy] -exclude = ["tests"] +exclude = ["tests", "dynamo/_evt_policy.py"] disallow_untyped_defs = true disallow_any_unimported = true no_implicit_optional = true