From 78f036da48d6daa55e645283e7c48d7275b7ec75 Mon Sep 17 00:00:00 2001 From: cswimr Date: Sat, 25 Jan 2025 22:50:35 +0000 Subject: [PATCH 1/5] feat(devcontainer): initialize redbot instance in postCreateCommand --- .devcontainer/devcontainer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 104770e..06e81b7 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -30,6 +30,6 @@ "PROJECT_DIR": "/workspaces/SeaCogs" }, "mounts": ["source=seacogs-persistent-data,target=/workspaces/SeaCogs/.data,type=volume"], - "postCreateCommand": "uv sync --frozen && sudo chown -R vscode:vscode /workspaces/SeaCogs/.data", + "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", "remoteUser": "vscode" } From 5384809780d0d1fed3b86f0158a2418cd69c971b Mon Sep 17 00:00:00 2001 From: cswimr Date: Sat, 25 Jan 2025 18:51:27 -0500 Subject: [PATCH 2/5] feat(hotreload): init (#49) This pull request adds a cog that allows for automatic reloading of local cogs. - [x] By submitting this pull request, I permit [cswimr](https://www.coastalcommits.com) to license my work under the [Mozilla Public License Version 2.0](https://www.coastalcommits.com/cswimr/SeaCogs/src/branch/main/LICENSE). Reviewed-on: https://www.coastalcommits.com/cswimr/SeaCogs/pulls/49 --- hotreload/__init__.py | 5 +++ hotreload/hotreload.py | 91 ++++++++++++++++++++++++++++++++++++++++++ hotreload/info.json | 17 ++++++++ pyproject.toml | 1 + uv.lock | 2 + 5 files changed, 116 insertions(+) create mode 100644 hotreload/__init__.py create mode 100644 hotreload/hotreload.py create mode 100644 hotreload/info.json diff --git a/hotreload/__init__.py b/hotreload/__init__.py new file mode 100644 index 0000000..7140931 --- /dev/null +++ b/hotreload/__init__.py @@ -0,0 +1,5 @@ +from .hotreload import HotReload + + +async def setup(bot): + await bot.add_cog(HotReload(bot)) diff --git a/hotreload/hotreload.py b/hotreload/hotreload.py new file mode 100644 index 0000000..27a6250 --- /dev/null +++ b/hotreload/hotreload.py @@ -0,0 +1,91 @@ +from asyncio import run_coroutine_threadsafe +from pathlib import Path + +from red_commons.logging import RedTraceLogger, getLogger +from redbot.core import commands +from redbot.core.bot import Red +from redbot.core.core_commands import CoreLogic +from redbot.core.utils.chat_formatting import bold, humanize_list +from watchdog.events import FileSystemEvent, RegexMatchingEventHandler +from watchdog.observers import Observer + + +class HotReload(commands.Cog): + """Automatically reload cogs in local cog paths on file change.""" + + __author__ = ["[cswimr](https://www.coastalcommits.com/cswimr)"] + __git__ = "https://www.coastalcommits.com/cswimr/SeaCogs" + __version__ = "1.0.0" + __documentation__ = "https://seacogs.coastalcommits.com/hotreload/" + + def __init__(self, bot: Red) -> None: + super().__init__() + self.bot: Red = bot + self.logger: RedTraceLogger = getLogger(name="red.SeaCogs.HotReload") + self.observer = None + watchdog_loggers = [getLogger(name="watchdog.observers.inotify_buffer")] + for watchdog_logger in watchdog_loggers: + watchdog_logger.setLevel("INFO") # SHUT UP!!!! + + def cog_load(self) -> None: + """Start the observer when the cog is loaded.""" + self.bot.loop.create_task(self.start_observer()) + + def cog_unload(self) -> None: + """Stop the observer when the cog is unloaded.""" + if self.observer: + self.observer.stop() + self.observer.join() + self.logger.info("Stopped observer. No longer watching for file changes.") + + 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 "" + text = [ + f"{pre_processed}{n}", + f"{bold('Cog Version:')} [{self.__version__}]({self.__git__})", + f"{bold('Author:')} {humanize_list(self.__author__)}", + f"{bold('Documentation:')} {self.__documentation__}", + ] + return "\n".join(text) + + async def get_paths(self) -> tuple[Path]: + """Retrieve user defined paths.""" + cog_manager = self.bot._cog_mgr + cog_paths = await cog_manager.user_defined_paths() + return (Path(path) for path in cog_paths) + + async def start_observer(self) -> None: + """Start the observer to watch for file changes.""" + self.observer = Observer() + paths = await self.get_paths() + for path in paths: + self.observer.schedule(event_handler=HotReloadHandler(bot=self.bot, path=path), path=path, recursive=True) + self.observer.start() + self.logger.info("Started observer. Watching for file changes.") + + +class HotReloadHandler(RegexMatchingEventHandler): + """Handler for file changes.""" + + def __init__(self, bot: Red, path: Path) -> None: + super().__init__(regexes=[r".*\.py$"]) + self.bot: Red = bot + self.path: Path = path + self.logger: RedTraceLogger = getLogger(name="red.SeaCogs.HotReload.Observer") + + def on_modified(self, event: FileSystemEvent) -> None: + """Handle file modification events.""" + if event.is_directory: + return + relative_path = Path(event.src_path).relative_to(self.path) + package_name = relative_path.parts[0] + self.logger.info(f"File {'/'.join(relative_path.parts[1:])} in the cog {package_name} has been modified.") + run_coroutine_threadsafe(self.reload_cog(package_name), loop=self.bot.loop) + + async def reload_cog(self, cog_name: str) -> None: + """Reload modified cog.""" + core_logic = CoreLogic(bot=self.bot) + self.logger.info(f"Reloading {cog_name} cog.") + await core_logic._reload(pkg_names=(cog_name,)) + self.logger.info(f"Reloaded {cog_name} cog.") diff --git a/hotreload/info.json b/hotreload/info.json new file mode 100644 index 0000000..7d47a1e --- /dev/null +++ b/hotreload/info.json @@ -0,0 +1,17 @@ +{ + "author" : ["cswimr"], + "install_msg" : "Thank you for installing HotReload!", + "name" : "HotReload", + "short" : "Automatically reload cogs in local cog paths on file change.", + "description" : "Automatically reload cogs in local cog paths on file change.", + "end_user_data_statement" : "This cog does not store end user data.", + "hidden": false, + "disabled": false, + "min_bot_version": "3.5.0", + "min_python_version": [3, 10, 0], + "requirements": ["watchdog"], + "tags": [ + "utility", + "development" + ] +} diff --git a/pyproject.toml b/pyproject.toml index 2c40dee..ccf98da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "py-dactyl", "pydantic>=2.9.2", "red-discordbot>=3.5.14", + "watchdog>=5.0.3", "websockets>=13.1", ] diff --git a/uv.lock b/uv.lock index 9056ced..a029a28 100644 --- a/uv.lock +++ b/uv.lock @@ -1667,6 +1667,7 @@ dependencies = [ { name = "py-dactyl" }, { name = "pydantic" }, { name = "red-discordbot" }, + { name = "watchdog" }, { name = "websockets" }, ] @@ -1706,6 +1707,7 @@ requires-dist = [ { name = "py-dactyl", git = "https://github.com/cswimr/pydactyl" }, { name = "pydantic", specifier = ">=2.9.2" }, { name = "red-discordbot", specifier = ">=3.5.14" }, + { name = "watchdog", specifier = ">=5.0.3" }, { name = "websockets", specifier = ">=13.1" }, ] From 5adc7a2c7b2f794a4fcf572b6084a2e76c48d279 Mon Sep 17 00:00:00 2001 From: cswimr Date: Sat, 25 Jan 2025 19:25:29 -0500 Subject: [PATCH 3/5] feat(hotreload): Add more events (#50) # More HotReload Events Currently, HotReload only supports file modification events. It should also support file moves, and some other event types. - [x] By submitting this pull request, I permit cswimr to license my work under the [Mozilla Public License Version 2.0](https://www.coastalcommits.com/cswimr/SeaCogs/src/branch/main/LICENSE). Reviewed-on: https://www.coastalcommits.com/cswimr/SeaCogs/pulls/50 --- hotreload/hotreload.py | 40 ++++++++++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/hotreload/hotreload.py b/hotreload/hotreload.py index 27a6250..d65e8d7 100644 --- a/hotreload/hotreload.py +++ b/hotreload/hotreload.py @@ -1,12 +1,13 @@ from asyncio import run_coroutine_threadsafe from pathlib import Path +from typing import Sequence from red_commons.logging import RedTraceLogger, getLogger from redbot.core import commands from redbot.core.bot import Red from redbot.core.core_commands import CoreLogic from redbot.core.utils.chat_formatting import bold, humanize_list -from watchdog.events import FileSystemEvent, RegexMatchingEventHandler +from watchdog.events import FileSystemEvent, FileSystemMovedEvent, RegexMatchingEventHandler from watchdog.observers import Observer @@ -15,7 +16,7 @@ class HotReload(commands.Cog): __author__ = ["[cswimr](https://www.coastalcommits.com/cswimr)"] __git__ = "https://www.coastalcommits.com/cswimr/SeaCogs" - __version__ = "1.0.0" + __version__ = "1.1.0" __documentation__ = "https://seacogs.coastalcommits.com/hotreload/" def __init__(self, bot: Red) -> None: @@ -74,18 +75,33 @@ class HotReloadHandler(RegexMatchingEventHandler): self.path: Path = path self.logger: RedTraceLogger = getLogger(name="red.SeaCogs.HotReload.Observer") - def on_modified(self, event: FileSystemEvent) -> None: - """Handle file modification events.""" + def on_any_event(self, event: FileSystemEvent) -> None: + """Handle filesystem events.""" if event.is_directory: return - relative_path = Path(event.src_path).relative_to(self.path) - package_name = relative_path.parts[0] - self.logger.info(f"File {'/'.join(relative_path.parts[1:])} in the cog {package_name} has been modified.") - run_coroutine_threadsafe(self.reload_cog(package_name), loop=self.bot.loop) - async def reload_cog(self, cog_name: str) -> None: + allowed_events = ("moved", "deleted", "created", "modified") + if event.event_type not in allowed_events: + return + + relative_src_path = Path(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) + cogs_to_reload.append(relative_dest_path.parts[0]) + else: + dest = "" + + self.logger.info(f"File {event.src_path} has been {event.event_type}{dest}.") + + run_coroutine_threadsafe(self.reload_cogs(cogs_to_reload), loop=self.bot.loop) + + async def reload_cogs(self, cog_names: Sequence[str]) -> None: """Reload modified cog.""" core_logic = CoreLogic(bot=self.bot) - self.logger.info(f"Reloading {cog_name} cog.") - await core_logic._reload(pkg_names=(cog_name,)) - self.logger.info(f"Reloaded {cog_name} cog.") + self.logger.info(f"Reloading cogs: {humanize_list(cog_names, style='unit')}") + await core_logic._reload(pkg_names=cog_names) + self.logger.info(f"Reloaded cogs: {humanize_list(cog_names, style='unit')}") From 7d1a9cc01a16d2e154f7e3a8df0a3fdefbf1d6ac Mon Sep 17 00:00:00 2001 From: cswimr Date: Sun, 26 Jan 2025 00:31:02 +0000 Subject: [PATCH 4/5] fix(hotreload): only add `dest_package_name` to the `cogs_to_reload` list if `dest_package_name != src_package_name` fix(hotreload): only add `dest_package_name` to the `cogs_to_reload` list if `dest_package_name != src_package_name` --- hotreload/hotreload.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/hotreload/hotreload.py b/hotreload/hotreload.py index d65e8d7..143d2f6 100644 --- a/hotreload/hotreload.py +++ b/hotreload/hotreload.py @@ -16,7 +16,7 @@ class HotReload(commands.Cog): __author__ = ["[cswimr](https://www.coastalcommits.com/cswimr)"] __git__ = "https://www.coastalcommits.com/cswimr/SeaCogs" - __version__ = "1.1.0" + __version__ = "1.1.1" __documentation__ = "https://seacogs.coastalcommits.com/hotreload/" def __init__(self, bot: Red) -> None: @@ -91,7 +91,9 @@ class HotReloadHandler(RegexMatchingEventHandler): if isinstance(event, FileSystemMovedEvent): dest = f" to {event.dest_path}" relative_dest_path = Path(event.dest_path).relative_to(self.path) - cogs_to_reload.append(relative_dest_path.parts[0]) + dest_package_name = relative_dest_path.parts[0] + if dest_package_name != src_package_name: + cogs_to_reload.append(dest_package_name) else: dest = "" From 2859f9350136dd8a6b3481cdc01e4d47298e3b59 Mon Sep 17 00:00:00 2001 From: cswimr Date: Sun, 26 Jan 2025 00:39:09 +0000 Subject: [PATCH 5/5] fix(bible): close asyncio session after unloading the cog --- bible/bible.py | 35 ++++++++++++----------------------- bible/errors.py | 12 +++--------- 2 files changed, 15 insertions(+), 32 deletions(-) diff --git a/bible/bible.py b/bible/bible.py index 0cfdb3c..1b44169 100644 --- a/bible/bible.py +++ b/bible/bible.py @@ -6,6 +6,7 @@ # |_____/ \___|\__,_|___/ \_/\_/ |_|_| |_| |_|_| |_| |_|\___|_| import random +from asyncio import create_task from io import BytesIO import aiohttp @@ -26,20 +27,21 @@ class Bible(commands.Cog): __author__ = ["[cswimr](https://www.coastalcommits.com/cswimr)"] __git__ = "https://www.coastalcommits.com/cswimr/SeaCogs" - __version__ = "1.1.1" + __version__ = "1.1.2" __documentation__ = "https://seacogs.coastalcommits.com/pterodactyl/" def __init__(self, bot: Red): super().__init__() self.bot = bot self.session = aiohttp.ClientSession() - self.config = Config.get_conf( - self, identifier=481923957134912, force_registration=True - ) + self.config = Config.get_conf(self, identifier=481923957134912, force_registration=True) self.logger = getLogger("red.SeaCogs.Bible") self.config.register_global(bible="de4e12af7f28f599-02") self.config.register_user(bible=None) + def cog_unload(self): + create_task(self.session.close()) + 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 "" @@ -51,14 +53,13 @@ class Bible(commands.Cog): ] return "\n".join(text) - def get_icon(self, color: Colour) -> File: """Get the docs.api.bible favicon with a given color.""" image_path = data_manager.bundled_data_path(self) / "api.bible-logo.png" image = Image.open(image_path) image = image.convert("RGBA") data = np.array(image) - red, green, blue, alpha = data.T # pylint: disable=unused-variable + red, green, blue, alpha = data.T # pylint: disable=unused-variable white_areas = (red == 255) & (blue == 255) & (green == 255) data[..., :-1][white_areas.T] = color.to_rgb() image = Image.fromarray(data) @@ -70,9 +71,7 @@ class Bible(commands.Cog): async def translate_book_name(self, bible_id: str, book_name: str) -> str: """Translate a book name to a book ID.""" - book_name_list = [ - w.lower() if w.lower() == "of" else w.title() for w in book_name.split() - ] + book_name_list = [w.lower() if w.lower() == "of" else w.title() for w in book_name.split()] book_name = " ".join(book_name_list) books = await self._get_books(bible_id) for book in books: @@ -247,13 +246,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 - ) + 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 - ) + passage = await self._get_passage(ctx, bible_id, f"{book_id}.{passage.replace(':', '.')}", False) except ( bible.errors.BibleAccessError, bible.errors.NotFound, @@ -275,10 +270,7 @@ class Bible(commands.Cog): description=passage["content"].replace("¶ ", ""), color=await ctx.embed_color(), ) - embed.set_footer( - text=f"{ctx.prefix}bible passage - Powered by API.Bible - {version.abbreviationLocal} ({version.languageLocal}, {version.descriptionLocal})", - icon_url="attachment://icon.png" - ) + embed.set_footer(text=f"{ctx.prefix}bible passage - Powered by API.Bible - {version.abbreviationLocal} ({version.languageLocal}, {version.descriptionLocal})", icon_url="attachment://icon.png") await ctx.send(embed=embed, file=icon) else: await ctx.send(f"## {passage['reference']}\n{passage['content']}") @@ -317,10 +309,7 @@ class Bible(commands.Cog): description=passage["content"].replace("¶ ", ""), color=await ctx.embed_color(), ) - embed.set_footer( - text=f"{ctx.prefix}bible random - Powered by API.Bible - {version.abbreviationLocal} ({version.languageLocal}, {version.descriptionLocal})", - icon_url="attachment://icon.png" - ) + embed.set_footer(text=f"{ctx.prefix}bible random - Powered by API.Bible - {version.abbreviationLocal} ({version.languageLocal}, {version.descriptionLocal})", icon_url="attachment://icon.png") await ctx.send(embed=embed, file=icon) else: await ctx.send(f"## {passage['reference']}\n{passage['content']}") diff --git a/bible/errors.py b/bible/errors.py index 4b6bb1c..708ca86 100644 --- a/bible/errors.py +++ b/bible/errors.py @@ -4,9 +4,7 @@ from redbot.core.utils.chat_formatting import error class BibleAccessError(Exception): def __init__( self, - message: str = error( - "The provided API key cannot retrieve sections from the configured Bible. Please report this to the bot owner." - ), + message: str = error("The provided API key cannot retrieve sections from the configured Bible. Please report this to the bot owner."), ): super().__init__(message) self.message = message @@ -15,9 +13,7 @@ class BibleAccessError(Exception): class Unauthorized(Exception): def __init__( self, - message: str = error( - "The API key for API.Bible is missing or invalid. Please report this to the bot owner.\nIf you are the bot owner, please check the documentation [here]()." - ), + message: str = error("The API key for API.Bible is missing or invalid. Please report this to the bot owner.\nIf you are the bot owner, please check the documentation [here]()."), ): super().__init__(message) self.message = message @@ -44,9 +40,7 @@ class ServiceUnavailable(Exception): class InexplicableError(Exception): def __init__( self, - message: str = error( - "An inexplicable 'Bad Request' error occurred. This error happens occassionally with the API.Bible service. Please try again. If the error persists, please report this to the bot owner." - ), + message: str = error("An inexplicable 'Bad Request' error occurred. This error happens occasionally with the API.Bible service. Please try again. If the error persists, please report this to the bot owner."), ): super().__init__(message) self.message = message