Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions dev/bot/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from discord.ext import commands

import pydis_core
from pydis_core.exts.source import SourceCode

from . import Bot

Expand All @@ -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"))


Expand Down
1 change: 1 addition & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
4 changes: 2 additions & 2 deletions pydis_core/_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -317,8 +317,8 @@ async def setup_hook(self) -> None:

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."""
Expand Down
6 changes: 5 additions & 1 deletion pydis_core/exts/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
"""Reusable Discord cogs."""
__all__ = []
from pydis_core.exts import source

__all__ = [
source,
]

__all__ = [module.__name__ for module in __all__]
207 changes: 207 additions & 0 deletions pydis_core/exts/source.py
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +70 to +75

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thoughts on adding a link to bot core as well here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noise for most use cases, pydis_core is never really relevant to those outside PyDis (hence we don't really widely promote it).

Supporting commands from pydis_core is about the most we should support I think.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of time someone looking at the source command is trying to contribute and looking at how commands are implemented. Giving a link to where other commands are implemented is a benefit and intent of the source command, IMO.

If a user is using the bot's source command, they're already interested in how it works, and surfacing bot-core is an important part of how the pydis bots work.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see a nice way to include this without fundamentally complicating what is a simple feature that hundreds of bots have.

If someone else feels passionately that we should include it, then sure, but the whole point here is to try and transparently carry across functionality from the current command, which obviously does not mention pydis_core.


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"):

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this could be provided as a setup variable with a default of "main"

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could, but this works with our current bots without change on their side. Just seems easier and is guaranteed to work for our use cases.

Setup for the bot core instance is already massive.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair enough, I think. Unified configuration should help this some.

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
4 changes: 2 additions & 2 deletions pydis_core/site_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,9 @@ async def maybe_raise_for_status(response: aiohttp.ClientResponse, *, should_rai
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:
"""
Expand Down
8 changes: 4 additions & 4 deletions pydis_core/utils/function.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).")

Expand Down
4 changes: 2 additions & 2 deletions pydis_core/utils/paste_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,6 @@ select = ["ALL"]
ignore = [
"A005",
"ANN002", "ANN003", "ANN204", "ANN206", "ANN401",
"B904",
"C401", "C408", "C901",
"COM812",
"CPY001",
Expand Down