Skip to content

Compact doc feature #3295

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
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
147 changes: 122 additions & 25 deletions bot/exts/info/doc/_cog.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from __future__ import annotations

import asyncio
import contextlib
import re
import sys
import textwrap
from collections import defaultdict
Expand Down Expand Up @@ -42,6 +44,10 @@

COMMAND_LOCK_SINGLETON = "inventory refresh"

REDO_EMOJI = "\U0001f501" # :repeat:
REDO_TIMEOUT = 30
COMPACT_DOC_REGEX = r"\[\[(?P<symbol>\S*)\]\]"
Copy link
Member

Choose a reason for hiding this comment

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

The reason I suggested matching specifically

[[`item`]]

is that this won't match Python lists like [[]] or [[initial]] or [[1,2,3]] and lead to confusing/noisy messages from the bot. Maybe this could be changed to

r"\[\[`(?P<symbol>[^\s\]`]*)`\]\]"

? This would also remove the need to strip ` from matches



class DocItem(NamedTuple):
"""Holds inventory symbol information."""
Expand Down Expand Up @@ -96,7 +102,6 @@ def update_single(self, package_name: str, base_url: str, inventory: InventoryDi

for group, items in inventory.items():
for symbol_name, relative_doc_url in items:

# e.g. get 'class' from 'py:class'
group_name = group.split(":")[1]
symbol_name = self.ensure_unique_symbol_name(
Expand Down Expand Up @@ -264,6 +269,25 @@ async def get_symbol_markdown(self, doc_item: DocItem) -> str:
return "Unable to parse the requested symbol."
return markdown

async def _get_symbols_items(self, symbols: list[str]) -> dict[str, DocItem | None]:
"""Get DocItems for the given list of symbols, and update the stats for the fetched doc items."""
if not self.refresh_event.is_set():
log.debug("Waiting for inventories to be refreshed before processing item.")
await self.refresh_event.wait()

# Ensure a refresh can't run in case of a context switch until the with block is exited
Copy link
Member

Choose a reason for hiding this comment

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

But a context switch cannot happen if there's no await/async for/async with

Copy link
Member

@vivekashok1221 vivekashok1221 Mar 15, 2025

Choose a reason for hiding this comment

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

I believe the code is fine but the comment needs adjusting. Not sure if context switch in the asyncio sense is what the original author (before anand moved it around) meant.
Ignore

with self.symbol_get_event:
items: dict[str, DocItem | None] = {}
for symbol_name in symbols:
symbol_name, doc_item = self.get_symbol_item(symbol_name)
if doc_item:
items[symbol_name] = doc_item
self.bot.stats.incr(f"doc_fetches.{doc_item.package}")
else:
items[symbol_name] = None

return items

async def create_symbol_embed(self, symbol_name: str) -> discord.Embed | None:
"""
Attempt to scrape and fetch the data for the given `symbol_name`, and build an embed from its contents.
Expand All @@ -273,33 +297,107 @@ async def create_symbol_embed(self, symbol_name: str) -> discord.Embed | None:
First check the DocRedisCache before querying the cog's `BatchParser`.
"""
log.trace(f"Building embed for symbol `{symbol_name}`")
if not self.refresh_event.is_set():
log.debug("Waiting for inventories to be refreshed before processing item.")
await self.refresh_event.wait()
# Ensure a refresh can't run in case of a context switch until the with block is exited
with self.symbol_get_event:
symbol_name, doc_item = self.get_symbol_item(symbol_name)
if doc_item is None:
log.debug("Symbol does not exist.")
return None
_items = await self._get_symbols_items([symbol_name])
doc_item = _items[symbol_name]

if doc_item is None:
log.debug(f"{symbol_name=} does not exist.")
return None

# Show all symbols with the same name that were renamed in the footer,
# with a max of 200 chars.
if symbol_name in self.renamed_symbols:
renamed_symbols = ", ".join(self.renamed_symbols[symbol_name])
footer_text = textwrap.shorten("Similar names: " + renamed_symbols, 200, placeholder=" ...")
else:
footer_text = ""

embed = discord.Embed(
title=discord.utils.escape_markdown(symbol_name),
url=f"{doc_item.url}#{doc_item.symbol_id}",
description=await self.get_symbol_markdown(doc_item),
)
embed.set_footer(text=footer_text)
return embed

self.bot.stats.incr(f"doc_fetches.{doc_item.package}")
async def create_compact_doc_message(self, symbols: list[str]) -> str:
"""
Create a markdown bullet list of links to docs for the given list of symbols.

# Show all symbols with the same name that were renamed in the footer,
# with a max of 200 chars.
if symbol_name in self.renamed_symbols:
renamed_symbols = ", ".join(self.renamed_symbols[symbol_name])
footer_text = textwrap.shorten("Similar names: " + renamed_symbols, 200, placeholder=" ...")
Link to at most 10 items, ignoring the rest.
"""
items = await self._get_symbols_items(symbols)
content = ""
link_count = 0
for symbol_name, doc_item in items.items():
if link_count >= 10:
break
if doc_item is None:
log.debug(f"{symbol_name=} does not exist.")
else:
footer_text = ""
link_count += 1
content += f"- [{discord.utils.escape_markdown(symbol_name)}](<{doc_item.url}#{doc_item.symbol_id}>)\n"

return content

embed = discord.Embed(
title=discord.utils.escape_markdown(symbol_name),
url=f"{doc_item.url}#{doc_item.symbol_id}",
description=await self.get_symbol_markdown(doc_item)
async def _handle_edit(self, original: discord.Message, bot_reply: discord.Message) -> bool:
"""
Re-eval a message for symbols if it gets edited.

The edit logic is essentially the same as that of the code eval (snekbox) command.
"""
def edit_check(before: discord.Message, after: discord.Message) -> bool:
return (
original.id == before.id
and before.content != after.content
and len(re.findall(COMPACT_DOC_REGEX, after.content.replace("`", ""))) > 0
Copy link
Member

