-
-
Notifications
You must be signed in to change notification settings - Fork 711
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
base: main
Are you sure you want to change the base?
Compact doc feature #3295
Changes from all commits
a37be2a
8d685c2
22bd71f
ad6c7f8
21c57da
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 | ||
|
@@ -42,6 +44,10 @@ | |
|
||
COMMAND_LOCK_SINGLETON = "inventory refresh" | ||
|
||
REDO_EMOJI = "\U0001f501" # :repeat: | ||
REDO_TIMEOUT = 30 | ||
COMPACT_DOC_REGEX = r"\[\[(?P<symbol>\S*)\]\]" | ||
|
||
|
||
class DocItem(NamedTuple): | ||
"""Holds inventory symbol information.""" | ||
|
@@ -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( | ||
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. But a context switch cannot happen if there's no There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
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. | ||
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: | ||
|
@@ -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 "" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You forgot to revert this formatting change. |
||
) | ||
await ctx.send(embed=embed) | ||
|
||
|
There was a problem hiding this comment.
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 tor"\[\[`(?P<symbol>[^\s\]`]*)`\]\]"
? This would also remove the need to strip ` from matches