diff --git a/Dockerfile b/Dockerfile index 37f5e0a..de9e721 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,11 +27,11 @@ ENV PYTHONUNBUFFERED=0 FROM base as production -CMD ["/bin/sh", "-c", "alembic upgrade head && python ./main.py bot --sync -c ./config.toml"] +CMD ["/bin/sh", "-c", "alembic upgrade head && python ./main.py run --sync -c ./config.toml"] FROM base as debug ENV DEBUG=1 ENV LOG_LEVEL=DEBUG RUN pip install debugpy -CMD ["/bin/sh", "-c", "alembic upgrade head && python -m debugpy --wait-for-client --listen 0.0.0.0:5678 ./main.py bot -c ./config.toml"] +CMD ["/bin/sh", "-c", "alembic upgrade head && python -m debugpy --wait-for-client --listen 0.0.0.0:5678 ./main.py run -c ./config.toml"] diff --git a/bin/export_commands.sh b/bin/export_commands.sh new file mode 100644 index 0000000..37aff26 --- /dev/null +++ b/bin/export_commands.sh @@ -0,0 +1,4 @@ +echo "Building the Docker image..." +docker compose --progress quiet build mybot +echo "Exporting the json file..." +docker compose --progress quiet run --rm -t -v "${PWD}:/app" mybot python3 ./src/main.py export-features diff --git a/requirements.in b/requirements.in index 2117bbd..c9dcde1 100644 --- a/requirements.in +++ b/requirements.in @@ -3,7 +3,6 @@ asyncpg sqlalchemy[asyncio] typing_extensions discord.py -click psutil alembic two048 # a personal library @@ -11,3 +10,4 @@ topggpy==2.0.0a0 lingua-language-detector==1.3.4 # WAITFOR: https://github.com/pemistahl/lingua-py/issues/213 aiohttp python-dateutil +typer[all] diff --git a/requirements.txt b/requirements.txt index a658076..e677239 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,7 +18,9 @@ asyncpg==0.29.0 attrs==23.2.0 # via aiohttp click==8.1.7 - # via -r requirements.in + # via typer +colorama==0.4.6 + # via typer discord-py==2.3.2 # via -r requirements.in frozenlist==1.4.1 @@ -33,8 +35,12 @@ lingua-language-detector==1.3.4 # via -r requirements.in mako==1.3.2 # via alembic +markdown-it-py==3.0.0 + # via rich markupsafe==2.1.5 # via mako +mdurl==0.1.2 + # via markdown-it-py multidict==6.0.5 # via # aiohttp @@ -43,10 +49,16 @@ numpy==1.26.4 # via lingua-language-detector psutil==5.9.8 # via -r requirements.in +pygments==2.17.2 + # via rich python-dateutil==2.9.0.post0 # via -r requirements.in regex==2023.12.25 # via lingua-language-detector +rich==13.7.1 + # via typer +shellingham==1.5.4 + # via typer six==1.16.0 # via python-dateutil sqlalchemy[asyncio]==2.0.28 @@ -57,11 +69,14 @@ topggpy==2.0.0a0 # via -r requirements.in two048==1.0.2 # via -r requirements.in +typer[all]==0.9.0 + # via -r requirements.in typing-extensions==4.10.0 # via # -r requirements.in # alembic # sqlalchemy + # typer wheel==0.43.0 # via -r requirements.in yarl==1.9.4 diff --git a/src/cogs/help.py b/src/cogs/help.py index 2260cb5..540190f 100644 --- a/src/cogs/help.py +++ b/src/cogs/help.py @@ -10,16 +10,16 @@ from discord.app_commands import Choice, locale_str as __ from discord.utils import get -from commands_exporter import ContextCommand, FeatureType, Misc, MiscCommandsType, SlashCommand from core import ExtendedCog, ResponseType, response_constructor from core.constants import Emojis from core.i18n import _ from core.utils import splitter +from features_exporter import ContextCommand, FeatureType, Misc, MiscCommandsType, SlashCommand if TYPE_CHECKING: from discord import Embed, Interaction - from commands_exporter import Feature + from features_exporter import Feature from mybot import MyBot diff --git a/src/cogs/translate/__init__.py b/src/cogs/translate/__init__.py index de5b20c..7f77ffd 100644 --- a/src/cogs/translate/__init__.py +++ b/src/cogs/translate/__init__.py @@ -209,6 +209,10 @@ def __init__(self, bot: MyBot): ) ) + async def cog_unload(self) -> None: + for translator in self.translators: + await translator.close() + async def public_translations(self, guild_id: int | None): if guild_id is None: # we are in private channels, IG return True diff --git a/src/cogs/translate/adapters/libretranslate.py b/src/cogs/translate/adapters/libretranslate.py index 4b4ffa9..cdeec37 100644 --- a/src/cogs/translate/adapters/libretranslate.py +++ b/src/cogs/translate/adapters/libretranslate.py @@ -65,6 +65,9 @@ def __init__(self): .build() ) + async def close(self): + await self.instance.close() + async def available_languages(self) -> Languages: return Languages(x.value for x in language_to_libre) diff --git a/src/core/_config.py b/src/core/_config.py index d3c1803..7e3c6fb 100644 --- a/src/core/_config.py +++ b/src/core/_config.py @@ -9,6 +9,7 @@ import logging import tomllib +from pathlib import Path from typing import Any, ClassVar, Self logger = logging.getLogger(__name__) @@ -59,7 +60,7 @@ def __getattribute__(self, name: str) -> Any: return None -def define_config(config_path: str | None = None, **kwargs: Any): +def define_config(config_path: Path | str | None = None, **kwargs: Any): if config_path: with open(config_path, encoding="utf-8") as f: kwargs |= tomllib.load(f.buffer) diff --git a/src/core/constants.py b/src/core/constants.py index ff76152..7bc6617 100644 --- a/src/core/constants.py +++ b/src/core/constants.py @@ -17,7 +17,7 @@ def _identity[T](x: T) -> T: class Emoji(str): - __slots__ = ("id",) + __slots__ = ("_id",) def __new__(cls, id: Snowflake) -> Self: return super().__new__(cls, f"<:_:{id}>") diff --git a/src/commands_exporter.py b/src/features_exporter.py similarity index 79% rename from src/commands_exporter.py rename to src/features_exporter.py index 61098b0..39fb211 100644 --- a/src/commands_exporter.py +++ b/src/features_exporter.py @@ -1,17 +1,18 @@ from __future__ import annotations -import asyncio +import gettext import json from dataclasses import asdict, dataclass, field, is_dataclass from enum import Enum from json import JSONEncoder +from pathlib import Path from typing import TYPE_CHECKING, Any, NotRequired, TypedDict, cast, overload import discord from discord import app_commands -from core._config import define_config from core.extended_commands import MiscCommand, MiscCommandsType +from core.i18n import translations if TYPE_CHECKING: from mybot import MyBot @@ -79,15 +80,24 @@ def fill_features( child: app_commands.Group | app_commands.Command[Any, ..., Any], features: list[SlashCommand], parent: SlashCommand, + translations: gettext.GNUTranslations | gettext.NullTranslations = ..., ) -> None: ... @overload -def fill_features(child: FeatureCodebaseTypes, features: list[Feature], parent: SlashCommand | None = None) -> None: ... +def fill_features( + child: FeatureCodebaseTypes, + features: list[Feature], + parent: SlashCommand | None = None, + translations: gettext.GNUTranslations | gettext.NullTranslations = ..., +) -> None: ... def fill_features( - child: FeatureCodebaseTypes, features: list[Feature] | list[SlashCommand], parent: SlashCommand | None = None + child: FeatureCodebaseTypes, + features: list[Feature] | list[SlashCommand], + parent: SlashCommand | None = None, + translations: gettext.GNUTranslations | gettext.NullTranslations = gettext.NullTranslations(), ) -> None: extras = cast(Extras, child.extras) @@ -159,20 +169,32 @@ def shared_kwargs(child: FeatureCodebaseTypes) -> dict[str, Any]: features.append(feature) # type: ignore -def extract_features(mybot: MyBot) -> list[Feature]: +def extract_features( + mybot: MyBot, + translations: gettext.GNUTranslations | gettext.NullTranslations = gettext.NullTranslations(), +) -> list[Feature]: features: list[Feature] = [] for app_command in mybot.tree.get_commands(): - fill_features(app_command, features) + fill_features(app_command, features, translations=translations) for misc_cmd in mybot.misc_commands(): - fill_features(misc_cmd, features) + fill_features(misc_cmd, features, translations=translations) return features -async def export(mybot: MyBot, filename: str = "features.json") -> None: - features: list[Feature] = extract_features(mybot) +async def features_exporter(filename: str | Path = Path("./features.json")): + from mybot import MyBot + + mybot = MyBot() + await mybot.load_extensions() + + # TODO: find out where the unclosed session comes from. + result: dict[str, list[Feature]] = {} + + for locale, translator in translations.items(): + result[locale.value] = extract_features(mybot, translator) def default(o: Any): if is_dataclass(o): @@ -183,20 +205,7 @@ def default(o: Any): return JSONEncoder().default(o) with open(filename, "w", encoding="utf-8") as file: # noqa: ASYNC101 - json.dump(features, file, indent=4, default=default) - - -async def main(): - mybot = MyBot(False) - await mybot.load_extensions() - - # TODO: find out where the unclosed session comes from. - await export(mybot) - - -if __name__ == "__main__": - from mybot import MyBot - - define_config(EXPORT_MODE=True) + json.dump(result, file, indent=4, default=default) - asyncio.run(main()) + for ext in tuple(mybot.extensions): + await mybot.unload_extension(ext) diff --git a/src/main.py b/src/main.py index 384d1e3..6d1cd0c 100644 --- a/src/main.py +++ b/src/main.py @@ -1,12 +1,15 @@ +import asyncio import logging import os from os import environ -from typing import Any +from pathlib import Path +from typing import Annotated, Any -import click +import typer from core._config import define_config from core._logger import create_logger +from features_exporter import features_exporter try: from dotenv import load_dotenv # pyright: ignore [reportMissingImports, reportUnknownVariableType] @@ -18,28 +21,19 @@ logger = create_logger(level=getattr(logging, environ.get("LOG_LEVEL", "INFO"))) logging.getLogger("discord").setLevel(logging.INFO) - -@click.group() -def cli(): - # The base command group. Will not be called directly. - pass +cli = typer.Typer() @cli.command() -@click.option( - "-c", - "--config", - "config_path", - default=None, - type=click.Path(exists=True), - help="Bind a configuration file.", -) -@click.option("--sync", is_flag=True, default=False, help="Sync slash command with Discord.") -@click.option("--sync-only", is_flag=True, default=False, help="Don't start the bot: just sync commands.") -def bot( - config_path: str | None, - sync: bool, - sync_only: bool, +def run( + config_path: Annotated[ + Path, + typer.Option("--config", "-c", help="Bind a configuration file."), + ] = Path("./config.toml"), + sync: Annotated[ + bool, + typer.Option("--sync", help="Sync slash command with Discord."), + ] = False, ): required_env_var = {"POSTGRES_USER", "POSTGRES_PASSWORD", "POSTGRES_DB", "MYBOT_TOKEN"} optional_env_var = { @@ -66,15 +60,20 @@ def bot( define_config(config_path, **kwargs) - if sync_only: - raise NotImplementedError("The option is not implemented yet. Will probably never be.") - from mybot import MyBot # MyBot is imported after the config is defined. - mybot: MyBot = MyBot(True, sync) + mybot: MyBot = MyBot(sync) mybot.run(os.environ["MYBOT_TOKEN"], reconnect=True, log_handler=None) +@cli.command() +def export_features( + filename: Annotated[Path, typer.Argument(help="The json filename for the output.")] = Path("./features.json"), +): + define_config(EXPORT_MODE=True) + asyncio.run(features_exporter(filename=filename)) + + if __name__ == "__main__": cli() diff --git a/src/mybot.py b/src/mybot.py index d91aae2..c658788 100644 --- a/src/mybot.py +++ b/src/mybot.py @@ -12,12 +12,12 @@ from discord.utils import get from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine -from commands_exporter import Feature, extract_features from core import ExtendedCog, ResponseType, TemporaryCache, config, response_constructor from core.custom_command_tree import CustomCommandTree from core.error_handler import ErrorHandler from core.extended_commands import MiscCommandContext from core.i18n import Translator +from features_exporter import Feature, extract_features if TYPE_CHECKING: from discord import Guild, Thread, User @@ -45,9 +45,8 @@ class MyBot(AutoShardedBot): db_engine: AsyncEngine async_session: async_sessionmaker[AsyncSession] - def __init__(self, running: bool = True, startup_sync: bool = False) -> None: + def __init__(self, startup_sync: bool = False) -> None: self.startup_sync: bool = startup_sync - self.running = running self._invite: discord.Invite | None = None self.error_handler = ErrorHandler(self)