Choose a reason for hiding this comment

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

and re.search(COMPACT_DOC_REGEX, after.content.replace("`", "")) is not None

)
embed.set_footer(text=footer_text)
return embed

def reaction_check(rxn: discord.Reaction, user: discord.User) -> bool:
return rxn.message.id == original.id and user.id == original.author.id and str(rxn) == REDO_EMOJI

try:
_, new_message = await self.bot.wait_for("message_edit", check=edit_check, timeout=REDO_TIMEOUT)
await new_message.add_reaction(REDO_EMOJI)
await self.bot.wait_for("reaction_add", check=reaction_check, timeout=10)
symbols = re.findall(COMPACT_DOC_REGEX, new_message.content.replace("`", ""))
if not symbols:
return None

log.trace(f"Getting doc links for {symbols=}, as {original.id=} was edited.")
links = await self.create_compact_doc_message(symbols)
await bot_reply.edit(content=links)
with contextlib.suppress(discord.HTTPException):
await original.clear_reaction(REDO_EMOJI)
return True
except TimeoutError:
with contextlib.suppress(discord.HTTPException):
await original.clear_reaction(REDO_EMOJI)
return False

@commands.Cog.listener()
async def on_message(self, message: discord.Message) -> None:
"""
Scan messages for symbols enclosed in double square brackets i.e [[]].

Reply with links to documentations for the specified symbols.
"""
if message.author == self.bot.user:
return

symbols = re.findall(COMPACT_DOC_REGEX, message.content.replace("`", ""))
if not symbols:
return

log.trace(f"Getting doc links for {symbols=}, {message.id=}")
async with message.channel.typing():
links = await self.create_compact_doc_message(symbols)
reply_msg = await message.reply(links)

while True:
again = await self._handle_edit(message, reply_msg)
if not again:
break

@commands.group(name="docs", aliases=("doc", "d"), invoke_without_command=True)
async def docs_group(self, ctx: commands.Context, *, symbol_name: str | None) -> None:
Expand Down Expand Up @@ -439,8 +537,7 @@ async def refresh_command(self, ctx: commands.Context) -> None:
removed = "- " + removed

embed = discord.Embed(
title="Inventories refreshed",
description=f"```diff\n{added}\n{removed}```" if added or removed else ""
title="Inventories refreshed", description=f"```diff\n{added}\n{removed}```" if added or removed else ""
Copy link
Member

Choose a reason for hiding this comment

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

You forgot to revert this formatting change.

)
await ctx.send(embed=embed)

Expand Down