diff options
| -rw-r--r-- | dev/bot/__main__.py | 2 | ||||
| -rw-r--r-- | docs/changelog.rst | 1 | ||||
| -rw-r--r-- | docs/utils.py | 2 | ||||
| -rw-r--r-- | pydis_core/_bot.py | 4 | ||||
| -rw-r--r-- | pydis_core/exts/__init__.py | 6 | ||||
| -rw-r--r-- | pydis_core/exts/source.py | 207 | ||||
| -rw-r--r-- | pydis_core/site_api.py | 4 | ||||
| -rw-r--r-- | pydis_core/utils/function.py | 12 | ||||
| -rw-r--r-- | pydis_core/utils/logging.py | 2 | ||||
| -rw-r--r-- | pydis_core/utils/paste_service.py | 4 | ||||
| -rw-r--r-- | pyproject.toml | 1 |
11 files changed, 229 insertions, 16 deletions
diff --git a/dev/bot/__main__.py b/dev/bot/__main__.py index 43c4dbd5..e8522533 100644 --- a/dev/bot/__main__.py +++ b/dev/bot/__main__.py @@ -7,6 +7,7 @@ import dotenv from discord.ext import commands import pydis_core +from pydis_core.exts.source import SourceCode from . import Bot @@ -30,6 +31,7 @@ async def main() -> None: """Run the bot.""" bot.http_session = aiohttp.ClientSession() async with bot: + await bot.add_cog(SourceCode(bot, github_repo="https://github.com/python-discord/bot-core")) await bot.start(os.getenv("BOT_TOKEN")) diff --git a/docs/changelog.rst b/docs/changelog.rst index 56355dfc..96402c4d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -7,6 +7,7 @@ Changelog .. XXX: CHANGE DATE BEFORE RELEASE - :release:`12.0.0 <9th November 2025>` +- :feature:`310` Provide a pre-built :obj:`pydis_core.exts.source.SourceCode` cog for providing links to command implementations. - :support:`309` Dependency bumps on all dependencies - :support:`309` Migrate build system from Poetry to uv - :support:`309` Explicit support for Python 3.13 and 3.14 diff --git a/docs/utils.py b/docs/utils.py index 567f0d18..d11f0d9c 100644 --- a/docs/utils.py +++ b/docs/utils.py @@ -98,7 +98,7 @@ def linkcode_resolve(repo_link: str, domain: str, info: dict[str, str]) -> str | # These are ClassVars added by pydantic. # Since they're not in our source code, we cannot resolve them to a url. return None - raise Exception(f"Could not find symbol `{symbol_name}` in {module.__name__}.") + raise Exception(f"Could not find symbol `{symbol_name}` in {module.__name__}.") from None start, end = pos _, offset = inspect.getsourcelines(symbol[-2]) diff --git a/pydis_core/_bot.py b/pydis_core/_bot.py index 783715dd..2510bd05 100644 --- a/pydis_core/_bot.py +++ b/pydis_core/_bot.py @@ -317,8 +317,8 @@ class BotBase(commands.Bot): try: await self.ping_services() - except Exception as e: # noqa: BLE001 - raise StartupError(e) + except Exception as e: + raise StartupError(e) from e async def ping_services(self) -> None: """Ping all required services on setup to ensure they are up before starting.""" diff --git a/pydis_core/exts/__init__.py b/pydis_core/exts/__init__.py index 9d59e8ad..d853b0ff 100644 --- a/pydis_core/exts/__init__.py +++ b/pydis_core/exts/__init__.py @@ -1,4 +1,8 @@ """Reusable Discord cogs.""" -__all__ = [] +from pydis_core.exts import source + +__all__ = [ + source, +] __all__ = [module.__name__ for module in __all__] diff --git a/pydis_core/exts/source.py b/pydis_core/exts/source.py new file mode 100644 index 00000000..b53fe859 --- /dev/null +++ b/pydis_core/exts/source.py @@ -0,0 +1,207 @@ +"""Pre-built cog to display source code links for commands and cogs.""" +import enum +import inspect +import os +from importlib import metadata +from pathlib import Path +from typing import NamedTuple, TYPE_CHECKING + +from discord import Embed +from discord.ext import commands +from discord.utils import escape_markdown + +if TYPE_CHECKING: + from pydis_core import BotBase as Bot + + +GITHUB_AVATAR = "https://avatars1.githubusercontent.com/u/9919" +BOT_CORE_REPO = "https://github.com/python-discord/bot-core" + +class _TagIdentifierStub(NamedTuple): + """A minmally functioning stub representing a tag identifier.""" + + group: str | None + name: str + + @classmethod + def from_string(cls, string: str) -> "_TagIdentifierStub": + """Create a TagIdentifierStub from a string.""" + split_string = string.split(" ", maxsplit=2) + if len(split_string) == 1: + return cls(None, split_string[0]) + return cls(split_string[0], split_string[1]) + + +class _SourceType(enum.StrEnum): + """The types of source objects recognized by the source command.""" + + help_command = enum.auto() + text_command = enum.auto() + core_command = enum.auto() + cog = enum.auto() + core_cog = enum.auto() + tag = enum.auto() + extension_not_loaded = enum.auto() + + +class SourceCode(commands.Cog, description="Displays information about the bot's source code."): + """ + Pre-built cog to display source code links for commands and cogs (and if applicable, tags). + + To use this cog, instantiate it with the bot instance and the base GitHub repository URL. + + Args: + bot (:obj:`pydis_core.BotBase`): The bot instance. + github_repo: The base URL to the GitHub repository (e.g. `https://github.com/python-discord/bot`). + """ + + def __init__(self, bot: "Bot", github_repo: str) -> None: + self.bot = bot + self.github_repo = github_repo.rstrip("/") + + @commands.command(name="source", aliases=("src",)) + async def source_command( + self, + ctx: "commands.Context[Bot]", + *, + source_item: str | None = None, + ) -> None: + """Display information and a GitHub link to the source code of a command, tag, or cog.""" + if not source_item: + embed = Embed(title=f"{self.bot.user.name}'s GitHub Repository") + embed.add_field(name="Repository", value=f"[Go to GitHub]({self.github_repo})") + embed.set_thumbnail(url=GITHUB_AVATAR) + await ctx.send(embed=embed) + return + + obj, source_type = await self._get_source_object(ctx, source_item) + embed = await self._build_embed(obj, source_type) + await ctx.send(embed=embed) + + @staticmethod + async def _get_source_object(ctx: "commands.Context[Bot]", argument: str) -> tuple[object, _SourceType]: + """Convert argument into the source object and source type.""" + if argument.lower() == "help": + return ctx.bot.help_command, _SourceType.help_command + + cog = ctx.bot.get_cog(argument) + if cog: + if inspect.getmodule(cog).__name__.startswith("pydis_core.exts"): + return cog, _SourceType.core_cog + return cog, _SourceType.cog + + cmd = ctx.bot.get_command(argument) + if cmd: + if cmd.module.startswith("pydis_core.exts"): + return cmd, _SourceType.core_command + return cmd, _SourceType.text_command + + tags_cog = ctx.bot.get_cog("Tags") + show_tag = True + + if not tags_cog: + show_tag = False + else: + identifier = _TagIdentifierStub.from_string(argument.lower()) + if identifier in tags_cog.tags: + return identifier, _SourceType.tag + + escaped_arg = escape_markdown(argument) + + raise commands.BadArgument( + f"Unable to convert '{escaped_arg}' to valid command{', tag,' if show_tag else ''} or Cog." + ) + + def _get_source_link(self, source_item: object, source_type: _SourceType) -> tuple[str, str, int | None]: + """ + Build GitHub link of source item, return this link, file location and first line number. + + Raise BadArgument if `source_item` is a dynamically-created object (e.g. via internal eval). + """ + if source_type == _SourceType.text_command or source_type == _SourceType.core_command: + source_item = inspect.unwrap(source_item.callback) + src = source_item.__code__ + filename = src.co_filename + elif source_type == _SourceType.tag: + tags_cog = self.bot.get_cog("Tags") + filename = tags_cog.tags[source_item].file_path + else: + src = type(source_item) + try: + filename = inspect.getsourcefile(src) + except TypeError as e: + raise commands.BadArgument("Cannot get source for a dynamically-created object.") from e + + if source_type != _SourceType.tag: + try: + lines, first_line_no = inspect.getsourcelines(src) + except OSError as e: + raise commands.BadArgument("Cannot get source for a dynamically-created object.") from e + + lines_extension = f"#L{first_line_no}-L{first_line_no+len(lines)-1}" + else: + first_line_no = None + lines_extension = "" + + if not first_line_no: + file_location = Path(filename) + elif source_type == _SourceType.core_command: + package_location = metadata.distribution("pydis_core").locate_file("") / "pydis_core" + internal_location = Path(filename).relative_to(package_location).as_posix() + file_location = "pydis_core/" + internal_location + elif source_type == _SourceType.core_cog: + package_location = metadata.distribution("pydis_core").locate_file("") / "pydis_core" / "exts" + internal_location = Path(filename).relative_to(package_location).as_posix() + file_location = "pydis_core/exts/" + internal_location + else: + # Handle tag file location differently than others to avoid errors in some cases + file_location = Path(filename).relative_to(Path.cwd()).as_posix() + + repo = self.github_repo if source_type != _SourceType.core_command else BOT_CORE_REPO + + if source_type == _SourceType.core_command or source_type == _SourceType.core_cog: + version = f"v{metadata.version('pydis_core')}" + elif sha := os.getenv("GITHUB_SHA"): + version = sha + else: + version = "main" + + url = f"{repo}/blob/{version}/{file_location}{lines_extension}" + + return url, file_location, first_line_no or None + + async def _build_embed(self, source_object: object, source_type: _SourceType) -> Embed | None: + """Build embed based on source object.""" + url, location, first_line = self._get_source_link(source_object, source_type) + + if source_type == _SourceType.help_command: + title = "Help Command" + description = source_object.__doc__.splitlines()[1] + elif source_type == _SourceType.text_command: + description = source_object.short_doc + title = f"Command: {source_object.qualified_name}" + elif source_type == _SourceType.core_command: + description = source_object.short_doc + title = f"Core Command: {source_object.qualified_name}" + elif source_type == _SourceType.core_cog: + title = f"Core Cog: {source_object.qualified_name}" + description = source_object.description.splitlines()[0] + elif source_type == _SourceType.tag: + title = f"Tag: {source_object}" + description = "" + else: + title = f"Cog: {source_object.qualified_name}" + description = source_object.description.splitlines()[0] + + embed = Embed(title=title, description=description) + embed.add_field(name="Source Code", value=f"[Go to GitHub]({url})") + line_text = f":{first_line}" if first_line else "" + + if source_type == _SourceType.core_cog or source_type == _SourceType.core_command: + project_name = "pydis_core" + else: + project_name = self.bot.user.name + + embed.set_footer(text=f"{project_name} \N{BLACK CIRCLE} {location}{line_text}", icon_url=GITHUB_AVATAR) + + return embed diff --git a/pydis_core/site_api.py b/pydis_core/site_api.py index f948cde3..bcad781a 100644 --- a/pydis_core/site_api.py +++ b/pydis_core/site_api.py @@ -92,9 +92,9 @@ class APIClient: try: response_json = await response.json() raise ResponseCodeError(response=response, response_json=response_json) - except aiohttp.ContentTypeError: + except aiohttp.ContentTypeError as e: response_text = await response.text() - raise ResponseCodeError(response=response, response_text=response_text) + raise ResponseCodeError(response=response, response_text=response_text) from e async def request(self, method: str, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict | None: """ diff --git a/pydis_core/utils/function.py b/pydis_core/utils/function.py index 5c0d7ba4..f2872207 100644 --- a/pydis_core/utils/function.py +++ b/pydis_core/utils/function.py @@ -51,16 +51,16 @@ def get_arg_value(name_or_pos: Argument, arguments: BoundArgs) -> typing.Any: try: _name, value = arg_values[arg_pos] - except IndexError: - raise ValueError(f"Argument position {arg_pos} is out of bounds.") + except IndexError as e: + raise ValueError(f"Argument position {arg_pos} is out of bounds.") from e else: return value elif isinstance(name_or_pos, str): arg_name = name_or_pos try: return arguments[arg_name] - except KeyError: - raise ValueError(f"Argument {arg_name!r} doesn't exist.") + except KeyError as e: + raise ValueError(f"Argument {arg_name!r} doesn't exist.") from e else: raise TypeError("'arg' must either be an int (positional index) or a str (keyword).") @@ -138,8 +138,8 @@ def update_wrapper_globals( If ``wrapper`` and ``wrapped`` share a global name that's also used in ``wrapped``\'s typehints, and is not in ``ignored_conflict_names``. """ - wrapped = typing.cast(types.FunctionType, wrapped) - wrapper = typing.cast(types.FunctionType, wrapper) + wrapped = typing.cast("types.FunctionType", wrapped) + wrapper = typing.cast("types.FunctionType", wrapper) annotation_global_names = ( ann.split(".", maxsplit=1)[0] for ann in wrapped.__annotations__.values() if isinstance(ann, str) diff --git a/pydis_core/utils/logging.py b/pydis_core/utils/logging.py index 256151a8..9e9e5d12 100644 --- a/pydis_core/utils/logging.py +++ b/pydis_core/utils/logging.py @@ -48,7 +48,7 @@ def get_logger(name: str | None = None) -> CustomLogger: Returns: An instance of the :obj:`CustomLogger` class. """ - return typing.cast(CustomLogger, logging.getLogger(name)) + return typing.cast("CustomLogger", logging.getLogger(name)) # Setup trace level logging so that we can use it within pydis_core. diff --git a/pydis_core/utils/paste_service.py b/pydis_core/utils/paste_service.py index 140e6cdc..26b7c7d7 100644 --- a/pydis_core/utils/paste_service.py +++ b/pydis_core/utils/paste_service.py @@ -98,8 +98,8 @@ async def send_to_paste_service( try: async with http_session.get(f"{paste_url}/api/v1/lexer") as response: response_json = await response.json() # Supported lexers are the keys. - except HTTPException: - raise PasteUploadError("Could not fetch supported lexers from selected paste_url.") + except HTTPException as e: + raise PasteUploadError("Could not fetch supported lexers from selected paste_url.") from e _lexers_supported_by_pastebin[paste_url] = list(response_json) diff --git a/pyproject.toml b/pyproject.toml index 3121d1b3..13ad72e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -104,7 +104,6 @@ select = ["ALL"] ignore = [ "A005", "ANN002", "ANN003", "ANN204", "ANN206", "ANN401", - "B904", "C401", "C408", "C901", "COM812", "CPY001", |