diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 21f8e9d..e8f0c07 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/astral-sh/uv:0.5.26@sha256:a0c0e6aed043f5138957ea89744536eed81f1db633dc9bb3be2b882116060be2 AS uv +FROM ghcr.io/astral-sh/uv:0.5.27@sha256:5adf09a5a526f380237408032a9308000d14d5947eafa687ad6c6a2476787b4f AS uv FROM python:3.11-slim@sha256:6ed5bff4d7d377e2a27d9285553b8c21cfccc4f00881de1b24c9bc8d90016e82 AS python FROM code.forgejo.org/forgejo/runner:6.2.1@sha256:fecc96a111a15811a6887ce488e75718089f24599e613e93db8e54fe70b706e8 AS forgejo-runner diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index e53bf4c..9bf34c9 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -9,6 +9,16 @@ }, "customizations": { "vscode": { + "settings": { + "python.terminal.activateEnvInCurrentTerminal": true, + "python.terminal.activateEnvironment": true, + "terminal.integrated.defaultProfile.linux": "zsh", + "terminal.integrated.profiles.linux": { + "zsh": { + "path": "/bin/zsh" + } + } + }, "extensions": [ "charliermarsh.ruff", "ms-azuretools.vscode-docker", @@ -35,6 +45,8 @@ "mounts": [ "source=seacogs-persistent-data,target=/workspaces/SeaCogs/.data,type=volume" ], - "postCreateCommand": "uv sync --frozen && sudo chown -R vscode:vscode /workspaces/SeaCogs/.data && uv run redbot-setup --no-prompt --instance-name=local --data-path=/workspaces/SeaCogs/.data --backend=json", + "postCreateCommand": { + "Setup Virtual Environment": "uv sync --frozen && sudo chown -R vscode:vscode /workspaces/SeaCogs/.data && uv run redbot-setup --no-prompt --instance-name=local --data-path=/workspaces/SeaCogs/.data --backend=json" + }, "remoteUser": "vscode" } diff --git a/.vscode/settings.json b/.vscode/settings.json index 9b76a06..68a52a5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,11 +11,22 @@ "[jsonc]": { "editor.defaultFormatter": "vscode.json-language-features" }, - "editor.formatOnSave": true, "files.exclude": { "**/.git": true, "**/__pycache__": true, "**/.ruff_cache": true, "**/.mypy_cache": true - } + }, + "python.analysis.diagnosticSeverityOverrides": { + "reportAttributeAccessIssue": false, // disabled because `commands.group.command` is listed as Any / Unknown for some reason + "reportCallIssue": "information" + }, + "python.analysis.diagnosticMode": "workspace", + "python.analysis.supportDocstringTemplate": true, + "python.analysis.typeCheckingMode": "basic", + "python.analysis.typeEvaluation.enableReachabilityAnalysis": true, + "python.analysis.typeEvaluation.strictDictionaryInference": true, + "python.analysis.typeEvaluation.strictListInference": true, + "python.analysis.typeEvaluation.strictSetInference": true, + "editor.formatOnSave": true, } diff --git a/antipolls/antipolls.py b/antipolls/antipolls.py index b721d08..6f6dac6 100644 --- a/antipolls/antipolls.py +++ b/antipolls/antipolls.py @@ -17,7 +17,7 @@ class AntiPolls(commands.Cog): __author__ = ["[cswimr](https://www.coastalcommits.com/cswimr)"] __git__ = "https://www.coastalcommits.com/cswimr/SeaCogs" - __version__ = "1.0.2" + __version__ = "1.0.3" __documentation__ = "https://seacogs.coastalcommits.com/antipolls/" def __init__(self, bot: Red): @@ -82,7 +82,7 @@ class AntiPolls(commands.Cog): return self.logger.trace("Deleted poll message %s", message.id) return self.logger.verbose("Message %s is not a poll, ignoring", message.id) - @commands.group(name="antipolls", aliases=["ap"]) + @commands.group(name="antipolls", aliases=["ap"]) # type: ignore @commands.guild_only() @commands.admin_or_permissions(manage_guild=True) async def antipolls(self, ctx: commands.Context) -> None: @@ -95,6 +95,8 @@ class AntiPolls(commands.Cog): @antipolls_roles.command(name="add") async def antipolls_roles_add(self, ctx: commands.Context, *roles: discord.Role) -> None: """Add roles to the whitelist.""" + assert ctx.guild is not None # using `assert` here and in the rest of this file to satisfy typecheckers + # this is safe because the commands are part of a guild-only command group async with self.config.guild(ctx.guild).role_whitelist() as role_whitelist: role_whitelist: list failed: list[discord.Role] = [] @@ -110,6 +112,7 @@ class AntiPolls(commands.Cog): @antipolls_roles.command(name="remove") async def antipolls_roles_remove(self, ctx: commands.Context, *roles: discord.Role) -> None: """Remove roles from the whitelist.""" + assert ctx.guild is not None async with self.config.guild(ctx.guild).role_whitelist() as role_whitelist: role_whitelist: list failed: list[discord.Role] = [] @@ -125,10 +128,11 @@ class AntiPolls(commands.Cog): @antipolls_roles.command(name="list") async def antipolls_roles_list(self, ctx: commands.Context) -> discord.Message: """List roles in the whitelist.""" + assert ctx.guild is not None role_whitelist = await self.config.guild(ctx.guild).role_whitelist() if not role_whitelist: return await ctx.send("No roles in the whitelist.") - roles = [ctx.guild.get_role(role) for role in role_whitelist] + roles = [role for role in (ctx.guild.get_role(role) for role in role_whitelist) if role is not None] return await ctx.send(humanize_list([role.mention for role in roles])) @antipolls.group(name="channels") @@ -138,6 +142,7 @@ class AntiPolls(commands.Cog): @antipolls_channels.command(name="add") async def antipolls_channels_add(self, ctx: commands.Context, *channels: discord.TextChannel) -> None: """Add channels to the whitelist.""" + assert ctx.guild is not None async with self.config.guild(ctx.guild).channel_whitelist() as channel_whitelist: channel_whitelist: list failed: list[discord.TextChannel] = [] @@ -153,6 +158,7 @@ class AntiPolls(commands.Cog): @antipolls_channels.command(name="remove") async def antipolls_channels_remove(self, ctx: commands.Context, *channels: discord.TextChannel) -> None: """Remove channels from the whitelist.""" + assert ctx.guild is not None async with self.config.guild(ctx.guild).channel_whitelist() as channel_whitelist: channel_whitelist: list failed: list[discord.TextChannel] = [] @@ -168,14 +174,19 @@ class AntiPolls(commands.Cog): @antipolls_channels.command(name="list") async def antipolls_channels_list(self, ctx: commands.Context) -> discord.Message: """List channels in the whitelist.""" + assert ctx.guild is not None channel_whitelist = await self.config.guild(ctx.guild).channel_whitelist() if not channel_whitelist: return await ctx.send("No channels in the whitelist.") - channels = [ctx.guild.get_channel(channel) for channel in channel_whitelist] + channels = [channel for channel in (ctx.guild.get_channel(channel) for channel in channel_whitelist) if channel is not None] + for c in channels: + if not c: + channels.remove(c) return await ctx.send(humanize_list([channel.mention for channel in channels])) @antipolls.command(name="managemessages") async def antipolls_managemessages(self, ctx: commands.Context, enabled: bool) -> None: """Toggle Manage Messages permission check.""" + assert ctx.guild is not None await self.config.guild(ctx.guild).manage_messages.set(enabled) await ctx.tick() diff --git a/backup/backup.py b/backup/backup.py index 66928bd..803e863 100644 --- a/backup/backup.py +++ b/backup/backup.py @@ -26,7 +26,7 @@ class Backup(commands.Cog): __author__ = ["[cswimr](https://www.coastalcommits.com/cswimr)"] __git__ = "https://www.coastalcommits.com/cswimr/SeaCogs" - __version__ = "1.1.2" + __version__ = "1.1.3" __documentation__ = "https://seacogs.coastalcommits.com/backup/" def __init__(self, bot: Red): @@ -45,14 +45,15 @@ class Backup(commands.Cog): ] return "\n".join(text) - @commands.group(autohelp=True) + @commands.group(autohelp=True) # type: ignore @commands.is_owner() - async def backup(self, ctx: commands.Context): + async def backup(self, ctx: commands.Context) -> None: """Backup your installed cogs.""" + pass @backup.command(name="export") @commands.is_owner() - async def backup_export(self, ctx: commands.Context): + async def backup_export(self, ctx: commands.Context) -> None: """Export your installed repositories and cogs to a file.""" downloader = ctx.bot.get_cog("Downloader") if downloader is None: @@ -91,13 +92,13 @@ class Backup(commands.Cog): @backup.command(name="import") @commands.is_owner() - async def backup_import(self, ctx: commands.Context): + async def backup_import(self, ctx: commands.Context) -> None: """Import your installed repositories and cogs from an export file.""" try: export = json.loads(await ctx.message.attachments[0].read()) except (json.JSONDecodeError, IndexError): try: - export = json.loads(await ctx.message.reference.resolved.attachments[0].read()) + export = json.loads(await ctx.message.reference.resolved.attachments[0].read()) # type: ignore - this is fine to let error because it gets handled except (json.JSONDecodeError, IndexError, AttributeError): await ctx.send(error("Please provide a valid JSON export file.")) return diff --git a/bible/bible.py b/bible/bible.py index c3af117..cd95566 100644 --- a/bible/bible.py +++ b/bible/bible.py @@ -27,7 +27,7 @@ class Bible(commands.Cog): __author__ = ["[cswimr](https://www.coastalcommits.com/cswimr)"] __git__ = "https://www.coastalcommits.com/cswimr/SeaCogs" - __version__ = "1.1.3" + __version__ = "1.1.4" __documentation__ = "https://seacogs.coastalcommits.com/pterodactyl/" def __init__(self, bot: Red): @@ -145,6 +145,7 @@ class Bible(commands.Cog): if response.status == 503: raise bible.errors.ServiceUnavailableError + assert self.bot.user is not None # bot will always be logged in fums_url = "https://fums.api.bible/f3" fums_params = { "t": data["meta"]["fumsToken"], @@ -246,9 +247,9 @@ class Bible(commands.Cog): from_verse, to_verse = passage.replace(":", ".").split("-") if "." not in to_verse: to_verse = f"{from_verse.split('.')[0]}.{to_verse}" - passage = await self._get_passage(ctx, bible_id, f"{book_id}.{from_verse}-{book_id}.{to_verse}", True) + retrieved_passage = await self._get_passage(ctx, bible_id, f"{book_id}.{from_verse}-{book_id}.{to_verse}", True) else: - passage = await self._get_passage(ctx, bible_id, f"{book_id}.{passage.replace(':', '.')}", False) + retrieved_passage = await self._get_passage(ctx, bible_id, f"{book_id}.{passage.replace(':', '.')}", False) except ( bible.errors.BibleAccessError, bible.errors.NotFoundError, @@ -259,21 +260,21 @@ class Bible(commands.Cog): await ctx.send(e.message) return - if len(passage["content"]) > 4096: + if len(retrieved_passage["content"]) > 4096: await ctx.send("The passage is too long to send.") return if await ctx.embed_requested(): icon = self.get_icon(await ctx.embed_color()) embed = Embed( - title=f"{passage['reference']}", - description=passage["content"].replace("¶ ", ""), + title=f"{retrieved_passage['reference']}", + description=retrieved_passage["content"].replace("¶ ", ""), color=await ctx.embed_color(), ) embed.set_footer(text=f"{ctx.prefix}bible passage - Powered by API.Bible - {version.abbreviation_local} ({version.language_local}, {version.description_local})", icon_url="attachment://icon.png") await ctx.send(embed=embed, file=icon) else: - await ctx.send(f"## {passage['reference']}\n{passage['content']}") + await ctx.send(f"## {retrieved_passage['reference']}\n{retrieved_passage['content']}") @bible.command(name="random") async def bible_random(self, ctx: commands.Context): diff --git a/emojiinfo/emojiinfo.py b/emojiinfo/emojiinfo.py index aae0a92..96b533d 100644 --- a/emojiinfo/emojiinfo.py +++ b/emojiinfo/emojiinfo.py @@ -16,7 +16,7 @@ class EmojiInfo(commands.Cog): __author__ = ["[cswimr](https://www.coastalcommits.com/cswimr)"] __git__ = "https://www.coastalcommits.com/cswimr/SeaCogs" - __version__ = "1.0.2" + __version__ = "1.0.3" __documentation__ = "https://seacogs.coastalcommits.com/emojiinfo/" def __init__(self, bot: Red) -> None: @@ -69,7 +69,7 @@ class EmojiInfo(commands.Cog): else: emoji_id = "" markdown = f"`{emoji}`" - name = f"{bold('Name:')} {emoji.aliases.pop(0)}\n" + name = f"{bold('Name:')} {emoji.aliases.pop(0) if emoji.aliases else emoji.name}\n" aliases = f"{bold('Aliases:')} {', '.join(emoji.aliases)}\n" if emoji.aliases else "" group = f"{bold('Group:')} {emoji.group}\n" @@ -82,15 +82,13 @@ class EmojiInfo(commands.Cog): await interaction.response.defer(ephemeral=ephemeral) try: - emoji: PartialEmoji = PartialEmoji.from_str(self, value=emoji) - ( - string, - emoji_url, - ) = await self.get_emoji_info(emoji) + retrieved_emoji: PartialEmoji = PartialEmoji.from_str(self, value=emoji) + string, emoji_url = await self.get_emoji_info(retrieved_emoji) self.logger.verbose(f"Emoji:\n{string}") except (IndexError, UnboundLocalError): return await interaction.followup.send("Please provide a valid emoji!") + assert isinstance(interaction.channel, discord.TextChannel) if await self.bot.embed_requested(channel=interaction.channel): embed = discord.Embed(title="Emoji Information", description=string, color=await self.fetch_primary_color(emoji_url) or await self.bot.get_embed_color(interaction.channel)) embed.set_thumbnail(url=emoji_url) @@ -104,20 +102,18 @@ class EmojiInfo(commands.Cog): async def emoji(self, ctx: commands.Context, *, emoji: str) -> None: """Retrieve information about an emoji.""" try: - emoji: PartialEmoji = PartialEmoji.from_str(self, value=emoji) - ( - string, - emoji_url, - ) = await self.get_emoji_info(emoji) + retrieved_emoji: PartialEmoji = PartialEmoji.from_str(self, value=emoji) + string, emoji_url = await self.get_emoji_info(retrieved_emoji) self.logger.verbose(f"Emoji:\n{string}") except (IndexError, UnboundLocalError): - return await ctx.send("Please provide a valid emoji!") + await ctx.send("Please provide a valid emoji!") + return if await ctx.embed_requested(): - embed = discord.Embed(title="Emoji Information", description=string, color=await self.fetch_primary_color(emoji_url) or await ctx.embed_color) + embed = discord.Embed(title="Emoji Information", description=string, color=await self.fetch_primary_color(emoji_url) or await ctx.embed_color()) embed.set_thumbnail(url=emoji_url) await ctx.send(embed=embed) - return None + return await ctx.send(content=string) - return None + return diff --git a/emojiinfo/model.py b/emojiinfo/model.py index 5885afb..c553950 100644 --- a/emojiinfo/model.py +++ b/emojiinfo/model.py @@ -77,7 +77,7 @@ class PartialEmoji(discord.PartialEmoji): name = groups["name"] return cls(name=name, animated=animated, id=emoji_id) - path: data_manager.Path = data_manager.bundled_data_path(coginstance) / "emojis.json" + path = data_manager.bundled_data_path(coginstance) / "emojis.json" with open(path, "r", encoding="UTF-8") as file: emojis: dict = json.load(file) emoji_aliases = [] diff --git a/hotreload/hotreload.py b/hotreload/hotreload.py index ee223f2..579e7db 100644 --- a/hotreload/hotreload.py +++ b/hotreload/hotreload.py @@ -2,18 +2,18 @@ import py_compile from asyncio import run_coroutine_threadsafe from pathlib import Path from tempfile import NamedTemporaryFile -from typing import TYPE_CHECKING, List, Sequence, Tuple +from typing import Generator, List, Sequence +import discord from red_commons.logging import RedTraceLogger, getLogger from redbot.core import Config, checks, commands from redbot.core.bot import Red from redbot.core.core_commands import CoreLogic from redbot.core.utils.chat_formatting import bold, box, humanize_list +from typing_extensions import override from watchdog.events import FileSystemEvent, FileSystemMovedEvent, RegexMatchingEventHandler from watchdog.observers import Observer - -if TYPE_CHECKING: - from watchdog.observers import ObserverType +from watchdog.observers.api import BaseObserver class HotReload(commands.Cog): @@ -21,24 +21,26 @@ class HotReload(commands.Cog): __author__ = ["[cswimr](https://www.coastalcommits.com/cswimr)"] __git__ = "https://www.coastalcommits.com/cswimr/SeaCogs" - __version__ = "1.4.0" + __version__ = "1.4.1" __documentation__ = "https://seacogs.coastalcommits.com/hotreload/" def __init__(self, bot: Red) -> None: super().__init__() self.bot: Red = bot - self.config = Config.get_conf(self, identifier=294518358420750336, force_registration=True) + self.config: Config = Config.get_conf(cog_instance=self, identifier=294518358420750336, force_registration=True) self.logger: RedTraceLogger = getLogger(name="red.SeaCogs.HotReload") - self.observers: List[ObserverType] = [] + self.observers: List[BaseObserver] = [] self.config.register_global(notify_channel=None, compile_before_reload=False) watchdog_loggers = [getLogger(name="watchdog.observers.inotify_buffer")] for watchdog_logger in watchdog_loggers: watchdog_logger.setLevel("INFO") # SHUT UP!!!! + @override async def cog_load(self) -> None: """Start the observer when the cog is loaded.""" - self.bot.loop.create_task(self.start_observer()) + _ = self.bot.loop.create_task(self.start_observer()) + @override async def cog_unload(self) -> None: """Stop the observer when the cog is unloaded.""" for observer in self.observers: @@ -46,6 +48,7 @@ class HotReload(commands.Cog): observer.join() self.logger.info("Stopped observer. No longer watching for file changes.") + @override def format_help_for_context(self, ctx: commands.Context) -> str: pre_processed = super().format_help_for_context(ctx) or "" n = "\n" if "\n\n" not in pre_processed else "" @@ -57,7 +60,7 @@ class HotReload(commands.Cog): ] return "\n".join(text) - async def get_paths(self) -> Tuple[Path]: + async def get_paths(self) -> Generator[Path]: """Retrieve user defined paths.""" cog_manager = self.bot._cog_mgr # noqa: SLF001 # We have to use this private method because there is no public API to get user defined paths cog_paths = await cog_manager.user_defined_paths() @@ -79,7 +82,7 @@ class HotReload(commands.Cog): self.logger.warning("Path %s does not exist. Skipping.", path) continue self.logger.debug("Adding observer schedule for path %s.", path) - observer.schedule(event_handler=HotReloadHandler(cog=self, path=path), path=path, recursive=True) + observer.schedule(event_handler=HotReloadHandler(cog=self, path=path), path=str(path), recursive=True) observer.start() self.logger.info("Started observer. Watching for file changes.") is_first = False @@ -91,24 +94,24 @@ class HotReload(commands.Cog): pass @hotreload_group.command(name="notifychannel") - async def hotreload_notifychannel(self, ctx: commands.Context, channel: commands.TextChannelConverter) -> None: + async def hotreload_notifychannel(self, ctx: commands.Context, channel: discord.TextChannel) -> None: """Set the channel to send notifications to.""" await self.config.notify_channel.set(channel.id) await ctx.send(f"Notifications will be sent to {channel.mention}.") - @hotreload_group.command(name="compile") + @hotreload_group.command(name="compile") # type: ignore async def hotreload_compile(self, ctx: commands.Context, compile_before_reload: bool) -> None: """Set whether to compile modified files before reloading.""" await self.config.compile_before_reload.set(compile_before_reload) await ctx.send(f"I {'will' if compile_before_reload else 'will not'} compile modified files before hotreloading cogs.") - @hotreload_group.command(name="list") + @hotreload_group.command(name="list") # type: ignore async def hotreload_list(self, ctx: commands.Context) -> None: """List the currently active observers.""" if not self.observers: await ctx.send("No observers are currently active.") return - await ctx.send(f"Currently active observers (If there are more than one of these, report an issue): {box(humanize_list(self.observers, style='unit'))}") + await ctx.send(f"Currently active observers (If there are more than one of these, report an issue): {box(humanize_list([str(o) for o in self.observers], style='unit'))}") class HotReloadHandler(RegexMatchingEventHandler): @@ -129,13 +132,13 @@ class HotReloadHandler(RegexMatchingEventHandler): if event.event_type not in allowed_events: return - relative_src_path = Path(event.src_path).relative_to(self.path) + relative_src_path = Path(str(event.src_path)).relative_to(self.path) src_package_name = relative_src_path.parts[0] cogs_to_reload = [src_package_name] if isinstance(event, FileSystemMovedEvent): dest = f" to {event.dest_path}" - relative_dest_path = Path(event.dest_path).relative_to(self.path) + relative_dest_path = Path(str(event.dest_path)).relative_to(self.path) dest_package_name = relative_dest_path.parts[0] if dest_package_name != src_package_name: cogs_to_reload.append(dest_package_name) @@ -147,7 +150,7 @@ class HotReloadHandler(RegexMatchingEventHandler): run_coroutine_threadsafe( coro=self.reload_cogs( cog_names=cogs_to_reload, - paths=[Path(p) for p in (event.src_path, getattr(event, "dest_path", None)) if p], + paths=[Path(str(p)) for p in (event.src_path, getattr(event, "dest_path", None)) if p], ), loop=self.cog.bot.loop, ) @@ -163,7 +166,7 @@ class HotReloadHandler(RegexMatchingEventHandler): self.logger.info("Reloaded cogs: %s", humanize_list(cog_names, style="unit")) channel = self.cog.bot.get_channel(await self.cog.config.notify_channel()) - if channel: + if channel and isinstance(channel, discord.TextChannel): await channel.send(f"Reloaded cogs: {humanize_list(cog_names, style='unit')}") def compile_modified_files(self, cog_names: Sequence[str], paths: Sequence[Path]) -> bool: @@ -176,7 +179,7 @@ class HotReloadHandler(RegexMatchingEventHandler): try: with NamedTemporaryFile() as temp_file: self.logger.debug("Attempting to compile %s", path) - py_compile.compile(file=path, cfile=temp_file.name, doraise=True) + py_compile.compile(file=str(path), cfile=temp_file.name, doraise=True) self.logger.debug("Successfully compiled %s", path) except py_compile.PyCompileError as e: diff --git a/nerdify/nerdify.py b/nerdify/nerdify.py index 346d37e..fb9b784 100644 --- a/nerdify/nerdify.py +++ b/nerdify/nerdify.py @@ -37,16 +37,20 @@ class Nerdify(commands.Cog): ] return "\n".join(text) - @commands.command(aliases=["nerd"]) async def nerdify( - self, ctx: commands.Context, *, text: Optional[str] = None, + self, + ctx: commands.Context, + *, + text: Optional[str] = None, ) -> None: """Nerdify the replied to message, previous message, or your own text.""" if not text: if hasattr(ctx.message, "reference") and ctx.message.reference: with suppress( - discord.Forbidden, discord.NotFound, discord.HTTPException, + discord.Forbidden, + discord.NotFound, + discord.HTTPException, ): message_id = ctx.message.reference.message_id if message_id: @@ -62,7 +66,9 @@ class Nerdify(commands.Cog): ctx.channel, self.nerdify_text(text), allowed_mentions=discord.AllowedMentions( - everyone=False, users=False, roles=False, + everyone=False, + users=False, + roles=False, ), ) @@ -77,7 +83,10 @@ class Nerdify(commands.Cog): return f'"{text}" 🤓' async def type_message( - self, destination: discord.abc.Messageable, content: str, **kwargs: Any, + self, + destination: discord.abc.Messageable, + content: str, + **kwargs: Any, ) -> Union[discord.Message, None]: """Simulate typing and sending a message to a destination. diff --git a/pterodactyl/pterodactyl.py b/pterodactyl/pterodactyl.py index 18e229f..99891e0 100644 --- a/pterodactyl/pterodactyl.py +++ b/pterodactyl/pterodactyl.py @@ -1,6 +1,6 @@ import asyncio import json -from typing import Mapping, Optional, Tuple, Union +from typing import AsyncIterable, Iterable, Mapping, Optional, Tuple, Union import discord import websockets @@ -9,8 +9,9 @@ from pydactyl import PterodactylClient from redbot.core import app_commands, commands from redbot.core.app_commands import Choice from redbot.core.bot import Red -from redbot.core.utils.chat_formatting import bold, box, error, humanize_list +from redbot.core.utils.chat_formatting import bold, box, humanize_list from redbot.core.utils.views import ConfirmView +from typing_extensions import override from pterodactyl import mcsrvstatus from pterodactyl.config import config, register_config @@ -22,7 +23,7 @@ class Pterodactyl(commands.Cog): __author__ = ["[cswimr](https://www.coastalcommits.com/cswimr)"] __git__ = "https://www.coastalcommits.com/cswimr/SeaCogs" - __version__ = "2.0.5" + __version__ = "2.0.6" __documentation__ = "https://seacogs.coastalcommits.com/pterodactyl/" def __init__(self, bot: Red): @@ -32,9 +33,10 @@ class Pterodactyl(commands.Cog): self.websocket: Optional[websockets.ClientConnection] = None self.retry_counter: int = 0 register_config(config) - self.task = self.get_task() + self.task = self._get_task() self.update_topic.start() + @override def format_help_for_context(self, ctx: commands.Context) -> str: pre_processed = super().format_help_for_context(ctx) or "" n = "\n" if "\n\n" not in pre_processed else "" @@ -46,50 +48,57 @@ class Pterodactyl(commands.Cog): ] return "\n".join(text) + @override async def cog_load(self) -> None: pterodactyl_keys = await self.bot.get_shared_api_tokens("pterodactyl") api_key = pterodactyl_keys.get("api_key") if api_key is None: - self.task.cancel() + self.maybe_cancel_task() logger.error("Pterodactyl API key not set. Please set it using `[p]set api`.") return base_url = await config.base_url() if base_url is None: - self.task.cancel() + self.maybe_cancel_task() logger.error("Pterodactyl base URL not set. Please set it using `[p]pterodactyl config url`.") return server_id = await config.server_id() if server_id is None: - self.task.cancel() + self.maybe_cancel_task() logger.error("Pterodactyl server ID not set. Please set it using `[p]pterodactyl config serverid`.") return self.client = PterodactylClient(base_url, api_key).client + @override async def cog_unload(self) -> None: self.update_topic.cancel() - self.task.cancel() - self.retry_counter = 0 + self.maybe_cancel_task() - def get_task(self) -> asyncio.Task: + def maybe_cancel_task(self, reset_retry_counter: bool = True) -> None: + if self.task: + self.task.cancel() + if reset_retry_counter: + self.retry_counter = 0 + + def _get_task(self) -> asyncio.Task: from pterodactyl.websocket import establish_websocket_connection task = self.bot.loop.create_task(establish_websocket_connection(self), name="Pterodactyl Websocket Connection") - task.add_done_callback(self.error_callback) + task.add_done_callback(self._error_callback) return task - def error_callback(self, fut) -> None: # NOTE Thanks flame442 and zephyrkul for helping me figure this out + def _error_callback(self, fut) -> None: # NOTE Thanks flame442 and zephyrkul for helping me figure this out try: fut.result() except asyncio.CancelledError: logger.info("WebSocket task has been cancelled.") except Exception as e: # pylint: disable=broad-exception-caught logger.error("WebSocket task has failed: %s", e, exc_info=e) - self.task.cancel() + self.maybe_cancel_task(reset_retry_counter=False) if self.retry_counter < 5: self.retry_counter += 1 logger.info("Retrying in %s seconds...", 5 * self.retry_counter) - self.task = self.bot.loop.call_later(5 * self.retry_counter, self.get_task) + self.task = self.bot.loop.call_later(5 * self.retry_counter, self._get_task) else: logger.info("Retry limit reached. Stopping task.") @@ -100,9 +109,9 @@ class Pterodactyl(commands.Cog): console = self.bot.get_channel(await config.console_channel()) chat = self.bot.get_channel(await config.chat_channel()) if console: - await console.edit(topic=topic) + await console.edit(topic=topic) # type: ignore if chat: - await chat.edit(topic=topic) + await chat.edit(topic=topic) # type: ignore @commands.Cog.listener() async def on_message_without_command(self, message: discord.Message) -> None: @@ -113,13 +122,7 @@ class Pterodactyl(commands.Cog): return logger.debug("Received console command from %s: %s", message.author.id, message.content) await message.channel.send(f"Received console command from {message.author.id}: {message.content[:1900]}", allowed_mentions=discord.AllowedMentions.none()) - try: - await self.websocket.send(json.dumps({"event": "send command", "args": [message.content]})) - except websockets.exceptions.ConnectionClosed as e: - logger.error("WebSocket connection closed: %s", e) - self.task.cancel() - self.retry_counter = 0 - self.task = self.get_task() + await self._send(json.dumps({"event": "send command", "args": [message.content]})) if message.channel.id == await config.chat_channel() and message.author.bot is False: logger.debug("Received chat message from %s: %s", message.author.id, message.content) channel = self.bot.get_channel(await config.console_channel()) @@ -127,13 +130,22 @@ class Pterodactyl(commands.Cog): await channel.send(f"Received chat message from {message.author.id}: {message.content[:1900]}", allowed_mentions=discord.AllowedMentions.none()) msg = json.dumps({"event": "send command", "args": [await self.get_chat_command(message)]}) logger.debug("Sending chat message to server:\n%s", msg) + await self._send(message=msg) + + async def _send(self, message: Union[websockets.Data, Iterable[websockets.Data], AsyncIterable[websockets.Data]], text: bool = False): + """Send a message through the websocket connection. Restarts the websocket connection task if it is closed, and reinvokes itself.""" + try: + await self.websocket.send(message=message, text=text) # type: ignore - we want this to error if `self.websocket` is none + except websockets.exceptions.ConnectionClosed as e: + logger.error("WebSocket connection closed: %s", e) + self.maybe_cancel_task() + self.task = self._get_task() try: - await self.websocket.send(msg) - except websockets.exceptions.ConnectionClosed as e: - logger.error("WebSocket connection closed: %s", e) - self.task.cancel() - self.retry_counter = 0 - self.task = self.get_task() + await asyncio.wait_for(fut=self.task, timeout=60) + await self._send(message=message, text=text) + except asyncio.TimeoutError: + logger.error("Timeout while waiting for websocket connection") + raise async def get_topic(self) -> str: topic: str = await config.topic() @@ -193,7 +205,7 @@ class Pterodactyl(commands.Cog): async def get_player_list_embed(self, ctx: Union[commands.Context, discord.Interaction]) -> Optional[discord.Embed]: player_list = await self.get_player_list() - if player_list: + if player_list and isinstance(ctx.channel, discord.abc.Messageable): embed = discord.Embed(color=await self.bot.get_embed_color(ctx.channel), title="Players Online") embed.description = player_list[0] return embed @@ -206,10 +218,12 @@ class Pterodactyl(commands.Cog): current_status = await config.current_status() if current_status == action_ing: - return await ctx.send(f"Server is already {action_ing}.", ephemeral=True) + await ctx.send(f"Server is already {action_ing}.", ephemeral=True) + return if current_status in ["starting", "stopping"] and action != "kill": - return await ctx.send("Another power action is already in progress.", ephemeral=True) + await ctx.send("Another power action is already in progress.", ephemeral=True) + return view = ConfirmView(ctx.author, disable_buttons=True) @@ -220,13 +234,13 @@ class Pterodactyl(commands.Cog): if view.result is True: await message.edit(content=f"Sending websocket command to {action} server...", view=None) - await self.websocket.send(json.dumps({"event": "set state", "args": [action]})) + await self._websocket_send(json.dumps({"event": "set state", "args": [action]})) await message.edit(content=f"Server {action_ing}", view=None) - return None + return await message.edit(content="Cancelled.", view=None) - return None + return async def send_command(self, ctx: Union[discord.Interaction, commands.Context], command: str): channel = self.bot.get_channel(await config.console_channel()) @@ -234,23 +248,15 @@ class Pterodactyl(commands.Cog): ctx = await self.bot.get_context(ctx) if channel: await channel.send(f"Received console command from {ctx.author.id}: {command[:1900]}", allowed_mentions=discord.AllowedMentions.none()) - try: - await self.websocket.send(json.dumps({"event": "send command", "args": [command]})) - await ctx.send(f"Command sent to server. {box(command, 'json')}") - except websockets.exceptions.ConnectionClosed as e: - logger.error("WebSocket connection closed: %s", e) - await ctx.send(error("WebSocket connection closed.")) - self.task.cancel() - self.retry_counter = 0 - self.task = self.get_task() + await self._websocket_send(json.dumps({"event": "send command", "args": [command]})) + await ctx.send(f"Command sent to server. {box(command, 'json')}") @commands.Cog.listener() async def on_red_api_tokens_update(self, service_name: str, api_tokens: Mapping[str, str]): # pylint: disable=unused-argument if service_name == "pterodactyl": logger.info("Configuration value set: api_key\nRestarting task...") - self.task.cancel() - self.retry_counter = 0 - self.task = self.get_task() + self.maybe_cancel_task(reset_retry_counter=True) + self.task = self._get_task() slash_pterodactyl = app_commands.Group(name="pterodactyl", description="Pterodactyl allows you to manage your Pterodactyl Panel from Discord.") @@ -346,9 +352,8 @@ class Pterodactyl(commands.Cog): await config.base_url.set(base_url) await ctx.send(f"Base URL set to {base_url}") logger.info("Configuration value set: base_url = %s\nRestarting task...", base_url) - self.task.cancel() - self.retry_counter = 0 - self.task = self.get_task() + self.maybe_cancel_task(reset_retry_counter=True) + self.task = self._get_task() @pterodactyl_config.command(name="serverid") async def pterodactyl_config_server_id(self, ctx: commands.Context, *, server_id: str) -> None: @@ -356,9 +361,8 @@ class Pterodactyl(commands.Cog): await config.server_id.set(server_id) await ctx.send(f"Server ID set to {server_id}") logger.info("Configuration value set: server_id = %s\nRestarting task...", server_id) - self.task.cancel() - self.retry_counter = 0 - self.task = self.get_task() + self.maybe_cancel_task(reset_retry_counter=True) + self.task = self._get_task() @pterodactyl_config.group(name="console") async def pterodactyl_config_console(self, ctx: commands.Context): diff --git a/pterodactyl/websocket.py b/pterodactyl/websocket.py index 4392744..6ead074 100644 --- a/pterodactyl/websocket.py +++ b/pterodactyl/websocket.py @@ -2,7 +2,7 @@ import json import re from pathlib import Path -from typing import Optional, Tuple, Union +from typing import Any, Optional, Tuple, Union import aiohttp import discord @@ -56,7 +56,9 @@ async def establish_websocket_connection(coginstance: Pterodactyl) -> None: content = mask_ip(content) console_channel = coginstance.bot.get_channel(await config.console_channel()) + assert isinstance(console_channel, discord.abc.Messageable) chat_channel = coginstance.bot.get_channel(await config.chat_channel()) + assert isinstance(chat_channel, discord.abc.Messageable) if console_channel is not None: if content.startswith("["): pagified_content = pagify(content, delims=[" ", "\n"]) @@ -83,7 +85,7 @@ async def establish_websocket_connection(coginstance: Pterodactyl) -> None: embed, img = await generate_join_leave_embed(coginstance=coginstance, username=join_message, join=True) if img: with open(img, "rb") as file: - await chat_channel.send(embed=embed, file=file) + await chat_channel.send(embed=embed, file=discord.File(fp=file)) else: await chat_channel.send(embed=embed) else: @@ -96,7 +98,7 @@ async def establish_websocket_connection(coginstance: Pterodactyl) -> None: embed, img = await generate_join_leave_embed(coginstance=coginstance, username=leave_message, join=False) if img: with open(img, "rb") as file: - await chat_channel.send(embed=embed, file=file) + await chat_channel.send(embed=embed, file=discord.File(fp=file)) else: await chat_channel.send(embed=embed) else: @@ -106,7 +108,11 @@ async def establish_websocket_connection(coginstance: Pterodactyl) -> None: if achievement_message: if chat_channel is not None: if coginstance.bot.embed_requested(chat_channel): - await chat_channel.send(embed=await generate_achievement_embed(coginstance, achievement_message["username"], achievement_message["achievement"], achievement_message["challenge"])) + embed, img = await generate_achievement_embed(coginstance, achievement_message["username"], achievement_message["achievement"], achievement_message["challenge"]) + if img: + await chat_channel.send(embed=embed, file=discord.File(fp=img)) + else: + await chat_channel.send(embed=embed) else: await chat_channel.send(f"{achievement_message['username']} has {'completed the challenge' if achievement_message['challenge'] else 'made the advancement'} {achievement_message['achievement']}") @@ -130,24 +136,27 @@ async def establish_websocket_connection(coginstance: Pterodactyl) -> None: await chat.send(await config.shutdown_msg()) -async def retrieve_websocket_credentials(coginstance: Pterodactyl) -> Optional[dict]: +async def retrieve_websocket_credentials(coginstance: Pterodactyl) -> dict: pterodactyl_keys = await coginstance.bot.get_shared_api_tokens("pterodactyl") api_key = pterodactyl_keys.get("api_key") if api_key is None: - coginstance.task.cancel() + coginstance.maybe_cancel_task() raise ValueError("Pterodactyl API key not set. Please set it using `[p]set api`.") base_url = await config.base_url() if base_url is None: - coginstance.task.cancel() + coginstance.maybe_cancel_task() raise ValueError("Pterodactyl base URL not set. Please set it using `[p]pterodactyl config url`.") server_id = await config.server_id() if server_id is None: - coginstance.task.cancel() + coginstance.maybe_cancel_task() raise ValueError("Pterodactyl server ID not set. Please set it using `[p]pterodactyl config serverid`.") client = PterodactylClient(base_url, api_key).client coginstance.client = client - websocket_credentials = client.servers.get_websocket(server_id) + websocket_credentials: dict[str, Any] = client.servers.get_websocket(server_id).json() + if not websocket_credentials: + coginstance.maybe_cancel_task() + raise ValueError("Failed to retrieve websocket credentials. Please ensure the API details are correctly configured.") logger.debug( """Websocket connection details retrieved: Socket: %s @@ -165,44 +174,44 @@ def remove_ansi_escape_codes(text: str) -> str: return ansi_escape.sub("", text) -async def check_if_server_message(text: str) -> Union[bool, str]: +async def check_if_server_message(text: str) -> Optional[str]: regex = await config.server_regex() match: Optional[re.Match[str]] = re.match(regex, text) if match: logger.trace("Message is a server message") return match.group(1) - return False + return None -async def check_if_chat_message(text: str) -> Union[bool, dict]: +async def check_if_chat_message(text: str) -> Optional[dict]: regex = await config.chat_regex() match: Optional[re.Match[str]] = re.match(regex, text) if match: groups = {"username": match.group(1), "message": match.group(2)} logger.trace("Message is a chat message\n%s", json.dumps(groups)) return groups - return False + return None -async def check_if_join_message(text: str) -> Union[bool, str]: +async def check_if_join_message(text: str) -> Optional[str]: regex = await config.join_regex() match: Optional[re.Match[str]] = re.match(regex, text) if match: logger.trace("Message is a join message") return match.group(1) - return False + return None -async def check_if_leave_message(text: str) -> Union[bool, str]: +async def check_if_leave_message(text: str) -> Optional[str]: regex = await config.leave_regex() match: Optional[re.Match[str]] = re.match(regex, text) if match: logger.trace("Message is a leave message") return match.group(1) - return False + return None -async def check_if_achievement_message(text: str) -> Union[bool, dict]: +async def check_if_achievement_message(text: str) -> Optional[dict]: regex = await config.achievement_regex() match: Optional[re.Match[str]] = re.match(regex, text) if match: @@ -213,7 +222,7 @@ async def check_if_achievement_message(text: str) -> Union[bool, dict]: groups["challenge"] = False logger.trace("Message is an achievement message") return groups - return False + return None async def get_info(username: str) -> Optional[dict]: diff --git a/pyproject.toml b/pyproject.toml index 94aa6c5..f5010a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,30 +10,30 @@ dependencies = [ "aiosqlite>=0.20.0", "beautifulsoup4>=4.12.3", "colorthief>=0.2.1", - "markdownify>=0.13.1", - "numpy>=2.1.2", - "phx-class-registry>=5.0.0", + "markdownify>=0.14.1", + "numpy>=2.2.2", + "phx-class-registry>=5.1.1", "pillow>=10.4.0", - "pip>=24.3.1", + "pip>=25.0", "py-dactyl", - "pydantic>=2.9.2", + "pydantic>=2.10.6", "red-discordbot>=3.5.14", - "watchdog>=5.0.3", - "websockets>=13.1", + "watchdog>=6.0.0", + "websockets>=14.2", ] -[project.optional-dependencies] +[dependency-groups] documentation = [ "mkdocs>=1.6.1", - "mkdocs-git-authors-plugin>=0.9.0", - "mkdocs-git-revision-date-localized-plugin>=1.2.9", - "mkdocs-material[imaging]>=9.5.40", - "mkdocstrings[python]>=0.26.1", - "mkdocs-redirects>=1.2.1", + "mkdocs-git-authors-plugin>=0.9.2", + "mkdocs-git-revision-date-localized-plugin>=1.3.0", + "mkdocs-material[imaging]>=9.5.50", + "mkdocs-redirects>=1.2.2", + "mkdocstrings[python]>=0.27.0", ] [tool.uv] -dev-dependencies = ["pylint>=3.3.1", "ruff>=0.6.9", "sqlite-web>=0.6.4"] +dev-dependencies = ["pylint>=3.3.3", "ruff>=0.9.3", "sqlite-web>=0.6.4"] [tool.uv.sources] py-dactyl = { git = "https://github.com/cswimr/pydactyl" } diff --git a/seautils/seautils.py b/seautils/seautils.py index 13d6d03..126ffd1 100644 --- a/seautils/seautils.py +++ b/seautils/seautils.py @@ -42,7 +42,7 @@ class SeaUtils(commands.Cog): __author__ = ["[cswimr](https://www.coastalcommits.com/cswimr)"] __git__ = "https://www.coastalcommits.com/cswimr/SeaCogs" - __version__ = "1.0.1" + __version__ = "1.0.2" __documentation__ = "https://seacogs.coastalcommits.com/seautils/" def __init__(self, bot: Red) -> None: @@ -74,7 +74,7 @@ class SeaUtils(commands.Cog): src = obj.function return inspect.getsource(object=src) - @commands.command(aliases=["source", "src", "code", "showsource"]) + @commands.command(aliases=["source", "src", "code", "showsource"]) # type: ignore @commands.is_owner() async def showcode(self, ctx: commands.Context, *, object: str) -> None: # pylint: disable=redefined-builtin # noqa: A002 """Show the code for a particular object.""" @@ -102,7 +102,7 @@ class SeaUtils(commands.Cog): else: await ctx.send(content="Object not found!", reference=ctx.message.to_reference(fail_if_not_exists=False)) - @commands.command(name="dig", aliases=["dnslookup", "nslookup"]) + @commands.command(name="dig", aliases=["dnslookup", "nslookup"]) # type: ignore @commands.is_owner() async def dig(self, ctx: commands.Context, name: str, record_type: str | None = None, server: str | None = None, port: int = 53) -> None: """Retrieve DNS information for a domain. @@ -110,7 +110,7 @@ class SeaUtils(commands.Cog): Uses `dig` to perform a DNS query. Will fall back to `nslookup` if `dig` is not installed on the system. `nslookup` does not provide as much information as `dig`, so only the `name` parameter will be used if `nslookup` is used. Will return the A, AAAA, and CNAME records for a domain by default. You can specify a different record type with the `type` parameter.""" - command_opts: list[str | int] = ["dig"] + command_opts: list[str] = ["dig"] query_types: list[str] = [record_type] if record_type else ["A", "AAAA", "CNAME"] if server: command_opts.extend(["@", server]) @@ -176,7 +176,7 @@ class SeaUtils(commands.Cog): embed.add_field(name="Authority Section", value=f"{cf.box(text=authority_section, lang='prolog')}", inline=False) await ctx.send(embed=embed) else: - await ctx.send(content=cf.box(text=stdout, lang="yaml")) + await ctx.send(content=cf.box(text=str(stdout), lang="yaml")) except FileNotFoundError: try: ns_process = await asyncio.create_subprocess_exec("nslookup", name, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) @@ -208,7 +208,7 @@ class SeaUtils(commands.Cog): html = await response.text() soup = BeautifulSoup(html, "html.parser") pre_tags = soup.find_all("pre") - content: list[Embed | str] = [] + content: list[str | Embed] = [] for pre_tag in pre_tags: text = format_rfc_text(md(pre_tag), number) if len(text) > 4096: @@ -227,6 +227,6 @@ class SeaUtils(commands.Cog): if await ctx.embed_requested(): for embed in content: embed.set_footer(text=f"Page {content.index(embed) + 1}/{len(content)}") - await SimpleMenu(pages=content, disable_after_timeout=True, timeout=300).start(ctx) + await SimpleMenu(pages=content, disable_after_timeout=True, timeout=300).start(ctx) # type: ignore else: await ctx.maybe_send_embed(message=cf.error(f"An error occurred while fetching RFC {number}. Status code: {response.status}.")) diff --git a/uv.lock b/uv.lock index 28ccf8f..e2ad6a7 100644 --- a/uv.lock +++ b/uv.lock @@ -591,7 +591,7 @@ name = "importlib-metadata" version = "8.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp" }, + { name = "zipp", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", size = 55304 } wheels = [ @@ -1672,7 +1672,12 @@ dependencies = [ { name = "websockets" }, ] -[package.optional-dependencies] +[package.dev-dependencies] +dev = [ + { name = "pylint" }, + { name = "ruff" }, + { name = "sqlite-web" }, +] documentation = [ { name = "mkdocs" }, { name = "mkdocs-git-authors-plugin" }, @@ -1682,42 +1687,37 @@ documentation = [ { name = "mkdocstrings", extra = ["python"] }, ] -[package.dev-dependencies] -dev = [ - { name = "pylint" }, - { name = "ruff" }, - { name = "sqlite-web" }, -] - [package.metadata] requires-dist = [ { name = "aiosqlite", specifier = ">=0.20.0" }, { name = "beautifulsoup4", specifier = ">=4.12.3" }, { name = "colorthief", specifier = ">=0.2.1" }, - { name = "markdownify", specifier = ">=0.13.1" }, - { name = "mkdocs", marker = "extra == 'documentation'", specifier = ">=1.6.1" }, - { name = "mkdocs-git-authors-plugin", marker = "extra == 'documentation'", specifier = ">=0.9.0" }, - { name = "mkdocs-git-revision-date-localized-plugin", marker = "extra == 'documentation'", specifier = ">=1.2.9" }, - { name = "mkdocs-material", extras = ["imaging"], marker = "extra == 'documentation'", specifier = ">=9.5.40" }, - { name = "mkdocs-redirects", marker = "extra == 'documentation'", specifier = ">=1.2.1" }, - { name = "mkdocstrings", extras = ["python"], marker = "extra == 'documentation'", specifier = ">=0.26.1" }, - { name = "numpy", specifier = ">=2.1.2" }, - { name = "phx-class-registry", specifier = ">=5.0.0" }, + { name = "markdownify", specifier = ">=0.14.1" }, + { name = "numpy", specifier = ">=2.2.2" }, + { name = "phx-class-registry", specifier = ">=5.1.1" }, { name = "pillow", specifier = ">=10.4.0" }, - { name = "pip", specifier = ">=24.3.1" }, + { name = "pip", specifier = ">=25.0" }, { name = "py-dactyl", git = "https://github.com/cswimr/pydactyl" }, - { name = "pydantic", specifier = ">=2.9.2" }, + { name = "pydantic", specifier = ">=2.10.6" }, { name = "red-discordbot", specifier = ">=3.5.14" }, - { name = "watchdog", specifier = ">=5.0.3" }, - { name = "websockets", specifier = ">=13.1" }, + { name = "watchdog", specifier = ">=6.0.0" }, + { name = "websockets", specifier = ">=14.2" }, ] [package.metadata.requires-dev] dev = [ - { name = "pylint", specifier = ">=3.3.1" }, - { name = "ruff", specifier = ">=0.6.9" }, + { name = "pylint", specifier = ">=3.3.3" }, + { name = "ruff", specifier = ">=0.9.3" }, { name = "sqlite-web", specifier = ">=0.6.4" }, ] +documentation = [ + { name = "mkdocs", specifier = ">=1.6.1" }, + { name = "mkdocs-git-authors-plugin", specifier = ">=0.9.2" }, + { name = "mkdocs-git-revision-date-localized-plugin", specifier = ">=1.3.0" }, + { name = "mkdocs-material", extras = ["imaging"], specifier = ">=9.5.50" }, + { name = "mkdocs-redirects", specifier = ">=1.2.2" }, + { name = "mkdocstrings", extras = ["python"], specifier = ">=0.27.0" }, +] [[package]] name = "six"