aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--dev/bot/__main__.py2
-rw-r--r--docs/changelog.rst1
-rw-r--r--docs/utils.py2
-rw-r--r--pydis_core/_bot.py4
-rw-r--r--pydis_core/exts/__init__.py6
-rw-r--r--pydis_core/exts/source.py207
-rw-r--r--pydis_core/site_api.py4
-rw-r--r--pydis_core/utils/function.py12
-rw-r--r--pydis_core/utils/logging.py2
-rw-r--r--pydis_core/utils/paste_service.py4
-rw-r--r--pyproject.toml1
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",