From b5af23252fe9186a6b1412cf67a935380f616555 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 17 Jun 2020 19:42:25 +0200 Subject: Resolve relative href urls in a html elements. Most docs will use relative urls to link across their pages, without resolving them ourselves the links remain unusable in discord's markdown and break out of codeblocks on mobile. --- bot/cogs/doc.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 204cffb37..51fb2cb82 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -7,6 +7,7 @@ from collections import OrderedDict from contextlib import suppress from types import SimpleNamespace from typing import Any, Callable, Optional, Tuple +from urllib.parse import urljoin import discord from bs4 import BeautifulSoup @@ -98,6 +99,10 @@ def async_cache(max_size: int = 128, arg_offset: int = 0) -> Callable: class DocMarkdownConverter(MarkdownConverter): """Subclass markdownify's MarkdownCoverter to provide custom conversion methods.""" + def __init__(self, *, page_url: str, **options): + super().__init__(**options) + self.page_url = page_url + def convert_code(self, el: PageElement, text: str) -> str: """Undo `markdownify`s underscore escaping.""" return f"`{text}`".replace('\\', '') @@ -107,10 +112,15 @@ class DocMarkdownConverter(MarkdownConverter): code = ''.join(el.strings) return f"```py\n{code}```" + def convert_a(self, el: PageElement, text: str) -> str: + """Resolve relative URLs to `self.page_url`.""" + el["href"] = urljoin(self.page_url, el["href"]) + return super().convert_a(el, text) + -def markdownify(html: str) -> DocMarkdownConverter: +def markdownify(html: str, *, url: str = "") -> DocMarkdownConverter: """Create a DocMarkdownConverter object from the input html.""" - return DocMarkdownConverter(bullets='•').convert(html) + return DocMarkdownConverter(bullets='•', page_url=url).convert(html) class InventoryURL(commands.Converter): @@ -293,7 +303,7 @@ class Doc(commands.Cog): signatures = scraped_html[0] permalink = self.inventories[symbol] - description = markdownify(scraped_html[1]) + description = markdownify(scraped_html[1], url=permalink) # Truncate the description of the embed to the last occurrence # of a double newline (interpreted as a paragraph) before index 1000. -- cgit v1.2.3 From 5dfbec9d589f62bb1270b162d734749d5b7b069d Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 17 Jun 2020 21:41:04 +0200 Subject: Make doc get greedy. This allows us to find docs for symbols with spaces in them. --- bot/cogs/doc.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 51fb2cb82..010cb9f4c 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -353,12 +353,12 @@ class Doc(commands.Cog): return embed @commands.group(name='docs', aliases=('doc', 'd'), invoke_without_command=True) - async def docs_group(self, ctx: commands.Context, symbol: commands.clean_content = None) -> None: + async def docs_group(self, ctx: commands.Context, *, symbol: str) -> None: """Lookup documentation for Python symbols.""" - await ctx.invoke(self.get_command, symbol) + await ctx.invoke(self.get_command, symbol=symbol) @docs_group.command(name='get', aliases=('g',)) - async def get_command(self, ctx: commands.Context, symbol: commands.clean_content = None) -> None: + async def get_command(self, ctx: commands.Context, *, symbol: str) -> None: """ Return a documentation embed for a given symbol. @@ -370,7 +370,7 @@ class Doc(commands.Cog): !docs aiohttp.ClientSession !docs get aiohttp.ClientSession """ - if symbol is None: + if not symbol: inventory_embed = discord.Embed( title=f"All inventories (`{len(self.base_urls)}` total)", colour=discord.Colour.blue() @@ -392,8 +392,9 @@ class Doc(commands.Cog): doc_embed = await self.get_symbol_embed(symbol) if doc_embed is None: + symbol = await discord.ext.commands.clean_content().convert(ctx, symbol) error_embed = discord.Embed( - description=f"Sorry, I could not find any documentation for `{symbol}`.", + description=f"Sorry, I could not find any documentation for `{(symbol)}`.", colour=discord.Colour.red() ) error_message = await ctx.send(embed=error_embed) -- cgit v1.2.3 From 39aa2fbe0d19edcb61080e49d591a370820bce47 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 17 Jun 2020 21:48:55 +0200 Subject: Skip symbols with slashes in them. The symbols mostly point to autogenerated pages, and do not link to specific symbols on their pages and are thus unreachable with the current implementation. --- bot/cogs/doc.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 010cb9f4c..59c3cc729 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -191,6 +191,8 @@ class Doc(commands.Cog): for group, value in package.items(): for symbol, (package_name, _version, relative_doc_url, _) in value.items(): + if "/" in symbol: + continue # skip unreachable symbols with slashes absolute_doc_url = base_url + relative_doc_url if symbol in self.inventories: -- cgit v1.2.3 From 41e906d6b978f0745f0aff5e7065ce142282a44f Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Thu, 18 Jun 2020 00:20:25 +0200 Subject: Move symbol parsing into separate methods. --- bot/cogs/doc.py | 66 +++++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 43 insertions(+), 23 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 59c3cc729..a1364dd8b 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -6,7 +6,7 @@ import textwrap from collections import OrderedDict from contextlib import suppress from types import SimpleNamespace -from typing import Any, Callable, Optional, Tuple +from typing import Any, Callable, List, Optional, Tuple from urllib.parse import urljoin import discord @@ -265,30 +265,14 @@ class Doc(commands.Cog): return None if symbol_id == f"module-{symbol}": - # Get page content from the module headerlink to the - # first tag that has its class in `SEARCH_END_TAG_ATTRS` - start_tag = symbol_heading.find("a", attrs={"class": "headerlink"}) - if start_tag is None: - return [], "" - - end_tag = start_tag.find_next(self._match_end_tag) - if end_tag is None: - return [], "" - - description_start_index = search_html.find(str(start_tag.parent)) + len(str(start_tag.parent)) - description_end_index = search_html.find(str(end_tag)) - description = search_html[description_start_index:description_end_index] - signatures = None + parsed_module = self.parse_module_symbol(symbol_heading, search_html) + if parsed_module is None: + return None + else: + signatures, description = parsed_module else: - signatures = [] - description = str(symbol_heading.find_next_sibling("dd")) - description_pos = search_html.find(description) - # Get text of up to 3 signatures, remove unwanted symbols - for element in [symbol_heading] + symbol_heading.find_next_siblings("dt", limit=2): - signature = UNWANTED_SIGNATURE_SYMBOLS_RE.sub("", element.text) - if signature and search_html.find(str(element)) < description_pos: - signatures.append(signature) + signatures, description = self.parse_symbol(symbol_heading, search_html) return signatures, description.replace('¶', '') @@ -354,6 +338,42 @@ class Doc(commands.Cog): ) return embed + @classmethod + def parse_module_symbol(cls, heading: PageElement, html: str) -> Optional[Tuple[None, str]]: + """Get page content from the headerlink up to a table or a tag with its class in `SEARCH_END_TAG_ATTRS`.""" + start_tag = heading.find("a", attrs={"class": "headerlink"}) + if start_tag is None: + return None + + end_tag = start_tag.find_next(cls._match_end_tag) + if end_tag is None: + return None + + description_start_index = html.find(str(start_tag.parent)) + len(str(start_tag.parent)) + description_end_index = html.find(str(end_tag)) + description = html[description_start_index:description_end_index] + + return None, description + + @staticmethod + def parse_symbol(heading: PageElement, html: str) -> Tuple[List[str], str]: + """ + Parse the signatures and description of a symbol. + + Collects up to 3 signatures from dt tags and a description from their sibling dd tag. + """ + signatures = [] + description = str(heading.find_next_sibling("dd")) + description_pos = html.find(description) + + for element in [heading] + heading.find_next_siblings("dt", limit=2): + signature = UNWANTED_SIGNATURE_SYMBOLS_RE.sub("", element.text) + + if signature and html.find(str(element)) < description_pos: + signatures.append(signature) + + return signatures, description + @commands.group(name='docs', aliases=('doc', 'd'), invoke_without_command=True) async def docs_group(self, ctx: commands.Context, *, symbol: str) -> None: """Lookup documentation for Python symbols.""" -- cgit v1.2.3 From b0f46ace7b2d4997d5002eb75199490f7828d829 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Thu, 18 Jun 2020 03:58:27 +0200 Subject: Make sure only class contents are included, without methods. When parsing classes, methods would sometimes get included causing bad looking markdown to be included in the description, this is solved by collecting all text *up to* the next dt tag. fixes: #990 --- bot/cogs/doc.py | 55 ++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index a1364dd8b..51323e64f 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -6,7 +6,7 @@ import textwrap from collections import OrderedDict from contextlib import suppress from types import SimpleNamespace -from typing import Any, Callable, List, Optional, Tuple +from typing import Any, Callable, List, Optional, Tuple, Union from urllib.parse import urljoin import discord @@ -265,7 +265,7 @@ class Doc(commands.Cog): return None if symbol_id == f"module-{symbol}": - parsed_module = self.parse_module_symbol(symbol_heading, search_html) + parsed_module = self.parse_module_symbol(symbol_heading) if parsed_module is None: return None else: @@ -339,32 +339,29 @@ class Doc(commands.Cog): return embed @classmethod - def parse_module_symbol(cls, heading: PageElement, html: str) -> Optional[Tuple[None, str]]: + def parse_module_symbol(cls, heading: PageElement) -> Optional[Tuple[None, str]]: """Get page content from the headerlink up to a table or a tag with its class in `SEARCH_END_TAG_ATTRS`.""" start_tag = heading.find("a", attrs={"class": "headerlink"}) if start_tag is None: return None - end_tag = start_tag.find_next(cls._match_end_tag) - if end_tag is None: + description = cls.find_all_text_until_tag(start_tag, cls._match_end_tag) + if description is None: return None - description_start_index = html.find(str(start_tag.parent)) + len(str(start_tag.parent)) - description_end_index = html.find(str(end_tag)) - description = html[description_start_index:description_end_index] - return None, description - @staticmethod - def parse_symbol(heading: PageElement, html: str) -> Tuple[List[str], str]: + @classmethod + def parse_symbol(cls, heading: PageElement, html: str) -> Tuple[List[str], str]: """ Parse the signatures and description of a symbol. Collects up to 3 signatures from dt tags and a description from their sibling dd tag. """ signatures = [] - description = str(heading.find_next_sibling("dd")) - description_pos = html.find(description) + description_element = heading.find_next_sibling("dd") + description_pos = html.find(str(description_element)) + description = "".join(cls.find_all_text_until_tag(description_element, ("dt",))) for element in [heading] + heading.find_next_siblings("dt", limit=2): signature = UNWANTED_SIGNATURE_SYMBOLS_RE.sub("", element.text) @@ -374,6 +371,38 @@ class Doc(commands.Cog): return signatures, description + @staticmethod + def find_all_text_until_tag( + start_element: PageElement, + tag_filter: Union[Tuple[str], Callable[[Tag], bool]] + ) -> Optional[str]: + """ + Get all text from
elements until a tag matching `tag_filter` is found, max 1000 elements searched. + + `tag_filter` can be either a tuple of string names to check against, + or a filtering callable that's applied to the tags. + If no matching end tag is found, None is returned. + """ + text = "" + element = start_element + for _ in range(1000): + if element is None: + break + + element = element.find_next() + if element.name == "p": + text += str(element) + + elif isinstance(tag_filter, tuple): + if element.name in tag_filter: + break + else: + if tag_filter(element): + break + else: + return None + return text + @commands.group(name='docs', aliases=('doc', 'd'), invoke_without_command=True) async def docs_group(self, ctx: commands.Context, *, symbol: str) -> None: """Lookup documentation for Python symbols.""" -- cgit v1.2.3 From 8756c741035d007a5d3f3309b877f56b9ccd0ef1 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 21 Jun 2020 00:59:32 +0200 Subject: Account for `NavigableString`s when gathering text. `find_next()` only goes to tags, leaving out text outside of them when parsing. --- bot/cogs/doc.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 51323e64f..d64e6692f 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -11,7 +11,7 @@ from urllib.parse import urljoin import discord from bs4 import BeautifulSoup -from bs4.element import PageElement, Tag +from bs4.element import NavigableString, PageElement, Tag from discord.errors import NotFound from discord.ext import commands from markdownify import MarkdownConverter @@ -377,7 +377,9 @@ class Doc(commands.Cog): tag_filter: Union[Tuple[str], Callable[[Tag], bool]] ) -> Optional[str]: """ - Get all text from
elements until a tag matching `tag_filter` is found, max 1000 elements searched. + Get all text from
elements and strings until a tag matching `tag_filter` is found. + + Max 1000 elements are searched to avoid going through whole pages when no matching tag is found. `tag_filter` can be either a tuple of string names to check against, or a filtering callable that's applied to the tags. @@ -389,7 +391,11 @@ class Doc(commands.Cog): if element is None: break - element = element.find_next() + element = element.next + while isinstance(element, NavigableString): + text += element + element = element.next + if element.name == "p": text += str(element) -- cgit v1.2.3 From e11c5a35f8f494f13323d53c0c514524902b2ae7 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 21 Jun 2020 01:45:54 +0200 Subject: Also check signatures before selected symbol when collecting 3 signatures. --- bot/cogs/doc.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index d64e6692f..b0adc52ba 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -363,7 +363,11 @@ class Doc(commands.Cog): description_pos = html.find(str(description_element)) description = "".join(cls.find_all_text_until_tag(description_element, ("dt",))) - for element in [heading] + heading.find_next_siblings("dt", limit=2): + for element in ( + *reversed(heading.find_previous_siblings("dt", limit=2)), + heading, + *heading.find_next_siblings("dt", limit=2), + )[-3:]: signature = UNWANTED_SIGNATURE_SYMBOLS_RE.sub("", element.text) if signature and html.find(str(element)) < description_pos: -- cgit v1.2.3 From bdccd72747829560eddecc2ae247e5da3a936237 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 21 Jun 2020 01:46:46 +0200 Subject: Remove unnecessary join. `find_all_text_until_tag` already returns a string so a join is not needed. --- bot/cogs/doc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index b0adc52ba..35139a050 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -361,7 +361,7 @@ class Doc(commands.Cog): signatures = [] description_element = heading.find_next_sibling("dd") description_pos = html.find(str(description_element)) - description = "".join(cls.find_all_text_until_tag(description_element, ("dt",))) + description = cls.find_all_text_until_tag(description_element, ("dt",)) for element in ( *reversed(heading.find_previous_siblings("dt", limit=2)), -- cgit v1.2.3 From d1900d537086b5d195da320cdc949e64afb99cd0 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 21 Jun 2020 01:52:02 +0200 Subject: Add symbol group name to symbol inventory entries. --- bot/cogs/doc.py | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 35139a050..741fd0ddd 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -6,7 +6,7 @@ import textwrap from collections import OrderedDict from contextlib import suppress from types import SimpleNamespace -from typing import Any, Callable, List, Optional, Tuple, Union +from typing import Any, Callable, List, NamedTuple, Optional, Tuple, Union from urllib.parse import urljoin import discord @@ -67,6 +67,13 @@ FAILED_REQUEST_RETRY_AMOUNT = 3 NOT_FOUND_DELETE_DELAY = RedirectOutput.delete_delay +class DocItem(NamedTuple): + """Holds inventory symbol information.""" + + url: str + group: str + + def async_cache(max_size: int = 128, arg_offset: int = 0) -> Callable: """ LRU cache implementation for coroutines. @@ -194,10 +201,10 @@ class Doc(commands.Cog): if "/" in symbol: continue # skip unreachable symbols with slashes absolute_doc_url = base_url + relative_doc_url + group_name = group.split(":")[1] if symbol in self.inventories: - group_name = group.split(":")[1] - symbol_base_url = self.inventories[symbol].split("/", 3)[2] + symbol_base_url = self.inventories[symbol].url.split("/", 3)[2] if ( group_name in NO_OVERRIDE_GROUPS or any(package in symbol_base_url for package in NO_OVERRIDE_PACKAGES) @@ -209,11 +216,11 @@ class Doc(commands.Cog): # Split `package_name` because of packages like Pillow that have spaces in them. symbol = f"{package_name.split()[0]}.{symbol}" - self.inventories[symbol] = absolute_doc_url + self.inventories[symbol] = DocItem(absolute_doc_url, group_name) self.renamed_symbols.add(symbol) continue - self.inventories[symbol] = absolute_doc_url + self.inventories[symbol] = DocItem(absolute_doc_url, group_name) log.trace(f"Fetched inventory for {package_name}.") @@ -248,15 +255,15 @@ class Doc(commands.Cog): If the given symbol is a module, returns a tuple `(None, str)` else if the symbol could not be found, returns `None`. """ - url = self.inventories.get(symbol) - if url is None: + symbol_info = self.inventories.get(symbol) + if symbol_info is None: return None - async with self.bot.http_session.get(url) as response: + async with self.bot.http_session.get(symbol_info.url) as response: html = await response.text(encoding='utf-8') # Find the signature header and parse the relevant parts. - symbol_id = url.split('#')[-1] + symbol_id = symbol_info.url.split('#')[-1] soup = BeautifulSoup(html, 'lxml') symbol_heading = soup.find(id=symbol_id) search_html = str(soup) @@ -288,7 +295,7 @@ class Doc(commands.Cog): return None signatures = scraped_html[0] - permalink = self.inventories[symbol] + permalink = self.inventories[symbol].url description = markdownify(scraped_html[1], url=permalink) # Truncate the description of the embed to the last occurrence -- cgit v1.2.3 From d790c404ca3dba3843f351d6f42e766956aa73a1 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 21 Jun 2020 02:37:32 +0200 Subject: Renamed existing symbols from `NO_OVERRIDE_GROUPS` instead of replacing. Before, when a symbol from the group shared the name with a symbol outside of it the symbol was simply replaced and lost. The new implementation renames the old symbols to the group_name.symbol format before the new symbol takes their place. --- bot/cogs/doc.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 741fd0ddd..4eea06386 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -209,16 +209,21 @@ class Doc(commands.Cog): group_name in NO_OVERRIDE_GROUPS or any(package in symbol_base_url for package in NO_OVERRIDE_PACKAGES) ): - symbol = f"{group_name}.{symbol}" - # If renamed `symbol` already exists, add library name in front to differentiate between them. - if symbol in self.renamed_symbols: - # Split `package_name` because of packages like Pillow that have spaces in them. - symbol = f"{package_name.split()[0]}.{symbol}" - self.inventories[symbol] = DocItem(absolute_doc_url, group_name) + elif (overridden_symbol_group := self.inventories[symbol].group) in NO_OVERRIDE_GROUPS: + overridden_symbol = f"{overridden_symbol_group}.{symbol}" + if overridden_symbol in self.renamed_symbols: + overridden_symbol = f"{package_name.split()[0]}.{overridden_symbol}" + + self.inventories[overridden_symbol] = self.inventories[symbol] + self.renamed_symbols.add(overridden_symbol) + + # If renamed `symbol` already exists, add library name in front to differentiate between them. + if symbol in self.renamed_symbols: + # Split `package_name` because of packages like Pillow that have spaces in them. + symbol = f"{package_name.split()[0]}.{symbol}" self.renamed_symbols.add(symbol) - continue self.inventories[symbol] = DocItem(absolute_doc_url, group_name) -- cgit v1.2.3 From bca55c25ffb3631ba05889a88908a02ccb2beb2a Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 21 Jun 2020 02:42:26 +0200 Subject: Fix typehint. --- bot/cogs/doc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 4eea06386..a01f6d64d 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -125,7 +125,7 @@ class DocMarkdownConverter(MarkdownConverter): return super().convert_a(el, text) -def markdownify(html: str, *, url: str = "") -> DocMarkdownConverter: +def markdownify(html: str, *, url: str = "") -> str: """Create a DocMarkdownConverter object from the input html.""" return DocMarkdownConverter(bullets='•', page_url=url).convert(html) -- cgit v1.2.3 From 38991027a38b1adc4be3c99d126dae76a3a62036 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 21 Jun 2020 03:09:23 +0200 Subject: Correct return when a module symbol could not be parsed. --- bot/cogs/doc.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index a01f6d64d..1c9d80e47 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -279,7 +279,7 @@ class Doc(commands.Cog): if symbol_id == f"module-{symbol}": parsed_module = self.parse_module_symbol(symbol_heading) if parsed_module is None: - return None + return [], "" else: signatures, description = parsed_module @@ -538,14 +538,13 @@ class Doc(commands.Cog): old_inventories = set(self.base_urls) with ctx.typing(): await self.refresh_inventory() - # Get differences of added and removed inventories - added = ', '.join(inv for inv in self.base_urls if inv not in old_inventories) - if added: - added = f"+ {added}" - - removed = ', '.join(inv for inv in old_inventories if inv not in self.base_urls) - if removed: - removed = f"- {removed}" + new_inventories = set(self.base_urls) + + if added := ", ".join(new_inventories - old_inventories): + added = "+ " + added + + if removed := ", ".join(old_inventories - new_inventories): + removed = "- " + removed embed = discord.Embed( title="Inventories refreshed", -- cgit v1.2.3 From a28ae5dfb610151060eab9856c44b2d192131f0d Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 21 Jun 2020 15:58:55 +0200 Subject: Strip backticks from symbol input. This allows the user to wrap symbols in codeblocks to avoid markdown. --- bot/cogs/doc.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 1c9d80e47..0dc1713a3 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -458,6 +458,7 @@ class Doc(commands.Cog): await ctx.send(embed=inventory_embed) else: + symbol = symbol.strip("`") # Fetching documentation for a symbol (at least for the first time, since # caching is used) takes quite some time, so let's send typing to indicate # that we got the command, but are still working on it. -- cgit v1.2.3 From c461bef250cd3d44fac2c0e64da21072f963909d Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sat, 27 Jun 2020 15:46:47 +0200 Subject: Redesign `find_all_text_until_tag` to search through all direct children. The previous approach didn't work for arbitrary tags with text. --- bot/cogs/doc.py | 39 ++++++++++++--------------------------- 1 file changed, 12 insertions(+), 27 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 0dc1713a3..e4b54f0a5 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -11,7 +11,7 @@ from urllib.parse import urljoin import discord from bs4 import BeautifulSoup -from bs4.element import NavigableString, PageElement, Tag +from bs4.element import PageElement, Tag from discord.errors import NotFound from discord.ext import commands from markdownify import MarkdownConverter @@ -357,7 +357,7 @@ class Doc(commands.Cog): if start_tag is None: return None - description = cls.find_all_text_until_tag(start_tag, cls._match_end_tag) + description = cls.find_all_children_until_tag(start_tag, cls._match_end_tag) if description is None: return None @@ -373,7 +373,7 @@ class Doc(commands.Cog): signatures = [] description_element = heading.find_next_sibling("dd") description_pos = html.find(str(description_element)) - description = cls.find_all_text_until_tag(description_element, ("dt",)) + description = cls.find_all_children_until_tag(description_element, tag_filter=("dt", "dl")) for element in ( *reversed(heading.find_previous_siblings("dt", limit=2)), @@ -388,41 +388,26 @@ class Doc(commands.Cog): return signatures, description @staticmethod - def find_all_text_until_tag( + def find_all_children_until_tag( start_element: PageElement, - tag_filter: Union[Tuple[str], Callable[[Tag], bool]] + tag_filter: Union[Tuple[str, ...], Callable[[Tag], bool]] ) -> Optional[str]: """ - Get all text from
 elements and strings until a tag matching `tag_filter` is found.
-
-        Max 1000 elements are searched to avoid going through whole pages when no matching tag is found.
+        Get all direct children until a child matching `tag_filter` is found.
 
         `tag_filter` can be either a tuple of string names to check against,
         or a filtering callable that's applied to the tags.
-        If no matching end tag is found, None is returned.
         """
         text = ""
-        element = start_element
-        for _ in range(1000):
-            if element is None:
-                break
-
-            element = element.next
-            while isinstance(element, NavigableString):
-                text += element
-                element = element.next
 
-            if element.name == "p":
-                text += str(element)
-
-            elif isinstance(tag_filter, tuple):
+        for element in start_element.find_next().find_next_siblings():
+            if isinstance(tag_filter, tuple):
                 if element.name in tag_filter:
                     break
-            else:
-                if tag_filter(element):
-                    break
-        else:
-            return None
+            elif tag_filter(element):
+                break
+            text += str(element)
+
         return text
 
     @commands.group(name='docs', aliases=('doc', 'd'), invoke_without_command=True)
-- 
cgit v1.2.3
From ff3afe58548a8f1ed675c1933545e481e99bfc78 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Sat, 27 Jun 2020 15:48:28 +0200
Subject: Only include one newline for `p` tags in `li` elements.
---
 bot/cogs/doc.py | 7 +++++++
 1 file changed, 7 insertions(+)
diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py
index e4b54f0a5..c1e8cebcf 100644
--- a/bot/cogs/doc.py
+++ b/bot/cogs/doc.py
@@ -124,6 +124,13 @@ class DocMarkdownConverter(MarkdownConverter):
         el["href"] = urljoin(self.page_url, el["href"])
         return super().convert_a(el, text)
 
+    def convert_p(self, el: PageElement, text: str) -> str:
+        """Include only one newline instead of two when the parent is a li tag."""
+        parent = el.parent
+        if parent is not None and parent.name == "li":
+            return f"{text}\n"
+        return super().convert_p(el, text)
+
 
 def markdownify(html: str, *, url: str = "") -> str:
     """Create a DocMarkdownConverter object from the input html."""
-- 
cgit v1.2.3
From 6532618a503a55653499089a2d6a4ca43be7e2bf Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Sun, 28 Jun 2020 01:45:17 +0200
Subject: Only update added inventory instead of all.
---
 bot/cogs/doc.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py
index c1e8cebcf..7c4beb075 100644
--- a/bot/cogs/doc.py
+++ b/bot/cogs/doc.py
@@ -504,7 +504,7 @@ class Doc(commands.Cog):
         # Rebuilding the inventory can take some time, so lets send out a
         # typing event to show that the Bot is still working.
         async with ctx.typing():
-            await self.refresh_inventory()
+            await self.update_single(package_name, base_url, inventory_url)
         await ctx.send(f"Added package `{package_name}` to database and refreshed inventory.")
 
     @docs_group.command(name='delete', aliases=('remove', 'rm', 'd'))
-- 
cgit v1.2.3
From fd839ef3f193586c204f52ca76a84c18a8f3ba1e Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Mon, 29 Jun 2020 02:39:00 +0200
Subject: Add stat for packages of fetched symbols.
An additional variable is added to the DocItem named tuple to accommodate this.
The `_package_name` is separated from `api_package_name` it previously overwrote and is now used
for the stats and renamed symbols because the names are in a friendlier format.
---
 bot/cogs/doc.py | 23 +++++++++++++----------
 1 file changed, 13 insertions(+), 10 deletions(-)
diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py
index 7c4beb075..e1c25d173 100644
--- a/bot/cogs/doc.py
+++ b/bot/cogs/doc.py
@@ -6,7 +6,7 @@ import textwrap
 from collections import OrderedDict
 from contextlib import suppress
 from types import SimpleNamespace
-from typing import Any, Callable, List, NamedTuple, Optional, Tuple, Union
+from typing import Any, Callable, Dict, List, NamedTuple, Optional, Tuple, Union
 from urllib.parse import urljoin
 
 import discord
@@ -70,6 +70,7 @@ NOT_FOUND_DELETE_DELAY = RedirectOutput.delete_delay
 class DocItem(NamedTuple):
     """Holds inventory symbol information."""
 
+    package: str
     url: str
     group: str
 
@@ -174,7 +175,7 @@ class Doc(commands.Cog):
     def __init__(self, bot: Bot):
         self.base_urls = {}
         self.bot = bot
-        self.inventories = {}
+        self.inventories: Dict[str, DocItem] = {}
         self.renamed_symbols = set()
 
         self.bot.loop.create_task(self.init_refresh_inventory())
@@ -185,7 +186,7 @@ class Doc(commands.Cog):
         await self.refresh_inventory()
 
     async def update_single(
-        self, package_name: str, base_url: str, inventory_url: str
+        self, api_package_name: str, base_url: str, inventory_url: str
     ) -> None:
         """
         Rebuild the inventory for a single package.
@@ -197,14 +198,14 @@ class Doc(commands.Cog):
             * `inventory_url` is the absolute URL to the intersphinx inventory, fetched by running
                 `intersphinx.fetch_inventory` in an executor on the bot's event loop
         """
-        self.base_urls[package_name] = base_url
+        self.base_urls[api_package_name] = base_url
 
         package = await self._fetch_inventory(inventory_url)
         if not package:
             return None
 
         for group, value in package.items():
-            for symbol, (package_name, _version, relative_doc_url, _) in value.items():
+            for symbol, (_package_name, _version, relative_doc_url, _) in value.items():
                 if "/" in symbol:
                     continue  # skip unreachable symbols with slashes
                 absolute_doc_url = base_url + relative_doc_url
@@ -221,7 +222,7 @@ class Doc(commands.Cog):
                     elif (overridden_symbol_group := self.inventories[symbol].group) in NO_OVERRIDE_GROUPS:
                         overridden_symbol = f"{overridden_symbol_group}.{symbol}"
                         if overridden_symbol in self.renamed_symbols:
-                            overridden_symbol = f"{package_name.split()[0]}.{overridden_symbol}"
+                            overridden_symbol = f"{api_package_name}.{overridden_symbol}"
 
                         self.inventories[overridden_symbol] = self.inventories[symbol]
                         self.renamed_symbols.add(overridden_symbol)
@@ -229,12 +230,12 @@ class Doc(commands.Cog):
                     # If renamed `symbol` already exists, add library name in front to differentiate between them.
                     if symbol in self.renamed_symbols:
                         # Split `package_name` because of packages like Pillow that have spaces in them.
-                        symbol = f"{package_name.split()[0]}.{symbol}"
+                        symbol = f"{api_package_name}.{symbol}"
                         self.renamed_symbols.add(symbol)
 
-                self.inventories[symbol] = DocItem(absolute_doc_url, group_name)
+                self.inventories[symbol] = DocItem(api_package_name, absolute_doc_url, group_name)
 
-        log.trace(f"Fetched inventory for {package_name}.")
+        log.trace(f"Fetched inventory for {api_package_name}.")
 
     async def refresh_inventory(self) -> None:
         """Refresh internal documentation inventory."""
@@ -306,8 +307,10 @@ class Doc(commands.Cog):
         if scraped_html is None:
             return None
 
+        symbol_obj = self.inventories[symbol]
+        self.bot.stats.incr(f"doc_fetches.{symbol_obj.package.lower()}")
         signatures = scraped_html[0]
-        permalink = self.inventories[symbol].url
+        permalink = symbol_obj.url
         description = markdownify(scraped_html[1], url=permalink)
 
         # Truncate the description of the embed to the last occurrence
-- 
cgit v1.2.3
From b6dc7536fd90e27f5dfdf3204dc2f17917d78ee2 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Mon, 29 Jun 2020 02:42:27 +0200
Subject: Trigger typing in converter instead of command.
The converter does a web request so triggering typing in the command itself
left out a period where the bot seemed inactive.
---
 bot/cogs/doc.py | 6 ++----
 1 file changed, 2 insertions(+), 4 deletions(-)
diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py
index e1c25d173..50aa9bbad 100644
--- a/bot/cogs/doc.py
+++ b/bot/cogs/doc.py
@@ -151,6 +151,7 @@ class InventoryURL(commands.Converter):
     @staticmethod
     async def convert(ctx: commands.Context, url: str) -> str:
         """Convert url to Intersphinx inventory URL."""
+        await ctx.trigger_typing()
         try:
             intersphinx.fetch_inventory(SPHINX_MOCK_APP, '', url)
         except AttributeError:
@@ -504,10 +505,7 @@ class Doc(commands.Cog):
             f"Inventory URL: {inventory_url}"
         )
 
-        # Rebuilding the inventory can take some time, so lets send out a
-        # typing event to show that the Bot is still working.
-        async with ctx.typing():
-            await self.update_single(package_name, base_url, inventory_url)
+        await self.update_single(package_name, base_url, inventory_url)
         await ctx.send(f"Added package `{package_name}` to database and refreshed inventory.")
 
     @docs_group.command(name='delete', aliases=('remove', 'rm', 'd'))
-- 
cgit v1.2.3
From 782cd1771ce9254761a70bbfbfa8e883c1330c6c Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Mon, 29 Jun 2020 16:27:24 +0200
Subject: Add option for user to delete the not found message before it's auto
 deleted.
---
 bot/cogs/doc.py | 15 +++++++++++----
 1 file changed, 11 insertions(+), 4 deletions(-)
diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py
index 50aa9bbad..b288a92b1 100644
--- a/bot/cogs/doc.py
+++ b/bot/cogs/doc.py
@@ -12,7 +12,6 @@ from urllib.parse import urljoin
 import discord
 from bs4 import BeautifulSoup
 from bs4.element import PageElement, Tag
-from discord.errors import NotFound
 from discord.ext import commands
 from markdownify import MarkdownConverter
 from requests import ConnectTimeout, ConnectionError, HTTPError
@@ -24,6 +23,7 @@ from bot.constants import MODERATION_ROLES, RedirectOutput
 from bot.converters import ValidPythonIdentifier, ValidURL
 from bot.decorators import with_role
 from bot.pagination import LinePaginator
+from bot.utils.messages import wait_for_deletion
 
 
 log = logging.getLogger(__name__)
@@ -468,9 +468,16 @@ class Doc(commands.Cog):
                     colour=discord.Colour.red()
                 )
                 error_message = await ctx.send(embed=error_embed)
-                with suppress(NotFound):
-                    await error_message.delete(delay=NOT_FOUND_DELETE_DELAY)
-                    await ctx.message.delete(delay=NOT_FOUND_DELETE_DELAY)
+                await wait_for_deletion(
+                    error_message,
+                    (ctx.author.id,),
+                    timeout=NOT_FOUND_DELETE_DELAY,
+                    client=self.bot
+                )
+                with suppress(discord.NotFound):
+                    await ctx.message.delete()
+                with suppress(discord.NotFound):
+                    await error_message.delete()
             else:
                 await ctx.send(embed=doc_embed)
 
-- 
cgit v1.2.3
From fa60e51243c56e6658a91ea63be67a42e22f1512 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Mon, 6 Jul 2020 21:23:41 +0200
Subject: Intern `group_names`
---
 bot/cogs/doc.py | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py
index b288a92b1..0975285e8 100644
--- a/bot/cogs/doc.py
+++ b/bot/cogs/doc.py
@@ -2,6 +2,7 @@ import asyncio
 import functools
 import logging
 import re
+import sys
 import textwrap
 from collections import OrderedDict
 from contextlib import suppress
@@ -210,7 +211,9 @@ class Doc(commands.Cog):
                 if "/" in symbol:
                     continue  # skip unreachable symbols with slashes
                 absolute_doc_url = base_url + relative_doc_url
-                group_name = group.split(":")[1]
+                # Intern the group names since they're reused in all the DocItems
+                # to remove unnecessary memory consumption from them being unique objects
+                group_name = sys.intern(group.split(":")[1])
 
                 if symbol in self.inventories:
                     symbol_base_url = self.inventories[symbol].url.split("/", 3)[2]
-- 
cgit v1.2.3
From 09987afb9b1e39fc5618b4217e1f33860cdd4bb4 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Tue, 7 Jul 2020 01:25:14 +0200
Subject: Create method to fetch and create a BeautifulSoup object from an url.
Moving this part of the logic into a separate method allows us to put a cache on it,
which caches the whole HTML document from the given url,
removing the need to do requests to the same URL for every symbol behind it.
---
 bot/cogs/doc.py | 17 +++++++++++------
 1 file changed, 11 insertions(+), 6 deletions(-)
diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py
index 0975285e8..71bfcfd4a 100644
--- a/bot/cogs/doc.py
+++ b/bot/cogs/doc.py
@@ -275,13 +275,9 @@ class Doc(commands.Cog):
         symbol_info = self.inventories.get(symbol)
         if symbol_info is None:
             return None
+        request_url, symbol_id = symbol_info.url.rsplit('#')
 
-        async with self.bot.http_session.get(symbol_info.url) as response:
-            html = await response.text(encoding='utf-8')
-
-        # Find the signature header and parse the relevant parts.
-        symbol_id = symbol_info.url.split('#')[-1]
-        soup = BeautifulSoup(html, 'lxml')
+        soup = await self._get_soup_from_url(request_url)
         symbol_heading = soup.find(id=symbol_id)
         search_html = str(soup)
 
@@ -424,6 +420,15 @@ class Doc(commands.Cog):
 
         return text
 
+    @async_cache(arg_offset=1)
+    async def _get_soup_from_url(self, url: str) -> BeautifulSoup:
+        """Create a BeautifulSoup object from the HTML data in `url` with the head tag removed."""
+        log.trace(f"Sending a request to {url}.")
+        async with self.bot.http_session.get(url) as response:
+            soup = BeautifulSoup(await response.text(encoding="utf8"), 'lxml')
+        soup.find("head").decompose()  # the head contains no useful data so we can remove it
+        return soup
+
     @commands.group(name='docs', aliases=('doc', 'd'), invoke_without_command=True)
     async def docs_group(self, ctx: commands.Context, *, symbol: str) -> None:
         """Lookup documentation for Python symbols."""
-- 
cgit v1.2.3
From 8462abaa15e0f9eb7b4f861d0485686ec7470ed0 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Tue, 7 Jul 2020 01:26:34 +0200
Subject: Use the group attribute instead of checking the symbol name.
---
 bot/cogs/doc.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py
index 71bfcfd4a..5ebfb6c25 100644
--- a/bot/cogs/doc.py
+++ b/bot/cogs/doc.py
@@ -284,7 +284,7 @@ class Doc(commands.Cog):
         if symbol_heading is None:
             return None
 
-        if symbol_id == f"module-{symbol}":
+        if symbol_info.group == "module":
             parsed_module = self.parse_module_symbol(symbol_heading)
             if parsed_module is None:
                 return [], ""
-- 
cgit v1.2.3
From 03dbddfcae35e47d57222343817ea779d6b67ab2 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Fri, 10 Jul 2020 22:36:19 +0200
Subject: Remove codeblock from symbol embed title.
The code block caused the url to not highlight the title text on mobile
---
 bot/cogs/doc.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py
index 5ebfb6c25..e2e3adb4e 100644
--- a/bot/cogs/doc.py
+++ b/bot/cogs/doc.py
@@ -350,7 +350,7 @@ class Doc(commands.Cog):
             embed_description += f"\n{description}"
 
         embed = discord.Embed(
-            title=f'`{symbol}`',
+            title=discord.utils.escape_markdown(symbol),
             url=permalink,
             description=embed_description
         )
-- 
cgit v1.2.3
From b59e39557ae97ac6bbc4e294651d1fe654bb2d21 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Tue, 14 Jul 2020 00:13:42 +0200
Subject: Add doc suffix to doc commands.
The `set` command shadowed the `set` symbol, causing the command
to seemingly not work. A suffix was added to all commands to keep
them consistent and future proof; the shorthands were kept unchanged
---
 bot/cogs/doc.py | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py
index e2e3adb4e..7f1fb6135 100644
--- a/bot/cogs/doc.py
+++ b/bot/cogs/doc.py
@@ -434,7 +434,7 @@ class Doc(commands.Cog):
         """Lookup documentation for Python symbols."""
         await ctx.invoke(self.get_command, symbol=symbol)
 
-    @docs_group.command(name='get', aliases=('g',))
+    @docs_group.command(name='getdoc', aliases=('g',))
     async def get_command(self, ctx: commands.Context, *, symbol: str) -> None:
         """
         Return a documentation embed for a given symbol.
@@ -489,7 +489,7 @@ class Doc(commands.Cog):
             else:
                 await ctx.send(embed=doc_embed)
 
-    @docs_group.command(name='set', aliases=('s',))
+    @docs_group.command(name='setdoc', aliases=('s',))
     @with_role(*MODERATION_ROLES)
     async def set_command(
         self, ctx: commands.Context, package_name: ValidPythonIdentifier,
@@ -523,7 +523,7 @@ class Doc(commands.Cog):
         await self.update_single(package_name, base_url, inventory_url)
         await ctx.send(f"Added package `{package_name}` to database and refreshed inventory.")
 
-    @docs_group.command(name='delete', aliases=('remove', 'rm', 'd'))
+    @docs_group.command(name='deletedoc', aliases=('removedoc', 'rm', 'd'))
     @with_role(*MODERATION_ROLES)
     async def delete_command(self, ctx: commands.Context, package_name: ValidPythonIdentifier) -> None:
         """
@@ -540,7 +540,7 @@ class Doc(commands.Cog):
             await self.refresh_inventory()
         await ctx.send(f"Successfully deleted `{package_name}` and refreshed inventory.")
 
-    @docs_group.command(name="refresh", aliases=("rfsh", "r"))
+    @docs_group.command(name="refreshdoc", aliases=("rfsh", "r"))
     @with_role(*MODERATION_ROLES)
     async def refresh_command(self, ctx: commands.Context) -> None:
         """Refresh inventories and send differences to channel."""
-- 
cgit v1.2.3
From ea0dcabbca10c5fe2afcee2b9451e1494bc069a2 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Tue, 14 Jul 2020 00:18:58 +0200
Subject: Make the symbol parameter optional.
The commands were changed to be greedy, this however made them
required arguments breaking the access to the default listing
of the available inventories
---
 bot/cogs/doc.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py
index 7f1fb6135..66c4b4ea8 100644
--- a/bot/cogs/doc.py
+++ b/bot/cogs/doc.py
@@ -430,12 +430,12 @@ class Doc(commands.Cog):
         return soup
 
     @commands.group(name='docs', aliases=('doc', 'd'), invoke_without_command=True)
-    async def docs_group(self, ctx: commands.Context, *, symbol: str) -> None:
+    async def docs_group(self, ctx: commands.Context, *, symbol: Optional[str]) -> None:
         """Lookup documentation for Python symbols."""
         await ctx.invoke(self.get_command, symbol=symbol)
 
     @docs_group.command(name='getdoc', aliases=('g',))
-    async def get_command(self, ctx: commands.Context, *, symbol: str) -> None:
+    async def get_command(self, ctx: commands.Context, *, symbol: Optional[str]) -> None:
         """
         Return a documentation embed for a given symbol.
 
-- 
cgit v1.2.3
From 40d831fb7b5ca7192fb1bdca8be9157f206eb2bc Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Tue, 14 Jul 2020 03:40:52 +0200
Subject: Change package name converter to only accept _a-z.
Package names are now directly used for stats, where
the lowercase a-z characters and _ are used.
---
 bot/cogs/doc.py   |  6 +++---
 bot/converters.py | 22 ++++++++++------------
 2 files changed, 13 insertions(+), 15 deletions(-)
diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py
index 66c4b4ea8..09bddb02c 100644
--- a/bot/cogs/doc.py
+++ b/bot/cogs/doc.py
@@ -21,7 +21,7 @@ from urllib3.exceptions import ProtocolError
 
 from bot.bot import Bot
 from bot.constants import MODERATION_ROLES, RedirectOutput
-from bot.converters import ValidPythonIdentifier, ValidURL
+from bot.converters import PackageName, ValidURL
 from bot.decorators import with_role
 from bot.pagination import LinePaginator
 from bot.utils.messages import wait_for_deletion
@@ -492,7 +492,7 @@ class Doc(commands.Cog):
     @docs_group.command(name='setdoc', aliases=('s',))
     @with_role(*MODERATION_ROLES)
     async def set_command(
-        self, ctx: commands.Context, package_name: ValidPythonIdentifier,
+        self, ctx: commands.Context, package_name: PackageName,
         base_url: ValidURL, inventory_url: InventoryURL
     ) -> None:
         """
@@ -525,7 +525,7 @@ class Doc(commands.Cog):
 
     @docs_group.command(name='deletedoc', aliases=('removedoc', 'rm', 'd'))
     @with_role(*MODERATION_ROLES)
-    async def delete_command(self, ctx: commands.Context, package_name: ValidPythonIdentifier) -> None:
+    async def delete_command(self, ctx: commands.Context, package_name: PackageName) -> None:
         """
         Removes the specified package from the database.
 
diff --git a/bot/converters.py b/bot/converters.py
index 72c46fdf0..fac94e9d0 100644
--- a/bot/converters.py
+++ b/bot/converters.py
@@ -34,22 +34,20 @@ def allowed_strings(*values, preserve_case: bool = False) -> t.Callable[[str], s
     return converter
 
 
-class ValidPythonIdentifier(Converter):
+class PackageName(Converter):
     """
-    A converter that checks whether the given string is a valid Python identifier.
+    A converter that checks whether the given string is a valid package name.
 
-    This is used to have package names that correspond to how you would use the package in your
-    code, e.g. `import package`.
-
-    Raises `BadArgument` if the argument is not a valid Python identifier, and simply passes through
-    the given argument otherwise.
+    Package names are used for stats and are restricted to the a-z and _ characters.
     """
 
-    @staticmethod
-    async def convert(ctx: Context, argument: str) -> str:
-        """Checks whether the given string is a valid Python identifier."""
-        if not argument.isidentifier():
-            raise BadArgument(f"`{argument}` is not a valid Python identifier")
+    PACKAGE_NAME_RE = re.compile(r"[^a-z_]")
+
+    @classmethod
+    async def convert(cls, ctx: Context, argument: str) -> str:
+        """Checks whether the given string is a valid package name."""
+        if cls.PACKAGE_NAME_RE.search(argument):
+            raise BadArgument("The provided package name is not valid, please only use the _ and a-z characters.")
         return argument
 
 
-- 
cgit v1.2.3
From 68805bb77d56f22854508f7912d00bdaab5daf5c Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Tue, 14 Jul 2020 03:49:18 +0200
Subject: Change docstrings to use suffixed command names.
---
 bot/cogs/doc.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py
index 09bddb02c..673a1156f 100644
--- a/bot/cogs/doc.py
+++ b/bot/cogs/doc.py
@@ -445,7 +445,7 @@ class Doc(commands.Cog):
             !docs
             !docs aiohttp
             !docs aiohttp.ClientSession
-            !docs get aiohttp.ClientSession
+            !docs getdoc aiohttp.ClientSession
         """
         if not symbol:
             inventory_embed = discord.Embed(
@@ -501,7 +501,7 @@ class Doc(commands.Cog):
         The database will update the object, should an existing item with the specified `package_name` already exist.
 
         Example:
-            !docs set \
+            !docs setdoc \
                     python \
                     https://docs.python.org/3/ \
                     https://docs.python.org/3/objects.inv
@@ -530,7 +530,7 @@ class Doc(commands.Cog):
         Removes the specified package from the database.
 
         Examples:
-            !docs delete aiohttp
+            !docs deletedoc aiohttp
         """
         await self.bot.api_client.delete(f'bot/documentation-links/{package_name}')
 
-- 
cgit v1.2.3
From d1413409f3cbfaaec94060df5c0fea7827fe874b Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Tue, 14 Jul 2020 23:54:03 +0200
Subject: Rename inventories to doc_symbols.
---
 bot/cogs/doc.py | 20 ++++++++++----------
 1 file changed, 10 insertions(+), 10 deletions(-)
diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py
index 673a1156f..526747bf4 100644
--- a/bot/cogs/doc.py
+++ b/bot/cogs/doc.py
@@ -177,7 +177,7 @@ class Doc(commands.Cog):
     def __init__(self, bot: Bot):
         self.base_urls = {}
         self.bot = bot
-        self.inventories: Dict[str, DocItem] = {}
+        self.doc_symbols: Dict[str, DocItem] = {}
         self.renamed_symbols = set()
 
         self.bot.loop.create_task(self.init_refresh_inventory())
@@ -215,20 +215,20 @@ class Doc(commands.Cog):
                 # to remove unnecessary memory consumption from them being unique objects
                 group_name = sys.intern(group.split(":")[1])
 
-                if symbol in self.inventories:
-                    symbol_base_url = self.inventories[symbol].url.split("/", 3)[2]
+                if symbol in self.doc_symbols:
+                    symbol_base_url = self.doc_symbols[symbol].url.split("/", 3)[2]
                     if (
                         group_name in NO_OVERRIDE_GROUPS
                         or any(package in symbol_base_url for package in NO_OVERRIDE_PACKAGES)
                     ):
                         symbol = f"{group_name}.{symbol}"
 
-                    elif (overridden_symbol_group := self.inventories[symbol].group) in NO_OVERRIDE_GROUPS:
+                    elif (overridden_symbol_group := self.doc_symbols[symbol].group) in NO_OVERRIDE_GROUPS:
                         overridden_symbol = f"{overridden_symbol_group}.{symbol}"
                         if overridden_symbol in self.renamed_symbols:
                             overridden_symbol = f"{api_package_name}.{overridden_symbol}"
 
-                        self.inventories[overridden_symbol] = self.inventories[symbol]
+                        self.doc_symbols[overridden_symbol] = self.doc_symbols[symbol]
                         self.renamed_symbols.add(overridden_symbol)
 
                     # If renamed `symbol` already exists, add library name in front to differentiate between them.
@@ -237,7 +237,7 @@ class Doc(commands.Cog):
                         symbol = f"{api_package_name}.{symbol}"
                         self.renamed_symbols.add(symbol)
 
-                self.inventories[symbol] = DocItem(api_package_name, absolute_doc_url, group_name)
+                self.doc_symbols[symbol] = DocItem(api_package_name, absolute_doc_url, group_name)
 
         log.trace(f"Fetched inventory for {api_package_name}.")
 
@@ -245,11 +245,11 @@ class Doc(commands.Cog):
         """Refresh internal documentation inventory."""
         log.debug("Refreshing documentation inventory...")
 
-        # Clear the old base URLS and inventories to ensure
+        # Clear the old base URLS and doc symbols to ensure
         # that we start from a fresh local dataset.
         # Also, reset the cache used for fetching documentation.
         self.base_urls.clear()
-        self.inventories.clear()
+        self.doc_symbols.clear()
         self.renamed_symbols.clear()
         async_cache.cache = OrderedDict()
 
@@ -272,7 +272,7 @@ class Doc(commands.Cog):
         If the given symbol is a module, returns a tuple `(None, str)`
         else if the symbol could not be found, returns `None`.
         """
-        symbol_info = self.inventories.get(symbol)
+        symbol_info = self.doc_symbols.get(symbol)
         if symbol_info is None:
             return None
         request_url, symbol_id = symbol_info.url.rsplit('#')
@@ -307,7 +307,7 @@ class Doc(commands.Cog):
         if scraped_html is None:
             return None
 
-        symbol_obj = self.inventories[symbol]
+        symbol_obj = self.doc_symbols[symbol]
         self.bot.stats.incr(f"doc_fetches.{symbol_obj.package.lower()}")
         signatures = scraped_html[0]
         permalink = symbol_obj.url
-- 
cgit v1.2.3
From daa46eccc6518e567777240d7b94f121c5eacf57 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Sat, 18 Jul 2020 15:52:25 +0200
Subject: Create a package for the Doc cog.
---
 bot/cogs/doc.py          | 603 -----------------------------------------------
 bot/cogs/doc/__init__.py |   7 +
 bot/cogs/doc/cog.py      | 598 ++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 605 insertions(+), 603 deletions(-)
 delete mode 100644 bot/cogs/doc.py
 create mode 100644 bot/cogs/doc/__init__.py
 create mode 100644 bot/cogs/doc/cog.py
diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py
deleted file mode 100644
index 526747bf4..000000000
--- a/bot/cogs/doc.py
+++ /dev/null
@@ -1,603 +0,0 @@
-import asyncio
-import functools
-import logging
-import re
-import sys
-import textwrap
-from collections import OrderedDict
-from contextlib import suppress
-from types import SimpleNamespace
-from typing import Any, Callable, Dict, List, NamedTuple, Optional, Tuple, Union
-from urllib.parse import urljoin
-
-import discord
-from bs4 import BeautifulSoup
-from bs4.element import PageElement, Tag
-from discord.ext import commands
-from markdownify import MarkdownConverter
-from requests import ConnectTimeout, ConnectionError, HTTPError
-from sphinx.ext import intersphinx
-from urllib3.exceptions import ProtocolError
-
-from bot.bot import Bot
-from bot.constants import MODERATION_ROLES, RedirectOutput
-from bot.converters import PackageName, ValidURL
-from bot.decorators import with_role
-from bot.pagination import LinePaginator
-from bot.utils.messages import wait_for_deletion
-
-
-log = logging.getLogger(__name__)
-logging.getLogger('urllib3').setLevel(logging.WARNING)
-
-# Since Intersphinx is intended to be used with Sphinx,
-# we need to mock its configuration.
-SPHINX_MOCK_APP = SimpleNamespace(
-    config=SimpleNamespace(
-        intersphinx_timeout=3,
-        tls_verify=True,
-        user_agent="python3:python-discord/bot:1.0.0"
-    )
-)
-
-NO_OVERRIDE_GROUPS = (
-    "2to3fixer",
-    "token",
-    "label",
-    "pdbcommand",
-    "term",
-)
-NO_OVERRIDE_PACKAGES = (
-    "python",
-)
-
-SEARCH_END_TAG_ATTRS = (
-    "data",
-    "function",
-    "class",
-    "exception",
-    "seealso",
-    "section",
-    "rubric",
-    "sphinxsidebar",
-)
-UNWANTED_SIGNATURE_SYMBOLS_RE = re.compile(r"\[source]|\\\\|¶")
-WHITESPACE_AFTER_NEWLINES_RE = re.compile(r"(?<=\n\n)(\s+)")
-
-FAILED_REQUEST_RETRY_AMOUNT = 3
-NOT_FOUND_DELETE_DELAY = RedirectOutput.delete_delay
-
-
-class DocItem(NamedTuple):
-    """Holds inventory symbol information."""
-
-    package: str
-    url: str
-    group: str
-
-
-def async_cache(max_size: int = 128, arg_offset: int = 0) -> Callable:
-    """
-    LRU cache implementation for coroutines.
-
-    Once the cache exceeds the maximum size, keys are deleted in FIFO order.
-
-    An offset may be optionally provided to be applied to the coroutine's arguments when creating the cache key.
-    """
-    # Assign the cache to the function itself so we can clear it from outside.
-    async_cache.cache = OrderedDict()
-
-    def decorator(function: Callable) -> Callable:
-        """Define the async_cache decorator."""
-        @functools.wraps(function)
-        async def wrapper(*args) -> Any:
-            """Decorator wrapper for the caching logic."""
-            key = ':'.join(args[arg_offset:])
-
-            value = async_cache.cache.get(key)
-            if value is None:
-                if len(async_cache.cache) > max_size:
-                    async_cache.cache.popitem(last=False)
-
-                async_cache.cache[key] = await function(*args)
-            return async_cache.cache[key]
-        return wrapper
-    return decorator
-
-
-class DocMarkdownConverter(MarkdownConverter):
-    """Subclass markdownify's MarkdownCoverter to provide custom conversion methods."""
-
-    def __init__(self, *, page_url: str, **options):
-        super().__init__(**options)
-        self.page_url = page_url
-
-    def convert_code(self, el: PageElement, text: str) -> str:
-        """Undo `markdownify`s underscore escaping."""
-        return f"`{text}`".replace('\\', '')
-
-    def convert_pre(self, el: PageElement, text: str) -> str:
-        """Wrap any codeblocks in `py` for syntax highlighting."""
-        code = ''.join(el.strings)
-        return f"```py\n{code}```"
-
-    def convert_a(self, el: PageElement, text: str) -> str:
-        """Resolve relative URLs to `self.page_url`."""
-        el["href"] = urljoin(self.page_url, el["href"])
-        return super().convert_a(el, text)
-
-    def convert_p(self, el: PageElement, text: str) -> str:
-        """Include only one newline instead of two when the parent is a li tag."""
-        parent = el.parent
-        if parent is not None and parent.name == "li":
-            return f"{text}\n"
-        return super().convert_p(el, text)
-
-
-def markdownify(html: str, *, url: str = "") -> str:
-    """Create a DocMarkdownConverter object from the input html."""
-    return DocMarkdownConverter(bullets='•', page_url=url).convert(html)
-
-
-class InventoryURL(commands.Converter):
-    """
-    Represents an Intersphinx inventory URL.
-
-    This converter checks whether intersphinx accepts the given inventory URL, and raises
-    `BadArgument` if that is not the case.
-
-    Otherwise, it simply passes through the given URL.
-    """
-
-    @staticmethod
-    async def convert(ctx: commands.Context, url: str) -> str:
-        """Convert url to Intersphinx inventory URL."""
-        await ctx.trigger_typing()
-        try:
-            intersphinx.fetch_inventory(SPHINX_MOCK_APP, '', url)
-        except AttributeError:
-            raise commands.BadArgument(f"Failed to fetch Intersphinx inventory from URL `{url}`.")
-        except ConnectionError:
-            if url.startswith('https'):
-                raise commands.BadArgument(
-                    f"Cannot establish a connection to `{url}`. Does it support HTTPS?"
-                )
-            raise commands.BadArgument(f"Cannot connect to host with URL `{url}`.")
-        except ValueError:
-            raise commands.BadArgument(
-                f"Failed to read Intersphinx inventory from URL `{url}`. "
-                "Are you sure that it's a valid inventory file?"
-            )
-        return url
-
-
-class Doc(commands.Cog):
-    """A set of commands for querying & displaying documentation."""
-
-    def __init__(self, bot: Bot):
-        self.base_urls = {}
-        self.bot = bot
-        self.doc_symbols: Dict[str, DocItem] = {}
-        self.renamed_symbols = set()
-
-        self.bot.loop.create_task(self.init_refresh_inventory())
-
-    async def init_refresh_inventory(self) -> None:
-        """Refresh documentation inventory on cog initialization."""
-        await self.bot.wait_until_guild_available()
-        await self.refresh_inventory()
-
-    async def update_single(
-        self, api_package_name: str, base_url: str, inventory_url: str
-    ) -> None:
-        """
-        Rebuild the inventory for a single package.
-
-        Where:
-            * `package_name` is the package name to use, appears in the log
-            * `base_url` is the root documentation URL for the specified package, used to build
-                absolute paths that link to specific symbols
-            * `inventory_url` is the absolute URL to the intersphinx inventory, fetched by running
-                `intersphinx.fetch_inventory` in an executor on the bot's event loop
-        """
-        self.base_urls[api_package_name] = base_url
-
-        package = await self._fetch_inventory(inventory_url)
-        if not package:
-            return None
-
-        for group, value in package.items():
-            for symbol, (_package_name, _version, relative_doc_url, _) in value.items():
-                if "/" in symbol:
-                    continue  # skip unreachable symbols with slashes
-                absolute_doc_url = base_url + relative_doc_url
-                # Intern the group names since they're reused in all the DocItems
-                # to remove unnecessary memory consumption from them being unique objects
-                group_name = sys.intern(group.split(":")[1])
-
-                if symbol in self.doc_symbols:
-                    symbol_base_url = self.doc_symbols[symbol].url.split("/", 3)[2]
-                    if (
-                        group_name in NO_OVERRIDE_GROUPS
-                        or any(package in symbol_base_url for package in NO_OVERRIDE_PACKAGES)
-                    ):
-                        symbol = f"{group_name}.{symbol}"
-
-                    elif (overridden_symbol_group := self.doc_symbols[symbol].group) in NO_OVERRIDE_GROUPS:
-                        overridden_symbol = f"{overridden_symbol_group}.{symbol}"
-                        if overridden_symbol in self.renamed_symbols:
-                            overridden_symbol = f"{api_package_name}.{overridden_symbol}"
-
-                        self.doc_symbols[overridden_symbol] = self.doc_symbols[symbol]
-                        self.renamed_symbols.add(overridden_symbol)
-
-                    # If renamed `symbol` already exists, add library name in front to differentiate between them.
-                    if symbol in self.renamed_symbols:
-                        # Split `package_name` because of packages like Pillow that have spaces in them.
-                        symbol = f"{api_package_name}.{symbol}"
-                        self.renamed_symbols.add(symbol)
-
-                self.doc_symbols[symbol] = DocItem(api_package_name, absolute_doc_url, group_name)
-
-        log.trace(f"Fetched inventory for {api_package_name}.")
-
-    async def refresh_inventory(self) -> None:
-        """Refresh internal documentation inventory."""
-        log.debug("Refreshing documentation inventory...")
-
-        # Clear the old base URLS and doc symbols to ensure
-        # that we start from a fresh local dataset.
-        # Also, reset the cache used for fetching documentation.
-        self.base_urls.clear()
-        self.doc_symbols.clear()
-        self.renamed_symbols.clear()
-        async_cache.cache = OrderedDict()
-
-        # Run all coroutines concurrently - since each of them performs a HTTP
-        # request, this speeds up fetching the inventory data heavily.
-        coros = [
-            self.update_single(
-                package["package"], package["base_url"], package["inventory_url"]
-            ) for package in await self.bot.api_client.get('bot/documentation-links')
-        ]
-        await asyncio.gather(*coros)
-
-    async def get_symbol_html(self, symbol: str) -> Optional[Tuple[list, str]]:
-        """
-        Given a Python symbol, return its signature and description.
-
-        The first tuple element is the signature of the given symbol as a markup-free string, and
-        the second tuple element is the description of the given symbol with HTML markup included.
-
-        If the given symbol is a module, returns a tuple `(None, str)`
-        else if the symbol could not be found, returns `None`.
-        """
-        symbol_info = self.doc_symbols.get(symbol)
-        if symbol_info is None:
-            return None
-        request_url, symbol_id = symbol_info.url.rsplit('#')
-
-        soup = await self._get_soup_from_url(request_url)
-        symbol_heading = soup.find(id=symbol_id)
-        search_html = str(soup)
-
-        if symbol_heading is None:
-            return None
-
-        if symbol_info.group == "module":
-            parsed_module = self.parse_module_symbol(symbol_heading)
-            if parsed_module is None:
-                return [], ""
-            else:
-                signatures, description = parsed_module
-
-        else:
-            signatures, description = self.parse_symbol(symbol_heading, search_html)
-
-        return signatures, description.replace('¶', '')
-
-    @async_cache(arg_offset=1)
-    async def get_symbol_embed(self, symbol: str) -> Optional[discord.Embed]:
-        """
-        Attempt to scrape and fetch the data for the given `symbol`, and build an embed from its contents.
-
-        If the symbol is known, an Embed with documentation about it is returned.
-        """
-        scraped_html = await self.get_symbol_html(symbol)
-        if scraped_html is None:
-            return None
-
-        symbol_obj = self.doc_symbols[symbol]
-        self.bot.stats.incr(f"doc_fetches.{symbol_obj.package.lower()}")
-        signatures = scraped_html[0]
-        permalink = symbol_obj.url
-        description = markdownify(scraped_html[1], url=permalink)
-
-        # Truncate the description of the embed to the last occurrence
-        # of a double newline (interpreted as a paragraph) before index 1000.
-        if len(description) > 1000:
-            shortened = description[:1000]
-            description_cutoff = shortened.rfind('\n\n', 100)
-            if description_cutoff == -1:
-                # Search the shortened version for cutoff points in decreasing desirability,
-                # cutoff at 1000 if none are found.
-                for string in (". ", ", ", ",", " "):
-                    description_cutoff = shortened.rfind(string)
-                    if description_cutoff != -1:
-                        break
-                else:
-                    description_cutoff = 1000
-            description = description[:description_cutoff]
-
-            # If there is an incomplete code block, cut it out
-            if description.count("```") % 2:
-                codeblock_start = description.rfind('```py')
-                description = description[:codeblock_start].rstrip()
-            description += f"... [read more]({permalink})"
-
-        description = WHITESPACE_AFTER_NEWLINES_RE.sub('', description)
-        if signatures is None:
-            # If symbol is a module, don't show signature.
-            embed_description = description
-
-        elif not signatures:
-            # It's some "meta-page", for example:
-            # https://docs.djangoproject.com/en/dev/ref/views/#module-django.views
-            embed_description = "This appears to be a generic page not tied to a specific symbol."
-
-        else:
-            embed_description = "".join(f"```py\n{textwrap.shorten(signature, 500)}```" for signature in signatures)
-            embed_description += f"\n{description}"
-
-        embed = discord.Embed(
-            title=discord.utils.escape_markdown(symbol),
-            url=permalink,
-            description=embed_description
-        )
-        # Show all symbols with the same name that were renamed in the footer.
-        embed.set_footer(
-            text=", ".join(renamed for renamed in self.renamed_symbols - {symbol} if renamed.endswith(f".{symbol}"))
-        )
-        return embed
-
-    @classmethod
-    def parse_module_symbol(cls, heading: PageElement) -> Optional[Tuple[None, str]]:
-        """Get page content from the headerlink up to a table or a tag with its class in `SEARCH_END_TAG_ATTRS`."""
-        start_tag = heading.find("a", attrs={"class": "headerlink"})
-        if start_tag is None:
-            return None
-
-        description = cls.find_all_children_until_tag(start_tag, cls._match_end_tag)
-        if description is None:
-            return None
-
-        return None, description
-
-    @classmethod
-    def parse_symbol(cls, heading: PageElement, html: str) -> Tuple[List[str], str]:
-        """
-        Parse the signatures and description of a symbol.
-
-        Collects up to 3 signatures from dt tags and a description from their sibling dd tag.
-        """
-        signatures = []
-        description_element = heading.find_next_sibling("dd")
-        description_pos = html.find(str(description_element))
-        description = cls.find_all_children_until_tag(description_element, tag_filter=("dt", "dl"))
-
-        for element in (
-            *reversed(heading.find_previous_siblings("dt", limit=2)),
-            heading,
-            *heading.find_next_siblings("dt", limit=2),
-        )[-3:]:
-            signature = UNWANTED_SIGNATURE_SYMBOLS_RE.sub("", element.text)
-
-            if signature and html.find(str(element)) < description_pos:
-                signatures.append(signature)
-
-        return signatures, description
-
-    @staticmethod
-    def find_all_children_until_tag(
-            start_element: PageElement,
-            tag_filter: Union[Tuple[str, ...], Callable[[Tag], bool]]
-    ) -> Optional[str]:
-        """
-        Get all direct children until a child matching `tag_filter` is found.
-
-        `tag_filter` can be either a tuple of string names to check against,
-        or a filtering callable that's applied to the tags.
-        """
-        text = ""
-
-        for element in start_element.find_next().find_next_siblings():
-            if isinstance(tag_filter, tuple):
-                if element.name in tag_filter:
-                    break
-            elif tag_filter(element):
-                break
-            text += str(element)
-
-        return text
-
-    @async_cache(arg_offset=1)
-    async def _get_soup_from_url(self, url: str) -> BeautifulSoup:
-        """Create a BeautifulSoup object from the HTML data in `url` with the head tag removed."""
-        log.trace(f"Sending a request to {url}.")
-        async with self.bot.http_session.get(url) as response:
-            soup = BeautifulSoup(await response.text(encoding="utf8"), 'lxml')
-        soup.find("head").decompose()  # the head contains no useful data so we can remove it
-        return soup
-
-    @commands.group(name='docs', aliases=('doc', 'd'), invoke_without_command=True)
-    async def docs_group(self, ctx: commands.Context, *, symbol: Optional[str]) -> None:
-        """Lookup documentation for Python symbols."""
-        await ctx.invoke(self.get_command, symbol=symbol)
-
-    @docs_group.command(name='getdoc', aliases=('g',))
-    async def get_command(self, ctx: commands.Context, *, symbol: Optional[str]) -> None:
-        """
-        Return a documentation embed for a given symbol.
-
-        If no symbol is given, return a list of all available inventories.
-
-        Examples:
-            !docs
-            !docs aiohttp
-            !docs aiohttp.ClientSession
-            !docs getdoc aiohttp.ClientSession
-        """
-        if not symbol:
-            inventory_embed = discord.Embed(
-                title=f"All inventories (`{len(self.base_urls)}` total)",
-                colour=discord.Colour.blue()
-            )
-
-            lines = sorted(f"• [`{name}`]({url})" for name, url in self.base_urls.items())
-            if self.base_urls:
-                await LinePaginator.paginate(lines, ctx, inventory_embed, max_size=400, empty=False)
-
-            else:
-                inventory_embed.description = "Hmmm, seems like there's nothing here yet."
-                await ctx.send(embed=inventory_embed)
-
-        else:
-            symbol = symbol.strip("`")
-            # Fetching documentation for a symbol (at least for the first time, since
-            # caching is used) takes quite some time, so let's send typing to indicate
-            # that we got the command, but are still working on it.
-            async with ctx.typing():
-                doc_embed = await self.get_symbol_embed(symbol)
-
-            if doc_embed is None:
-                symbol = await discord.ext.commands.clean_content().convert(ctx, symbol)
-                error_embed = discord.Embed(
-                    description=f"Sorry, I could not find any documentation for `{(symbol)}`.",
-                    colour=discord.Colour.red()
-                )
-                error_message = await ctx.send(embed=error_embed)
-                await wait_for_deletion(
-                    error_message,
-                    (ctx.author.id,),
-                    timeout=NOT_FOUND_DELETE_DELAY,
-                    client=self.bot
-                )
-                with suppress(discord.NotFound):
-                    await ctx.message.delete()
-                with suppress(discord.NotFound):
-                    await error_message.delete()
-            else:
-                await ctx.send(embed=doc_embed)
-
-    @docs_group.command(name='setdoc', aliases=('s',))
-    @with_role(*MODERATION_ROLES)
-    async def set_command(
-        self, ctx: commands.Context, package_name: PackageName,
-        base_url: ValidURL, inventory_url: InventoryURL
-    ) -> None:
-        """
-        Adds a new documentation metadata object to the site's database.
-
-        The database will update the object, should an existing item with the specified `package_name` already exist.
-
-        Example:
-            !docs setdoc \
-                    python \
-                    https://docs.python.org/3/ \
-                    https://docs.python.org/3/objects.inv
-        """
-        body = {
-            'package': package_name,
-            'base_url': base_url,
-            'inventory_url': inventory_url
-        }
-        await self.bot.api_client.post('bot/documentation-links', json=body)
-
-        log.info(
-            f"User @{ctx.author} ({ctx.author.id}) added a new documentation package:\n"
-            f"Package name: {package_name}\n"
-            f"Base url: {base_url}\n"
-            f"Inventory URL: {inventory_url}"
-        )
-
-        await self.update_single(package_name, base_url, inventory_url)
-        await ctx.send(f"Added package `{package_name}` to database and refreshed inventory.")
-
-    @docs_group.command(name='deletedoc', aliases=('removedoc', 'rm', 'd'))
-    @with_role(*MODERATION_ROLES)
-    async def delete_command(self, ctx: commands.Context, package_name: PackageName) -> None:
-        """
-        Removes the specified package from the database.
-
-        Examples:
-            !docs deletedoc aiohttp
-        """
-        await self.bot.api_client.delete(f'bot/documentation-links/{package_name}')
-
-        async with ctx.typing():
-            # Rebuild the inventory to ensure that everything
-            # that was from this package is properly deleted.
-            await self.refresh_inventory()
-        await ctx.send(f"Successfully deleted `{package_name}` and refreshed inventory.")
-
-    @docs_group.command(name="refreshdoc", aliases=("rfsh", "r"))
-    @with_role(*MODERATION_ROLES)
-    async def refresh_command(self, ctx: commands.Context) -> None:
-        """Refresh inventories and send differences to channel."""
-        old_inventories = set(self.base_urls)
-        with ctx.typing():
-            await self.refresh_inventory()
-        new_inventories = set(self.base_urls)
-
-        if added := ", ".join(new_inventories - old_inventories):
-            added = "+ " + added
-
-        if removed := ", ".join(old_inventories - new_inventories):
-            removed = "- " + removed
-
-        embed = discord.Embed(
-            title="Inventories refreshed",
-            description=f"```diff\n{added}\n{removed}```" if added or removed else ""
-        )
-        await ctx.send(embed=embed)
-
-    async def _fetch_inventory(self, inventory_url: str) -> Optional[dict]:
-        """Get and return inventory from `inventory_url`. If fetching fails, return None."""
-        fetch_func = functools.partial(intersphinx.fetch_inventory, SPHINX_MOCK_APP, '', inventory_url)
-        for retry in range(1, FAILED_REQUEST_RETRY_AMOUNT+1):
-            try:
-                package = await self.bot.loop.run_in_executor(None, fetch_func)
-            except ConnectTimeout:
-                log.error(
-                    f"Fetching of inventory {inventory_url} timed out,"
-                    f" trying again. ({retry}/{FAILED_REQUEST_RETRY_AMOUNT})"
-                )
-            except ProtocolError:
-                log.error(
-                    f"Connection lost while fetching inventory {inventory_url},"
-                    f" trying again. ({retry}/{FAILED_REQUEST_RETRY_AMOUNT})"
-                )
-            except HTTPError as e:
-                log.error(f"Fetching of inventory {inventory_url} failed with status code {e.response.status_code}.")
-                return None
-            except ConnectionError:
-                log.error(f"Couldn't establish connection to inventory {inventory_url}.")
-                return None
-            else:
-                return package
-        log.error(f"Fetching of inventory {inventory_url} failed.")
-        return None
-
-    @staticmethod
-    def _match_end_tag(tag: Tag) -> bool:
-        """Matches `tag` if its class value is in `SEARCH_END_TAG_ATTRS` or the tag is table."""
-        for attr in SEARCH_END_TAG_ATTRS:
-            if attr in tag.get("class", ()):
-                return True
-
-        return tag.name == "table"
-
-
-def setup(bot: Bot) -> None:
-    """Load the Doc cog."""
-    bot.add_cog(Doc(bot))
diff --git a/bot/cogs/doc/__init__.py b/bot/cogs/doc/__init__.py
new file mode 100644
index 000000000..19a71ee66
--- /dev/null
+++ b/bot/cogs/doc/__init__.py
@@ -0,0 +1,7 @@
+from bot.bot import Bot
+from .cog import DocCog
+
+
+def setup(bot: Bot) -> None:
+    """Load the Doc cog."""
+    bot.add_cog(DocCog(bot))
diff --git a/bot/cogs/doc/cog.py b/bot/cogs/doc/cog.py
new file mode 100644
index 000000000..463e4ebc6
--- /dev/null
+++ b/bot/cogs/doc/cog.py
@@ -0,0 +1,598 @@
+import asyncio
+import functools
+import logging
+import re
+import sys
+import textwrap
+from collections import OrderedDict
+from contextlib import suppress
+from types import SimpleNamespace
+from typing import Any, Callable, Dict, List, NamedTuple, Optional, Tuple, Union
+from urllib.parse import urljoin
+
+import discord
+from bs4 import BeautifulSoup
+from bs4.element import PageElement, Tag
+from discord.ext import commands
+from markdownify import MarkdownConverter
+from requests import ConnectTimeout, ConnectionError, HTTPError
+from sphinx.ext import intersphinx
+from urllib3.exceptions import ProtocolError
+
+from bot.bot import Bot
+from bot.constants import MODERATION_ROLES, RedirectOutput
+from bot.converters import PackageName, ValidURL
+from bot.decorators import with_role
+from bot.pagination import LinePaginator
+from bot.utils.messages import wait_for_deletion
+
+
+log = logging.getLogger(__name__)
+logging.getLogger('urllib3').setLevel(logging.WARNING)
+
+# Since Intersphinx is intended to be used with Sphinx,
+# we need to mock its configuration.
+SPHINX_MOCK_APP = SimpleNamespace(
+    config=SimpleNamespace(
+        intersphinx_timeout=3,
+        tls_verify=True,
+        user_agent="python3:python-discord/bot:1.0.0"
+    )
+)
+
+NO_OVERRIDE_GROUPS = (
+    "2to3fixer",
+    "token",
+    "label",
+    "pdbcommand",
+    "term",
+)
+NO_OVERRIDE_PACKAGES = (
+    "python",
+)
+
+SEARCH_END_TAG_ATTRS = (
+    "data",
+    "function",
+    "class",
+    "exception",
+    "seealso",
+    "section",
+    "rubric",
+    "sphinxsidebar",
+)
+UNWANTED_SIGNATURE_SYMBOLS_RE = re.compile(r"\[source]|\\\\|¶")
+WHITESPACE_AFTER_NEWLINES_RE = re.compile(r"(?<=\n\n)(\s+)")
+
+FAILED_REQUEST_RETRY_AMOUNT = 3
+NOT_FOUND_DELETE_DELAY = RedirectOutput.delete_delay
+
+
+class DocItem(NamedTuple):
+    """Holds inventory symbol information."""
+
+    package: str
+    url: str
+    group: str
+
+
+def async_cache(max_size: int = 128, arg_offset: int = 0) -> Callable:
+    """
+    LRU cache implementation for coroutines.
+
+    Once the cache exceeds the maximum size, keys are deleted in FIFO order.
+
+    An offset may be optionally provided to be applied to the coroutine's arguments when creating the cache key.
+    """
+    # Assign the cache to the function itself so we can clear it from outside.
+    async_cache.cache = OrderedDict()
+
+    def decorator(function: Callable) -> Callable:
+        """Define the async_cache decorator."""
+        @functools.wraps(function)
+        async def wrapper(*args) -> Any:
+            """Decorator wrapper for the caching logic."""
+            key = ':'.join(args[arg_offset:])
+
+            value = async_cache.cache.get(key)
+            if value is None:
+                if len(async_cache.cache) > max_size:
+                    async_cache.cache.popitem(last=False)
+
+                async_cache.cache[key] = await function(*args)
+            return async_cache.cache[key]
+        return wrapper
+    return decorator
+
+
+class DocMarkdownConverter(MarkdownConverter):
+    """Subclass markdownify's MarkdownCoverter to provide custom conversion methods."""
+
+    def __init__(self, *, page_url: str, **options):
+        super().__init__(**options)
+        self.page_url = page_url
+
+    def convert_code(self, el: PageElement, text: str) -> str:
+        """Undo `markdownify`s underscore escaping."""
+        return f"`{text}`".replace('\\', '')
+
+    def convert_pre(self, el: PageElement, text: str) -> str:
+        """Wrap any codeblocks in `py` for syntax highlighting."""
+        code = ''.join(el.strings)
+        return f"```py\n{code}```"
+
+    def convert_a(self, el: PageElement, text: str) -> str:
+        """Resolve relative URLs to `self.page_url`."""
+        el["href"] = urljoin(self.page_url, el["href"])
+        return super().convert_a(el, text)
+
+    def convert_p(self, el: PageElement, text: str) -> str:
+        """Include only one newline instead of two when the parent is a li tag."""
+        parent = el.parent
+        if parent is not None and parent.name == "li":
+            return f"{text}\n"
+        return super().convert_p(el, text)
+
+
+def markdownify(html: str, *, url: str = "") -> str:
+    """Create a DocMarkdownConverter object from the input html."""
+    return DocMarkdownConverter(bullets='•', page_url=url).convert(html)
+
+
+class InventoryURL(commands.Converter):
+    """
+    Represents an Intersphinx inventory URL.
+
+    This converter checks whether intersphinx accepts the given inventory URL, and raises
+    `BadArgument` if that is not the case.
+
+    Otherwise, it simply passes through the given URL.
+    """
+
+    @staticmethod
+    async def convert(ctx: commands.Context, url: str) -> str:
+        """Convert url to Intersphinx inventory URL."""
+        await ctx.trigger_typing()
+        try:
+            intersphinx.fetch_inventory(SPHINX_MOCK_APP, '', url)
+        except AttributeError:
+            raise commands.BadArgument(f"Failed to fetch Intersphinx inventory from URL `{url}`.")
+        except ConnectionError:
+            if url.startswith('https'):
+                raise commands.BadArgument(
+                    f"Cannot establish a connection to `{url}`. Does it support HTTPS?"
+                )
+            raise commands.BadArgument(f"Cannot connect to host with URL `{url}`.")
+        except ValueError:
+            raise commands.BadArgument(
+                f"Failed to read Intersphinx inventory from URL `{url}`. "
+                "Are you sure that it's a valid inventory file?"
+            )
+        return url
+
+
+class DocCog(commands.Cog):
+    """A set of commands for querying & displaying documentation."""
+
+    def __init__(self, bot: Bot):
+        self.base_urls = {}
+        self.bot = bot
+        self.doc_symbols: Dict[str, DocItem] = {}
+        self.renamed_symbols = set()
+
+        self.bot.loop.create_task(self.init_refresh_inventory())
+
+    async def init_refresh_inventory(self) -> None:
+        """Refresh documentation inventory on cog initialization."""
+        await self.bot.wait_until_guild_available()
+        await self.refresh_inventory()
+
+    async def update_single(
+        self, api_package_name: str, base_url: str, inventory_url: str
+    ) -> None:
+        """
+        Rebuild the inventory for a single package.
+
+        Where:
+            * `package_name` is the package name to use, appears in the log
+            * `base_url` is the root documentation URL for the specified package, used to build
+                absolute paths that link to specific symbols
+            * `inventory_url` is the absolute URL to the intersphinx inventory, fetched by running
+                `intersphinx.fetch_inventory` in an executor on the bot's event loop
+        """
+        self.base_urls[api_package_name] = base_url
+
+        package = await self._fetch_inventory(inventory_url)
+        if not package:
+            return None
+
+        for group, value in package.items():
+            for symbol, (_package_name, _version, relative_doc_url, _) in value.items():
+                if "/" in symbol:
+                    continue  # skip unreachable symbols with slashes
+                absolute_doc_url = base_url + relative_doc_url
+                # Intern the group names since they're reused in all the DocItems
+                # to remove unnecessary memory consumption from them being unique objects
+                group_name = sys.intern(group.split(":")[1])
+
+                if symbol in self.doc_symbols:
+                    symbol_base_url = self.doc_symbols[symbol].url.split("/", 3)[2]
+                    if (
+                        group_name in NO_OVERRIDE_GROUPS
+                        or any(package in symbol_base_url for package in NO_OVERRIDE_PACKAGES)
+                    ):
+                        symbol = f"{group_name}.{symbol}"
+
+                    elif (overridden_symbol_group := self.doc_symbols[symbol].group) in NO_OVERRIDE_GROUPS:
+                        overridden_symbol = f"{overridden_symbol_group}.{symbol}"
+                        if overridden_symbol in self.renamed_symbols:
+                            overridden_symbol = f"{api_package_name}.{overridden_symbol}"
+
+                        self.doc_symbols[overridden_symbol] = self.doc_symbols[symbol]
+                        self.renamed_symbols.add(overridden_symbol)
+
+                    # If renamed `symbol` already exists, add library name in front to differentiate between them.
+                    if symbol in self.renamed_symbols:
+                        # Split `package_name` because of packages like Pillow that have spaces in them.
+                        symbol = f"{api_package_name}.{symbol}"
+                        self.renamed_symbols.add(symbol)
+
+                self.doc_symbols[symbol] = DocItem(api_package_name, absolute_doc_url, group_name)
+
+        log.trace(f"Fetched inventory for {api_package_name}.")
+
+    async def refresh_inventory(self) -> None:
+        """Refresh internal documentation inventory."""
+        log.debug("Refreshing documentation inventory...")
+
+        # Clear the old base URLS and doc symbols to ensure
+        # that we start from a fresh local dataset.
+        # Also, reset the cache used for fetching documentation.
+        self.base_urls.clear()
+        self.doc_symbols.clear()
+        self.renamed_symbols.clear()
+        async_cache.cache = OrderedDict()
+
+        # Run all coroutines concurrently - since each of them performs a HTTP
+        # request, this speeds up fetching the inventory data heavily.
+        coros = [
+            self.update_single(
+                package["package"], package["base_url"], package["inventory_url"]
+            ) for package in await self.bot.api_client.get('bot/documentation-links')
+        ]
+        await asyncio.gather(*coros)
+
+    async def get_symbol_html(self, symbol: str) -> Optional[Tuple[list, str]]:
+        """
+        Given a Python symbol, return its signature and description.
+
+        The first tuple element is the signature of the given symbol as a markup-free string, and
+        the second tuple element is the description of the given symbol with HTML markup included.
+
+        If the given symbol is a module, returns a tuple `(None, str)`
+        else if the symbol could not be found, returns `None`.
+        """
+        symbol_info = self.doc_symbols.get(symbol)
+        if symbol_info is None:
+            return None
+        request_url, symbol_id = symbol_info.url.rsplit('#')
+
+        soup = await self._get_soup_from_url(request_url)
+        symbol_heading = soup.find(id=symbol_id)
+        search_html = str(soup)
+
+        if symbol_heading is None:
+            return None
+
+        if symbol_info.group == "module":
+            parsed_module = self.parse_module_symbol(symbol_heading)
+            if parsed_module is None:
+                return [], ""
+            else:
+                signatures, description = parsed_module
+
+        else:
+            signatures, description = self.parse_symbol(symbol_heading, search_html)
+
+        return signatures, description.replace('¶', '')
+
+    @async_cache(arg_offset=1)
+    async def get_symbol_embed(self, symbol: str) -> Optional[discord.Embed]:
+        """
+        Attempt to scrape and fetch the data for the given `symbol`, and build an embed from its contents.
+
+        If the symbol is known, an Embed with documentation about it is returned.
+        """
+        scraped_html = await self.get_symbol_html(symbol)
+        if scraped_html is None:
+            return None
+
+        symbol_obj = self.doc_symbols[symbol]
+        self.bot.stats.incr(f"doc_fetches.{symbol_obj.package.lower()}")
+        signatures = scraped_html[0]
+        permalink = symbol_obj.url
+        description = markdownify(scraped_html[1], url=permalink)
+
+        # Truncate the description of the embed to the last occurrence
+        # of a double newline (interpreted as a paragraph) before index 1000.
+        if len(description) > 1000:
+            shortened = description[:1000]
+            description_cutoff = shortened.rfind('\n\n', 100)
+            if description_cutoff == -1:
+                # Search the shortened version for cutoff points in decreasing desirability,
+                # cutoff at 1000 if none are found.
+                for string in (". ", ", ", ",", " "):
+                    description_cutoff = shortened.rfind(string)
+                    if description_cutoff != -1:
+                        break
+                else:
+                    description_cutoff = 1000
+            description = description[:description_cutoff]
+
+            # If there is an incomplete code block, cut it out
+            if description.count("```") % 2:
+                codeblock_start = description.rfind('```py')
+                description = description[:codeblock_start].rstrip()
+            description += f"... [read more]({permalink})"
+
+        description = WHITESPACE_AFTER_NEWLINES_RE.sub('', description)
+        if signatures is None:
+            # If symbol is a module, don't show signature.
+            embed_description = description
+
+        elif not signatures:
+            # It's some "meta-page", for example:
+            # https://docs.djangoproject.com/en/dev/ref/views/#module-django.views
+            embed_description = "This appears to be a generic page not tied to a specific symbol."
+
+        else:
+            embed_description = "".join(f"```py\n{textwrap.shorten(signature, 500)}```" for signature in signatures)
+            embed_description += f"\n{description}"
+
+        embed = discord.Embed(
+            title=discord.utils.escape_markdown(symbol),
+            url=permalink,
+            description=embed_description
+        )
+        # Show all symbols with the same name that were renamed in the footer.
+        embed.set_footer(
+            text=", ".join(renamed for renamed in self.renamed_symbols - {symbol} if renamed.endswith(f".{symbol}"))
+        )
+        return embed
+
+    @classmethod
+    def parse_module_symbol(cls, heading: PageElement) -> Optional[Tuple[None, str]]:
+        """Get page content from the headerlink up to a table or a tag with its class in `SEARCH_END_TAG_ATTRS`."""
+        start_tag = heading.find("a", attrs={"class": "headerlink"})
+        if start_tag is None:
+            return None
+
+        description = cls.find_all_children_until_tag(start_tag, cls._match_end_tag)
+        if description is None:
+            return None
+
+        return None, description
+
+    @classmethod
+    def parse_symbol(cls, heading: PageElement, html: str) -> Tuple[List[str], str]:
+        """
+        Parse the signatures and description of a symbol.
+
+        Collects up to 3 signatures from dt tags and a description from their sibling dd tag.
+        """
+        signatures = []
+        description_element = heading.find_next_sibling("dd")
+        description_pos = html.find(str(description_element))
+        description = cls.find_all_children_until_tag(description_element, tag_filter=("dt", "dl"))
+
+        for element in (
+            *reversed(heading.find_previous_siblings("dt", limit=2)),
+            heading,
+            *heading.find_next_siblings("dt", limit=2),
+        )[-3:]:
+            signature = UNWANTED_SIGNATURE_SYMBOLS_RE.sub("", element.text)
+
+            if signature and html.find(str(element)) < description_pos:
+                signatures.append(signature)
+
+        return signatures, description
+
+    @staticmethod
+    def find_all_children_until_tag(
+            start_element: PageElement,
+            tag_filter: Union[Tuple[str, ...], Callable[[Tag], bool]]
+    ) -> Optional[str]:
+        """
+        Get all direct children until a child matching `tag_filter` is found.
+
+        `tag_filter` can be either a tuple of string names to check against,
+        or a filtering callable that's applied to the tags.
+        """
+        text = ""
+
+        for element in start_element.find_next().find_next_siblings():
+            if isinstance(tag_filter, tuple):
+                if element.name in tag_filter:
+                    break
+            elif tag_filter(element):
+                break
+            text += str(element)
+
+        return text
+
+    @async_cache(arg_offset=1)
+    async def _get_soup_from_url(self, url: str) -> BeautifulSoup:
+        """Create a BeautifulSoup object from the HTML data in `url` with the head tag removed."""
+        log.trace(f"Sending a request to {url}.")
+        async with self.bot.http_session.get(url) as response:
+            soup = BeautifulSoup(await response.text(encoding="utf8"), 'lxml')
+        soup.find("head").decompose()  # the head contains no useful data so we can remove it
+        return soup
+
+    @commands.group(name='docs', aliases=('doc', 'd'), invoke_without_command=True)
+    async def docs_group(self, ctx: commands.Context, *, symbol: Optional[str]) -> None:
+        """Lookup documentation for Python symbols."""
+        await ctx.invoke(self.get_command, symbol=symbol)
+
+    @docs_group.command(name='getdoc', aliases=('g',))
+    async def get_command(self, ctx: commands.Context, *, symbol: Optional[str]) -> None:
+        """
+        Return a documentation embed for a given symbol.
+
+        If no symbol is given, return a list of all available inventories.
+
+        Examples:
+            !docs
+            !docs aiohttp
+            !docs aiohttp.ClientSession
+            !docs getdoc aiohttp.ClientSession
+        """
+        if not symbol:
+            inventory_embed = discord.Embed(
+                title=f"All inventories (`{len(self.base_urls)}` total)",
+                colour=discord.Colour.blue()
+            )
+
+            lines = sorted(f"• [`{name}`]({url})" for name, url in self.base_urls.items())
+            if self.base_urls:
+                await LinePaginator.paginate(lines, ctx, inventory_embed, max_size=400, empty=False)
+
+            else:
+                inventory_embed.description = "Hmmm, seems like there's nothing here yet."
+                await ctx.send(embed=inventory_embed)
+
+        else:
+            symbol = symbol.strip("`")
+            # Fetching documentation for a symbol (at least for the first time, since
+            # caching is used) takes quite some time, so let's send typing to indicate
+            # that we got the command, but are still working on it.
+            async with ctx.typing():
+                doc_embed = await self.get_symbol_embed(symbol)
+
+            if doc_embed is None:
+                symbol = await discord.ext.commands.clean_content().convert(ctx, symbol)
+                error_embed = discord.Embed(
+                    description=f"Sorry, I could not find any documentation for `{(symbol)}`.",
+                    colour=discord.Colour.red()
+                )
+                error_message = await ctx.send(embed=error_embed)
+                await wait_for_deletion(
+                    error_message,
+                    (ctx.author.id,),
+                    timeout=NOT_FOUND_DELETE_DELAY,
+                    client=self.bot
+                )
+                with suppress(discord.NotFound):
+                    await ctx.message.delete()
+                with suppress(discord.NotFound):
+                    await error_message.delete()
+            else:
+                await ctx.send(embed=doc_embed)
+
+    @docs_group.command(name='setdoc', aliases=('s',))
+    @with_role(*MODERATION_ROLES)
+    async def set_command(
+        self, ctx: commands.Context, package_name: PackageName,
+        base_url: ValidURL, inventory_url: InventoryURL
+    ) -> None:
+        """
+        Adds a new documentation metadata object to the site's database.
+
+        The database will update the object, should an existing item with the specified `package_name` already exist.
+
+        Example:
+            !docs setdoc \
+                    python \
+                    https://docs.python.org/3/ \
+                    https://docs.python.org/3/objects.inv
+        """
+        body = {
+            'package': package_name,
+            'base_url': base_url,
+            'inventory_url': inventory_url
+        }
+        await self.bot.api_client.post('bot/documentation-links', json=body)
+
+        log.info(
+            f"User @{ctx.author} ({ctx.author.id}) added a new documentation package:\n"
+            f"Package name: {package_name}\n"
+            f"Base url: {base_url}\n"
+            f"Inventory URL: {inventory_url}"
+        )
+
+        await self.update_single(package_name, base_url, inventory_url)
+        await ctx.send(f"Added package `{package_name}` to database and refreshed inventory.")
+
+    @docs_group.command(name='deletedoc', aliases=('removedoc', 'rm', 'd'))
+    @with_role(*MODERATION_ROLES)
+    async def delete_command(self, ctx: commands.Context, package_name: PackageName) -> None:
+        """
+        Removes the specified package from the database.
+
+        Examples:
+            !docs deletedoc aiohttp
+        """
+        await self.bot.api_client.delete(f'bot/documentation-links/{package_name}')
+
+        async with ctx.typing():
+            # Rebuild the inventory to ensure that everything
+            # that was from this package is properly deleted.
+            await self.refresh_inventory()
+        await ctx.send(f"Successfully deleted `{package_name}` and refreshed inventory.")
+
+    @docs_group.command(name="refreshdoc", aliases=("rfsh", "r"))
+    @with_role(*MODERATION_ROLES)
+    async def refresh_command(self, ctx: commands.Context) -> None:
+        """Refresh inventories and send differences to channel."""
+        old_inventories = set(self.base_urls)
+        with ctx.typing():
+            await self.refresh_inventory()
+        new_inventories = set(self.base_urls)
+
+        if added := ", ".join(new_inventories - old_inventories):
+            added = "+ " + added
+
+        if removed := ", ".join(old_inventories - new_inventories):
+            removed = "- " + removed
+
+        embed = discord.Embed(
+            title="Inventories refreshed",
+            description=f"```diff\n{added}\n{removed}```" if added or removed else ""
+        )
+        await ctx.send(embed=embed)
+
+    async def _fetch_inventory(self, inventory_url: str) -> Optional[dict]:
+        """Get and return inventory from `inventory_url`. If fetching fails, return None."""
+        fetch_func = functools.partial(intersphinx.fetch_inventory, SPHINX_MOCK_APP, '', inventory_url)
+        for retry in range(1, FAILED_REQUEST_RETRY_AMOUNT+1):
+            try:
+                package = await self.bot.loop.run_in_executor(None, fetch_func)
+            except ConnectTimeout:
+                log.error(
+                    f"Fetching of inventory {inventory_url} timed out,"
+                    f" trying again. ({retry}/{FAILED_REQUEST_RETRY_AMOUNT})"
+                )
+            except ProtocolError:
+                log.error(
+                    f"Connection lost while fetching inventory {inventory_url},"
+                    f" trying again. ({retry}/{FAILED_REQUEST_RETRY_AMOUNT})"
+                )
+            except HTTPError as e:
+                log.error(f"Fetching of inventory {inventory_url} failed with status code {e.response.status_code}.")
+                return None
+            except ConnectionError:
+                log.error(f"Couldn't establish connection to inventory {inventory_url}.")
+                return None
+            else:
+                return package
+        log.error(f"Fetching of inventory {inventory_url} failed.")
+        return None
+
+    @staticmethod
+    def _match_end_tag(tag: Tag) -> bool:
+        """Matches `tag` if its class value is in `SEARCH_END_TAG_ATTRS` or the tag is table."""
+        for attr in SEARCH_END_TAG_ATTRS:
+            if attr in tag.get("class", ()):
+                return True
+
+        return tag.name == "table"
-- 
cgit v1.2.3
From c3bda11a10e3706d7e457f727e57e6a92f604d1e Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Sat, 18 Jul 2020 16:16:49 +0200
Subject: Move async_cache into a separate module
---
 bot/cogs/doc/cache.py | 32 ++++++++++++++++++++++++++++++++
 bot/cogs/doc/cog.py   | 33 ++-------------------------------
 2 files changed, 34 insertions(+), 31 deletions(-)
 create mode 100644 bot/cogs/doc/cache.py
diff --git a/bot/cogs/doc/cache.py b/bot/cogs/doc/cache.py
new file mode 100644
index 000000000..9da2a1dab
--- /dev/null
+++ b/bot/cogs/doc/cache.py
@@ -0,0 +1,32 @@
+import functools
+from collections import OrderedDict
+from typing import Any, Callable
+
+
+def async_cache(max_size: int = 128, arg_offset: int = 0) -> Callable:
+    """
+    LRU cache implementation for coroutines.
+
+    Once the cache exceeds the maximum size, keys are deleted in FIFO order.
+
+    An offset may be optionally provided to be applied to the coroutine's arguments when creating the cache key.
+    """
+    # Assign the cache to the function itself so we can clear it from outside.
+    async_cache.cache = OrderedDict()
+
+    def decorator(function: Callable) -> Callable:
+        """Define the async_cache decorator."""
+        @functools.wraps(function)
+        async def wrapper(*args) -> Any:
+            """Decorator wrapper for the caching logic."""
+            key = ':'.join(args[arg_offset:])
+
+            value = async_cache.cache.get(key)
+            if value is None:
+                if len(async_cache.cache) > max_size:
+                    async_cache.cache.popitem(last=False)
+
+                async_cache.cache[key] = await function(*args)
+            return async_cache.cache[key]
+        return wrapper
+    return decorator
diff --git a/bot/cogs/doc/cog.py b/bot/cogs/doc/cog.py
index 463e4ebc6..2627951e8 100644
--- a/bot/cogs/doc/cog.py
+++ b/bot/cogs/doc/cog.py
@@ -7,7 +7,7 @@ import textwrap
 from collections import OrderedDict
 from contextlib import suppress
 from types import SimpleNamespace
-from typing import Any, Callable, Dict, List, NamedTuple, Optional, Tuple, Union
+from typing import Callable, Dict, List, NamedTuple, Optional, Tuple, Union
 from urllib.parse import urljoin
 
 import discord
@@ -25,7 +25,7 @@ from bot.converters import PackageName, ValidURL
 from bot.decorators import with_role
 from bot.pagination import LinePaginator
 from bot.utils.messages import wait_for_deletion
-
+from .cache import async_cache
 
 log = logging.getLogger(__name__)
 logging.getLogger('urllib3').setLevel(logging.WARNING)
@@ -76,35 +76,6 @@ class DocItem(NamedTuple):
     group: str
 
 
-def async_cache(max_size: int = 128, arg_offset: int = 0) -> Callable:
-    """
-    LRU cache implementation for coroutines.
-
-    Once the cache exceeds the maximum size, keys are deleted in FIFO order.
-
-    An offset may be optionally provided to be applied to the coroutine's arguments when creating the cache key.
-    """
-    # Assign the cache to the function itself so we can clear it from outside.
-    async_cache.cache = OrderedDict()
-
-    def decorator(function: Callable) -> Callable:
-        """Define the async_cache decorator."""
-        @functools.wraps(function)
-        async def wrapper(*args) -> Any:
-            """Decorator wrapper for the caching logic."""
-            key = ':'.join(args[arg_offset:])
-
-            value = async_cache.cache.get(key)
-            if value is None:
-                if len(async_cache.cache) > max_size:
-                    async_cache.cache.popitem(last=False)
-
-                async_cache.cache[key] = await function(*args)
-            return async_cache.cache[key]
-        return wrapper
-    return decorator
-
-
 class DocMarkdownConverter(MarkdownConverter):
     """Subclass markdownify's MarkdownCoverter to provide custom conversion methods."""
 
-- 
cgit v1.2.3
From 53213ec69208370342498cdc417f3c90d35b8f3e Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Sat, 18 Jul 2020 16:37:19 +0200
Subject: Move main parsing methods into a new module
---
 bot/cogs/doc/cog.py    | 102 +++----------------------------------------------
 bot/cogs/doc/parser.py | 102 +++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 108 insertions(+), 96 deletions(-)
 create mode 100644 bot/cogs/doc/parser.py
diff --git a/bot/cogs/doc/cog.py b/bot/cogs/doc/cog.py
index 2627951e8..4a275c7c6 100644
--- a/bot/cogs/doc/cog.py
+++ b/bot/cogs/doc/cog.py
@@ -7,12 +7,11 @@ import textwrap
 from collections import OrderedDict
 from contextlib import suppress
 from types import SimpleNamespace
-from typing import Callable, Dict, List, NamedTuple, Optional, Tuple, Union
+from typing import Dict, NamedTuple, Optional, Tuple
 from urllib.parse import urljoin
 
 import discord
-from bs4 import BeautifulSoup
-from bs4.element import PageElement, Tag
+from bs4.element import PageElement
 from discord.ext import commands
 from markdownify import MarkdownConverter
 from requests import ConnectTimeout, ConnectionError, HTTPError
@@ -26,6 +25,7 @@ from bot.decorators import with_role
 from bot.pagination import LinePaginator
 from bot.utils.messages import wait_for_deletion
 from .cache import async_cache
+from .parser import get_soup_from_url, parse_module_symbol, parse_symbol
 
 log = logging.getLogger(__name__)
 logging.getLogger('urllib3').setLevel(logging.WARNING)
@@ -51,19 +51,7 @@ NO_OVERRIDE_PACKAGES = (
     "python",
 )
 
-SEARCH_END_TAG_ATTRS = (
-    "data",
-    "function",
-    "class",
-    "exception",
-    "seealso",
-    "section",
-    "rubric",
-    "sphinxsidebar",
-)
-UNWANTED_SIGNATURE_SYMBOLS_RE = re.compile(r"\[source]|\\\\|¶")
 WHITESPACE_AFTER_NEWLINES_RE = re.compile(r"(?<=\n\n)(\s+)")
-
 FAILED_REQUEST_RETRY_AMOUNT = 3
 NOT_FOUND_DELETE_DELAY = RedirectOutput.delete_delay
 
@@ -248,7 +236,7 @@ class DocCog(commands.Cog):
             return None
         request_url, symbol_id = symbol_info.url.rsplit('#')
 
-        soup = await self._get_soup_from_url(request_url)
+        soup = await get_soup_from_url(self.bot.http_session, request_url)
         symbol_heading = soup.find(id=symbol_id)
         search_html = str(soup)
 
@@ -256,14 +244,14 @@ class DocCog(commands.Cog):
             return None
 
         if symbol_info.group == "module":
-            parsed_module = self.parse_module_symbol(symbol_heading)
+            parsed_module = parse_module_symbol(symbol_heading)
             if parsed_module is None:
                 return [], ""
             else:
                 signatures, description = parsed_module
 
         else:
-            signatures, description = self.parse_symbol(symbol_heading, search_html)
+            signatures, description = parse_symbol(symbol_heading, search_html)
 
         return signatures, description.replace('¶', '')
 
@@ -331,75 +319,6 @@ class DocCog(commands.Cog):
         )
         return embed
 
-    @classmethod
-    def parse_module_symbol(cls, heading: PageElement) -> Optional[Tuple[None, str]]:
-        """Get page content from the headerlink up to a table or a tag with its class in `SEARCH_END_TAG_ATTRS`."""
-        start_tag = heading.find("a", attrs={"class": "headerlink"})
-        if start_tag is None:
-            return None
-
-        description = cls.find_all_children_until_tag(start_tag, cls._match_end_tag)
-        if description is None:
-            return None
-
-        return None, description
-
-    @classmethod
-    def parse_symbol(cls, heading: PageElement, html: str) -> Tuple[List[str], str]:
-        """
-        Parse the signatures and description of a symbol.
-
-        Collects up to 3 signatures from dt tags and a description from their sibling dd tag.
-        """
-        signatures = []
-        description_element = heading.find_next_sibling("dd")
-        description_pos = html.find(str(description_element))
-        description = cls.find_all_children_until_tag(description_element, tag_filter=("dt", "dl"))
-
-        for element in (
-            *reversed(heading.find_previous_siblings("dt", limit=2)),
-            heading,
-            *heading.find_next_siblings("dt", limit=2),
-        )[-3:]:
-            signature = UNWANTED_SIGNATURE_SYMBOLS_RE.sub("", element.text)
-
-            if signature and html.find(str(element)) < description_pos:
-                signatures.append(signature)
-
-        return signatures, description
-
-    @staticmethod
-    def find_all_children_until_tag(
-            start_element: PageElement,
-            tag_filter: Union[Tuple[str, ...], Callable[[Tag], bool]]
-    ) -> Optional[str]:
-        """
-        Get all direct children until a child matching `tag_filter` is found.
-
-        `tag_filter` can be either a tuple of string names to check against,
-        or a filtering callable that's applied to the tags.
-        """
-        text = ""
-
-        for element in start_element.find_next().find_next_siblings():
-            if isinstance(tag_filter, tuple):
-                if element.name in tag_filter:
-                    break
-            elif tag_filter(element):
-                break
-            text += str(element)
-
-        return text
-
-    @async_cache(arg_offset=1)
-    async def _get_soup_from_url(self, url: str) -> BeautifulSoup:
-        """Create a BeautifulSoup object from the HTML data in `url` with the head tag removed."""
-        log.trace(f"Sending a request to {url}.")
-        async with self.bot.http_session.get(url) as response:
-            soup = BeautifulSoup(await response.text(encoding="utf8"), 'lxml')
-        soup.find("head").decompose()  # the head contains no useful data so we can remove it
-        return soup
-
     @commands.group(name='docs', aliases=('doc', 'd'), invoke_without_command=True)
     async def docs_group(self, ctx: commands.Context, *, symbol: Optional[str]) -> None:
         """Lookup documentation for Python symbols."""
@@ -558,12 +477,3 @@ class DocCog(commands.Cog):
                 return package
         log.error(f"Fetching of inventory {inventory_url} failed.")
         return None
-
-    @staticmethod
-    def _match_end_tag(tag: Tag) -> bool:
-        """Matches `tag` if its class value is in `SEARCH_END_TAG_ATTRS` or the tag is table."""
-        for attr in SEARCH_END_TAG_ATTRS:
-            if attr in tag.get("class", ()):
-                return True
-
-        return tag.name == "table"
diff --git a/bot/cogs/doc/parser.py b/bot/cogs/doc/parser.py
new file mode 100644
index 000000000..67621591b
--- /dev/null
+++ b/bot/cogs/doc/parser.py
@@ -0,0 +1,102 @@
+import logging
+import re
+from typing import Callable, List, Optional, Tuple, Union
+
+from aiohttp import ClientSession
+from bs4 import BeautifulSoup
+from bs4.element import PageElement, Tag
+
+from .cache import async_cache
+
+log = logging.getLogger(__name__)
+
+UNWANTED_SIGNATURE_SYMBOLS_RE = re.compile(r"\[source]|\\\\|¶")
+SEARCH_END_TAG_ATTRS = (
+    "data",
+    "function",
+    "class",
+    "exception",
+    "seealso",
+    "section",
+    "rubric",
+    "sphinxsidebar",
+)
+
+
+def parse_module_symbol(heading: PageElement) -> Optional[Tuple[None, str]]:
+    """Get page content from the headerlink up to a table or a tag with its class in `SEARCH_END_TAG_ATTRS`."""
+    start_tag = heading.find("a", attrs={"class": "headerlink"})
+    if start_tag is None:
+        return None
+
+    description = find_all_children_until_tag(start_tag, _match_end_tag)
+    if description is None:
+        return None
+
+    return None, description
+
+
+def parse_symbol(heading: PageElement, html: str) -> Tuple[List[str], str]:
+    """
+    Parse the signatures and description of a symbol.
+
+    Collects up to 3 signatures from dt tags and a description from their sibling dd tag.
+    """
+    signatures = []
+    description_element = heading.find_next_sibling("dd")
+    description_pos = html.find(str(description_element))
+    description = find_all_children_until_tag(description_element, tag_filter=("dt", "dl"))
+
+    for element in (
+            *reversed(heading.find_previous_siblings("dt", limit=2)),
+            heading,
+            *heading.find_next_siblings("dt", limit=2),
+    )[-3:]:
+        signature = UNWANTED_SIGNATURE_SYMBOLS_RE.sub("", element.text)
+
+        if signature and html.find(str(element)) < description_pos:
+            signatures.append(signature)
+
+    return signatures, description
+
+
+def find_all_children_until_tag(
+        start_element: PageElement,
+        tag_filter: Union[Tuple[str, ...], Callable[[Tag], bool]]
+) -> Optional[str]:
+    """
+    Get all direct children until a child matching `tag_filter` is found.
+
+    `tag_filter` can be either a tuple of string names to check against,
+    or a filtering callable that's applied to the tags.
+    """
+    text = ""
+
+    for element in start_element.find_next().find_next_siblings():
+        if isinstance(tag_filter, tuple):
+            if element.name in tag_filter:
+                break
+        elif tag_filter(element):
+            break
+        text += str(element)
+
+    return text
+
+
+@async_cache(arg_offset=1)
+async def get_soup_from_url(http_session: ClientSession, url: str) -> BeautifulSoup:
+    """Create a BeautifulSoup object from the HTML data in `url` with the head tag removed."""
+    log.trace(f"Sending a request to {url}.")
+    async with http_session.get(url) as response:
+        soup = BeautifulSoup(await response.text(encoding="utf8"), 'lxml')
+    soup.find("head").decompose()  # the head contains no useful data so we can remove it
+    return soup
+
+
+def _match_end_tag(tag: Tag) -> bool:
+    """Matches `tag` if its class value is in `SEARCH_END_TAG_ATTRS` or the tag is table."""
+    for attr in SEARCH_END_TAG_ATTRS:
+        if attr in tag.get("class", ()):
+            return True
+
+    return tag.name == "table"
-- 
cgit v1.2.3
From eb8361d7fa9d0eb0dd5982c6df0fd35b80d40ba6 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Sun, 19 Jul 2020 03:13:02 +0200
Subject: Move markdown truncation into parser module
---
 bot/cogs/doc/cog.py    | 27 ++-------------------------
 bot/cogs/doc/parser.py | 29 +++++++++++++++++++++++++++++
 2 files changed, 31 insertions(+), 25 deletions(-)
diff --git a/bot/cogs/doc/cog.py b/bot/cogs/doc/cog.py
index 4a275c7c6..bd4e9d4d1 100644
--- a/bot/cogs/doc/cog.py
+++ b/bot/cogs/doc/cog.py
@@ -25,7 +25,7 @@ from bot.decorators import with_role
 from bot.pagination import LinePaginator
 from bot.utils.messages import wait_for_deletion
 from .cache import async_cache
-from .parser import get_soup_from_url, parse_module_symbol, parse_symbol
+from .parser import get_soup_from_url, parse_module_symbol, parse_symbol, truncate_markdown
 
 log = logging.getLogger(__name__)
 logging.getLogger('urllib3').setLevel(logging.WARNING)
@@ -270,30 +270,7 @@ class DocCog(commands.Cog):
         self.bot.stats.incr(f"doc_fetches.{symbol_obj.package.lower()}")
         signatures = scraped_html[0]
         permalink = symbol_obj.url
-        description = markdownify(scraped_html[1], url=permalink)
-
-        # Truncate the description of the embed to the last occurrence
-        # of a double newline (interpreted as a paragraph) before index 1000.
-        if len(description) > 1000:
-            shortened = description[:1000]
-            description_cutoff = shortened.rfind('\n\n', 100)
-            if description_cutoff == -1:
-                # Search the shortened version for cutoff points in decreasing desirability,
-                # cutoff at 1000 if none are found.
-                for string in (". ", ", ", ",", " "):
-                    description_cutoff = shortened.rfind(string)
-                    if description_cutoff != -1:
-                        break
-                else:
-                    description_cutoff = 1000
-            description = description[:description_cutoff]
-
-            # If there is an incomplete code block, cut it out
-            if description.count("```") % 2:
-                codeblock_start = description.rfind('```py')
-                description = description[:codeblock_start].rstrip()
-            description += f"... [read more]({permalink})"
-
+        description = truncate_markdown(markdownify(scraped_html[1], url=permalink), permalink, 1000)
         description = WHITESPACE_AFTER_NEWLINES_RE.sub('', description)
         if signatures is None:
             # If symbol is a module, don't show signature.
diff --git a/bot/cogs/doc/parser.py b/bot/cogs/doc/parser.py
index 67621591b..010826a96 100644
--- a/bot/cogs/doc/parser.py
+++ b/bot/cogs/doc/parser.py
@@ -83,6 +83,35 @@ def find_all_children_until_tag(
     return text
 
 
+def truncate_markdown(markdown: str, permalink: str, max_length: int) -> str:
+    """
+    Truncate `markdown` to be at most `max_length` characters.
+
+    The markdown string is searched for substrings to cut at, to keep its structure,
+    but if none are found the string is simply sliced.
+    """
+    if len(markdown) > max_length:
+        shortened = markdown[:max_length]
+        description_cutoff = shortened.rfind('\n\n', 100)
+        if description_cutoff == -1:
+            # Search the shortened version for cutoff points in decreasing desirability,
+            # cutoff at 1000 if none are found.
+            for string in (". ", ", ", ",", " "):
+                description_cutoff = shortened.rfind(string)
+                if description_cutoff != -1:
+                    break
+            else:
+                description_cutoff = max_length
+        markdown = markdown[:description_cutoff]
+
+        # If there is an incomplete code block, cut it out
+        if markdown.count("```") % 2:
+            codeblock_start = markdown.rfind('```py')
+            markdown = markdown[:codeblock_start].rstrip()
+        markdown += f"... [read more]({permalink})"
+    return markdown
+
+
 @async_cache(arg_offset=1)
 async def get_soup_from_url(http_session: ClientSession, url: str) -> BeautifulSoup:
     """Create a BeautifulSoup object from the HTML data in `url` with the head tag removed."""
-- 
cgit v1.2.3
From 0f8b991fffce8b808bf25f1ad9ed710bb1ff4919 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Mon, 20 Jul 2020 02:24:19 +0200
Subject: Rename parser.py to parsing.py.
Parser is a stdlib module name, a rename avoids shadowing it.
---
 bot/cogs/doc/cog.py     |   2 +-
 bot/cogs/doc/parser.py  | 131 ------------------------------------------------
 bot/cogs/doc/parsing.py | 131 ++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 132 insertions(+), 132 deletions(-)
 delete mode 100644 bot/cogs/doc/parser.py
 create mode 100644 bot/cogs/doc/parsing.py
diff --git a/bot/cogs/doc/cog.py b/bot/cogs/doc/cog.py
index bd4e9d4d1..4e4f3b737 100644
--- a/bot/cogs/doc/cog.py
+++ b/bot/cogs/doc/cog.py
@@ -25,7 +25,7 @@ from bot.decorators import with_role
 from bot.pagination import LinePaginator
 from bot.utils.messages import wait_for_deletion
 from .cache import async_cache
-from .parser import get_soup_from_url, parse_module_symbol, parse_symbol, truncate_markdown
+from .parsing import get_soup_from_url, parse_module_symbol, parse_symbol, truncate_markdown
 
 log = logging.getLogger(__name__)
 logging.getLogger('urllib3').setLevel(logging.WARNING)
diff --git a/bot/cogs/doc/parser.py b/bot/cogs/doc/parser.py
deleted file mode 100644
index 010826a96..000000000
--- a/bot/cogs/doc/parser.py
+++ /dev/null
@@ -1,131 +0,0 @@
-import logging
-import re
-from typing import Callable, List, Optional, Tuple, Union
-
-from aiohttp import ClientSession
-from bs4 import BeautifulSoup
-from bs4.element import PageElement, Tag
-
-from .cache import async_cache
-
-log = logging.getLogger(__name__)
-
-UNWANTED_SIGNATURE_SYMBOLS_RE = re.compile(r"\[source]|\\\\|¶")
-SEARCH_END_TAG_ATTRS = (
-    "data",
-    "function",
-    "class",
-    "exception",
-    "seealso",
-    "section",
-    "rubric",
-    "sphinxsidebar",
-)
-
-
-def parse_module_symbol(heading: PageElement) -> Optional[Tuple[None, str]]:
-    """Get page content from the headerlink up to a table or a tag with its class in `SEARCH_END_TAG_ATTRS`."""
-    start_tag = heading.find("a", attrs={"class": "headerlink"})
-    if start_tag is None:
-        return None
-
-    description = find_all_children_until_tag(start_tag, _match_end_tag)
-    if description is None:
-        return None
-
-    return None, description
-
-
-def parse_symbol(heading: PageElement, html: str) -> Tuple[List[str], str]:
-    """
-    Parse the signatures and description of a symbol.
-
-    Collects up to 3 signatures from dt tags and a description from their sibling dd tag.
-    """
-    signatures = []
-    description_element = heading.find_next_sibling("dd")
-    description_pos = html.find(str(description_element))
-    description = find_all_children_until_tag(description_element, tag_filter=("dt", "dl"))
-
-    for element in (
-            *reversed(heading.find_previous_siblings("dt", limit=2)),
-            heading,
-            *heading.find_next_siblings("dt", limit=2),
-    )[-3:]:
-        signature = UNWANTED_SIGNATURE_SYMBOLS_RE.sub("", element.text)
-
-        if signature and html.find(str(element)) < description_pos:
-            signatures.append(signature)
-
-    return signatures, description
-
-
-def find_all_children_until_tag(
-        start_element: PageElement,
-        tag_filter: Union[Tuple[str, ...], Callable[[Tag], bool]]
-) -> Optional[str]:
-    """
-    Get all direct children until a child matching `tag_filter` is found.
-
-    `tag_filter` can be either a tuple of string names to check against,
-    or a filtering callable that's applied to the tags.
-    """
-    text = ""
-
-    for element in start_element.find_next().find_next_siblings():
-        if isinstance(tag_filter, tuple):
-            if element.name in tag_filter:
-                break
-        elif tag_filter(element):
-            break
-        text += str(element)
-
-    return text
-
-
-def truncate_markdown(markdown: str, permalink: str, max_length: int) -> str:
-    """
-    Truncate `markdown` to be at most `max_length` characters.
-
-    The markdown string is searched for substrings to cut at, to keep its structure,
-    but if none are found the string is simply sliced.
-    """
-    if len(markdown) > max_length:
-        shortened = markdown[:max_length]
-        description_cutoff = shortened.rfind('\n\n', 100)
-        if description_cutoff == -1:
-            # Search the shortened version for cutoff points in decreasing desirability,
-            # cutoff at 1000 if none are found.
-            for string in (". ", ", ", ",", " "):
-                description_cutoff = shortened.rfind(string)
-                if description_cutoff != -1:
-                    break
-            else:
-                description_cutoff = max_length
-        markdown = markdown[:description_cutoff]
-
-        # If there is an incomplete code block, cut it out
-        if markdown.count("```") % 2:
-            codeblock_start = markdown.rfind('```py')
-            markdown = markdown[:codeblock_start].rstrip()
-        markdown += f"... [read more]({permalink})"
-    return markdown
-
-
-@async_cache(arg_offset=1)
-async def get_soup_from_url(http_session: ClientSession, url: str) -> BeautifulSoup:
-    """Create a BeautifulSoup object from the HTML data in `url` with the head tag removed."""
-    log.trace(f"Sending a request to {url}.")
-    async with http_session.get(url) as response:
-        soup = BeautifulSoup(await response.text(encoding="utf8"), 'lxml')
-    soup.find("head").decompose()  # the head contains no useful data so we can remove it
-    return soup
-
-
-def _match_end_tag(tag: Tag) -> bool:
-    """Matches `tag` if its class value is in `SEARCH_END_TAG_ATTRS` or the tag is table."""
-    for attr in SEARCH_END_TAG_ATTRS:
-        if attr in tag.get("class", ()):
-            return True
-
-    return tag.name == "table"
diff --git a/bot/cogs/doc/parsing.py b/bot/cogs/doc/parsing.py
new file mode 100644
index 000000000..010826a96
--- /dev/null
+++ b/bot/cogs/doc/parsing.py
@@ -0,0 +1,131 @@
+import logging
+import re
+from typing import Callable, List, Optional, Tuple, Union
+
+from aiohttp import ClientSession
+from bs4 import BeautifulSoup
+from bs4.element import PageElement, Tag
+
+from .cache import async_cache
+
+log = logging.getLogger(__name__)
+
+UNWANTED_SIGNATURE_SYMBOLS_RE = re.compile(r"\[source]|\\\\|¶")
+SEARCH_END_TAG_ATTRS = (
+    "data",
+    "function",
+    "class",
+    "exception",
+    "seealso",
+    "section",
+    "rubric",
+    "sphinxsidebar",
+)
+
+
+def parse_module_symbol(heading: PageElement) -> Optional[Tuple[None, str]]:
+    """Get page content from the headerlink up to a table or a tag with its class in `SEARCH_END_TAG_ATTRS`."""
+    start_tag = heading.find("a", attrs={"class": "headerlink"})
+    if start_tag is None:
+        return None
+
+    description = find_all_children_until_tag(start_tag, _match_end_tag)
+    if description is None:
+        return None
+
+    return None, description
+
+
+def parse_symbol(heading: PageElement, html: str) -> Tuple[List[str], str]:
+    """
+    Parse the signatures and description of a symbol.
+
+    Collects up to 3 signatures from dt tags and a description from their sibling dd tag.
+    """
+    signatures = []
+    description_element = heading.find_next_sibling("dd")
+    description_pos = html.find(str(description_element))
+    description = find_all_children_until_tag(description_element, tag_filter=("dt", "dl"))
+
+    for element in (
+            *reversed(heading.find_previous_siblings("dt", limit=2)),
+            heading,
+            *heading.find_next_siblings("dt", limit=2),
+    )[-3:]:
+        signature = UNWANTED_SIGNATURE_SYMBOLS_RE.sub("", element.text)
+
+        if signature and html.find(str(element)) < description_pos:
+            signatures.append(signature)
+
+    return signatures, description
+
+
+def find_all_children_until_tag(
+        start_element: PageElement,
+        tag_filter: Union[Tuple[str, ...], Callable[[Tag], bool]]
+) -> Optional[str]:
+    """
+    Get all direct children until a child matching `tag_filter` is found.
+
+    `tag_filter` can be either a tuple of string names to check against,
+    or a filtering callable that's applied to the tags.
+    """
+    text = ""
+
+    for element in start_element.find_next().find_next_siblings():
+        if isinstance(tag_filter, tuple):
+            if element.name in tag_filter:
+                break
+        elif tag_filter(element):
+            break
+        text += str(element)
+
+    return text
+
+
+def truncate_markdown(markdown: str, permalink: str, max_length: int) -> str:
+    """
+    Truncate `markdown` to be at most `max_length` characters.
+
+    The markdown string is searched for substrings to cut at, to keep its structure,
+    but if none are found the string is simply sliced.
+    """
+    if len(markdown) > max_length:
+        shortened = markdown[:max_length]
+        description_cutoff = shortened.rfind('\n\n', 100)
+        if description_cutoff == -1:
+            # Search the shortened version for cutoff points in decreasing desirability,
+            # cutoff at 1000 if none are found.
+            for string in (". ", ", ", ",", " "):
+                description_cutoff = shortened.rfind(string)
+                if description_cutoff != -1:
+                    break
+            else:
+                description_cutoff = max_length
+        markdown = markdown[:description_cutoff]
+
+        # If there is an incomplete code block, cut it out
+        if markdown.count("```") % 2:
+            codeblock_start = markdown.rfind('```py')
+            markdown = markdown[:codeblock_start].rstrip()
+        markdown += f"... [read more]({permalink})"
+    return markdown
+
+
+@async_cache(arg_offset=1)
+async def get_soup_from_url(http_session: ClientSession, url: str) -> BeautifulSoup:
+    """Create a BeautifulSoup object from the HTML data in `url` with the head tag removed."""
+    log.trace(f"Sending a request to {url}.")
+    async with http_session.get(url) as response:
+        soup = BeautifulSoup(await response.text(encoding="utf8"), 'lxml')
+    soup.find("head").decompose()  # the head contains no useful data so we can remove it
+    return soup
+
+
+def _match_end_tag(tag: Tag) -> bool:
+    """Matches `tag` if its class value is in `SEARCH_END_TAG_ATTRS` or the tag is table."""
+    for attr in SEARCH_END_TAG_ATTRS:
+        if attr in tag.get("class", ()):
+            return True
+
+    return tag.name == "table"
-- 
cgit v1.2.3
From 4560f0f89b52cfcb8b18abeb1efa707c334a86d4 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Mon, 20 Jul 2020 02:28:25 +0200
Subject: Remove permalink from truncated markdown.
The permalink serves no functional purpose in the embed,
as it is already included in the title. But it does
add the complexity of passing in the url to the parser.
---
 bot/cogs/doc/cog.py     | 2 +-
 bot/cogs/doc/parsing.py | 4 ++--
 2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/bot/cogs/doc/cog.py b/bot/cogs/doc/cog.py
index 4e4f3b737..36fbe9010 100644
--- a/bot/cogs/doc/cog.py
+++ b/bot/cogs/doc/cog.py
@@ -270,7 +270,7 @@ class DocCog(commands.Cog):
         self.bot.stats.incr(f"doc_fetches.{symbol_obj.package.lower()}")
         signatures = scraped_html[0]
         permalink = symbol_obj.url
-        description = truncate_markdown(markdownify(scraped_html[1], url=permalink), permalink, 1000)
+        description = truncate_markdown(markdownify(scraped_html[1], url=permalink), 1000)
         description = WHITESPACE_AFTER_NEWLINES_RE.sub('', description)
         if signatures is None:
             # If symbol is a module, don't show signature.
diff --git a/bot/cogs/doc/parsing.py b/bot/cogs/doc/parsing.py
index 010826a96..3b79e0a93 100644
--- a/bot/cogs/doc/parsing.py
+++ b/bot/cogs/doc/parsing.py
@@ -83,7 +83,7 @@ def find_all_children_until_tag(
     return text
 
 
-def truncate_markdown(markdown: str, permalink: str, max_length: int) -> str:
+def truncate_markdown(markdown: str, max_length: int) -> str:
     """
     Truncate `markdown` to be at most `max_length` characters.
 
@@ -108,7 +108,7 @@ def truncate_markdown(markdown: str, permalink: str, max_length: int) -> str:
         if markdown.count("```") % 2:
             codeblock_start = markdown.rfind('```py')
             markdown = markdown[:codeblock_start].rstrip()
-        markdown += f"... [read more]({permalink})"
+        markdown += "... read more"
     return markdown
 
 
-- 
cgit v1.2.3
From cecd2c8e320a2a0ff0095cd1fa197552d43c6684 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Mon, 20 Jul 2020 02:31:56 +0200
Subject: Simplify cutoff text.
"read more" seemed out of place with no permalink over it.
---
 bot/cogs/doc/parsing.py | 7 ++++---
 1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/bot/cogs/doc/parsing.py b/bot/cogs/doc/parsing.py
index 3b79e0a93..994124e92 100644
--- a/bot/cogs/doc/parsing.py
+++ b/bot/cogs/doc/parsing.py
@@ -1,5 +1,6 @@
 import logging
 import re
+import string
 from typing import Callable, List, Optional, Tuple, Union
 
 from aiohttp import ClientSession
@@ -96,8 +97,8 @@ def truncate_markdown(markdown: str, max_length: int) -> str:
         if description_cutoff == -1:
             # Search the shortened version for cutoff points in decreasing desirability,
             # cutoff at 1000 if none are found.
-            for string in (". ", ", ", ",", " "):
-                description_cutoff = shortened.rfind(string)
+            for cutoff_string in (". ", ", ", ",", " "):
+                description_cutoff = shortened.rfind(cutoff_string)
                 if description_cutoff != -1:
                     break
             else:
@@ -108,7 +109,7 @@ def truncate_markdown(markdown: str, max_length: int) -> str:
         if markdown.count("```") % 2:
             codeblock_start = markdown.rfind('```py')
             markdown = markdown[:codeblock_start].rstrip()
-        markdown += "... read more"
+        markdown = markdown.rstrip(string.punctuation) + "..."
     return markdown
 
 
-- 
cgit v1.2.3
From 2b24579b49ced873e05e375051bbbb4ec2855b12 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Mon, 20 Jul 2020 03:55:31 +0200
Subject: Add function for finding tags until a matching tag
This will allow flexibility in the future when collecting tags
for the description and signature of symbols.
The base is a function which accepts a callable which is called and
iterated over, but 3 names with a partial function that has the callable
supplied are provided to keep the outside interface neater.
---
 bot/cogs/doc/parsing.py | 35 +++++++++++++++++++++++++++++++++++
 1 file changed, 35 insertions(+)
diff --git a/bot/cogs/doc/parsing.py b/bot/cogs/doc/parsing.py
index 994124e92..5e5a5be66 100644
--- a/bot/cogs/doc/parsing.py
+++ b/bot/cogs/doc/parsing.py
@@ -1,6 +1,7 @@
 import logging
 import re
 import string
+from functools import partial
 from typing import Callable, List, Optional, Tuple, Union
 
 from aiohttp import ClientSession
@@ -24,6 +25,40 @@ SEARCH_END_TAG_ATTRS = (
 )
 
 
+def find_elements_until_tag(
+        start_element: PageElement,
+        tag_filter: Union[Tuple[str, ...], Callable[[Tag], bool]],
+        *,
+        func: Callable,
+        limit: int = None,
+) -> List[str]:
+    """
+    Get all tags until a tag matching `tag_filter` is found.
+
+    `tag_filter` can be either a tuple of string names to check against,
+    or a filtering t.Callable that's applied to the tags.
+
+    `func` takes in a BeautifulSoup unbound method for finding multiple elements, such as `BeautifulSoup.find_all`.
+    That method is then iterated over and all tags until the matching tag are added to the return list as strings.
+    """
+    elements = []
+
+    for element in func(start_element, limit=limit):
+        if isinstance(tag_filter, tuple):
+            if element.name in tag_filter:
+                break
+        elif tag_filter(element):
+            break
+        elements.append(str(element))
+
+    return elements
+
+
+find_next_children_until_tag = partial(find_elements_until_tag, func=partial(BeautifulSoup.find_all, recursive=False))
+find_next_siblings_until_tag = partial(find_elements_until_tag, func=BeautifulSoup.find_next_siblings)
+find_previous_siblings_until_tag = partial(find_elements_until_tag, func=BeautifulSoup.find_previous_siblings)
+
+
 def parse_module_symbol(heading: PageElement) -> Optional[Tuple[None, str]]:
     """Get page content from the headerlink up to a table or a tag with its class in `SEARCH_END_TAG_ATTRS`."""
     start_tag = heading.find("a", attrs={"class": "headerlink"})
-- 
cgit v1.2.3
From 9f78dbafc3bc532bbfb5ffa0ef110fdeb0c3e8a5 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Mon, 20 Jul 2020 03:57:27 +0200
Subject: Simplify module parsing method.
Instead of returning None and multiple values, the method now
only returns the string of the description.
Previously the parsing returned None and quit
when appropriate tags for shortening the description
were not found, but the new implementation simply defaults to the
provided start tag if a better alternative is not found.
---
 bot/cogs/doc/parsing.py | 19 ++++++++++---------
 1 file changed, 10 insertions(+), 9 deletions(-)
diff --git a/bot/cogs/doc/parsing.py b/bot/cogs/doc/parsing.py
index 5e5a5be66..368feeb68 100644
--- a/bot/cogs/doc/parsing.py
+++ b/bot/cogs/doc/parsing.py
@@ -59,17 +59,18 @@ find_next_siblings_until_tag = partial(find_elements_until_tag, func=BeautifulSo
 find_previous_siblings_until_tag = partial(find_elements_until_tag, func=BeautifulSoup.find_previous_siblings)
 
 
-def parse_module_symbol(heading: PageElement) -> Optional[Tuple[None, str]]:
-    """Get page content from the headerlink up to a table or a tag with its class in `SEARCH_END_TAG_ATTRS`."""
-    start_tag = heading.find("a", attrs={"class": "headerlink"})
-    if start_tag is None:
-        return None
+def get_module_description(start_element: PageElement) -> Optional[str]:
+    """
+    Get page content to a table or a tag with its class in `SEARCH_END_TAG_ATTRS`.
 
-    description = find_all_children_until_tag(start_tag, _match_end_tag)
-    if description is None:
-        return None
+    A headerlink a tag is attempted to be found to skip repeating the module name in the description,
+    if it's found it's used as the tag to search from instead of the `start_element`.
+    """
+    header = start_element.find("a", attrs={"class": "headerlink"})
+    start_tag = header.parent if header is not None else start_element
+    description = "".join(str(tag) for tag in find_next_siblings_until_tag(start_tag, _match_end_tag))
 
-    return None, description
+    return description
 
 
 def parse_symbol(heading: PageElement, html: str) -> Tuple[List[str], str]:
-- 
cgit v1.2.3
From 082867253cd19c70516102a3d4972da6d501ff6f Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Mon, 20 Jul 2020 17:35:07 +0200
Subject: Create a function for collecting signatures.
By getting the signatures without the description we get more
flexibility of parsing different symbol groups and decouple the logic
from the description which can be parsed directly with the new
`find_elements_until_tag` based function.
---
 bot/cogs/doc/parsing.py | 46 ++++++++++------------------------------------
 1 file changed, 10 insertions(+), 36 deletions(-)
diff --git a/bot/cogs/doc/parsing.py b/bot/cogs/doc/parsing.py
index 368feeb68..5b60f1609 100644
--- a/bot/cogs/doc/parsing.py
+++ b/bot/cogs/doc/parsing.py
@@ -73,51 +73,25 @@ def get_module_description(start_element: PageElement) -> Optional[str]:
     return description
 
 
-def parse_symbol(heading: PageElement, html: str) -> Tuple[List[str], str]:
+def get_signatures(start_signature: PageElement) -> List[str]:
     """
-    Parse the signatures and description of a symbol.
+    Collect up to 3 signatures from dt tags around the `start_signature` dt tag.
 
-    Collects up to 3 signatures from dt tags and a description from their sibling dd tag.
+    First the signatures under the `start_signature` are included;
+    if less than 2 are found, tags above the start signature are added to the result if any are present.
     """
     signatures = []
-    description_element = heading.find_next_sibling("dd")
-    description_pos = html.find(str(description_element))
-    description = find_all_children_until_tag(description_element, tag_filter=("dt", "dl"))
-
     for element in (
-            *reversed(heading.find_previous_siblings("dt", limit=2)),
-            heading,
-            *heading.find_next_siblings("dt", limit=2),
+            *reversed(find_previous_siblings_until_tag(start_signature, ("dd",), limit=2)),
+            start_signature,
+            *find_next_siblings_until_tag(start_signature, ("dd",), limit=2),
     )[-3:]:
-        signature = UNWANTED_SIGNATURE_SYMBOLS_RE.sub("", element.text)
+        signature = UNWANTED_SIGNATURE_SYMBOLS_RE.sub("", element)
 
-        if signature and html.find(str(element)) < description_pos:
+        if signature:
             signatures.append(signature)
 
-    return signatures, description
-
-
-def find_all_children_until_tag(
-        start_element: PageElement,
-        tag_filter: Union[Tuple[str, ...], Callable[[Tag], bool]]
-) -> Optional[str]:
-    """
-    Get all direct children until a child matching `tag_filter` is found.
-
-    `tag_filter` can be either a tuple of string names to check against,
-    or a filtering callable that's applied to the tags.
-    """
-    text = ""
-
-    for element in start_element.find_next().find_next_siblings():
-        if isinstance(tag_filter, tuple):
-            if element.name in tag_filter:
-                break
-        elif tag_filter(element):
-            break
-        text += str(element)
-
-    return text
+    return signatures
 
 
 def truncate_markdown(markdown: str, max_length: int) -> str:
-- 
cgit v1.2.3
From caedfb0c16bc98eb94d723caff42dfe0799f8f17 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Wed, 22 Jul 2020 01:38:00 +0200
Subject: Remove conversion to str when finding elements.
The tags need to be processed down the line,
which is not viable on strings.
---
 bot/cogs/doc/parsing.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/bot/cogs/doc/parsing.py b/bot/cogs/doc/parsing.py
index 5b60f1609..acf3a0804 100644
--- a/bot/cogs/doc/parsing.py
+++ b/bot/cogs/doc/parsing.py
@@ -31,7 +31,7 @@ def find_elements_until_tag(
         *,
         func: Callable,
         limit: int = None,
-) -> List[str]:
+) -> List[Tag]:
     """
     Get all tags until a tag matching `tag_filter` is found.
 
@@ -49,7 +49,7 @@ def find_elements_until_tag(
                 break
         elif tag_filter(element):
             break
-        elements.append(str(element))
+        elements.append(element)
 
     return elements
 
-- 
cgit v1.2.3
From 1c997846f282f76d17700f0f16c0a0abb5c49a30 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Wed, 22 Jul 2020 01:39:43 +0200
Subject: Fix handling of elements when fetching signatures.
After the change to `find_elements_until_tag`,
the text contentsneed to be extracted from the tags
instead of passing them directly to re.
---
 bot/cogs/doc/parsing.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/bot/cogs/doc/parsing.py b/bot/cogs/doc/parsing.py
index acf3a0804..725fe47cd 100644
--- a/bot/cogs/doc/parsing.py
+++ b/bot/cogs/doc/parsing.py
@@ -86,7 +86,7 @@ def get_signatures(start_signature: PageElement) -> List[str]:
             start_signature,
             *find_next_siblings_until_tag(start_signature, ("dd",), limit=2),
     )[-3:]:
-        signature = UNWANTED_SIGNATURE_SYMBOLS_RE.sub("", element)
+        signature = UNWANTED_SIGNATURE_SYMBOLS_RE.sub("", element.text)
 
         if signature:
             signatures.append(signature)
-- 
cgit v1.2.3
From e10def8a3d79dffd8cc53acd6b30fa43741d140c Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Wed, 22 Jul 2020 02:03:31 +0200
Subject: Move DocMarkdownConverter to parsing.
---
 bot/cogs/doc/cog.py     | 34 ----------------------------------
 bot/cogs/doc/parsing.py | 34 ++++++++++++++++++++++++++++++++++
 2 files changed, 34 insertions(+), 34 deletions(-)
diff --git a/bot/cogs/doc/cog.py b/bot/cogs/doc/cog.py
index 36fbe9010..a7dcd9020 100644
--- a/bot/cogs/doc/cog.py
+++ b/bot/cogs/doc/cog.py
@@ -64,40 +64,6 @@ class DocItem(NamedTuple):
     group: str
 
 
-class DocMarkdownConverter(MarkdownConverter):
-    """Subclass markdownify's MarkdownCoverter to provide custom conversion methods."""
-
-    def __init__(self, *, page_url: str, **options):
-        super().__init__(**options)
-        self.page_url = page_url
-
-    def convert_code(self, el: PageElement, text: str) -> str:
-        """Undo `markdownify`s underscore escaping."""
-        return f"`{text}`".replace('\\', '')
-
-    def convert_pre(self, el: PageElement, text: str) -> str:
-        """Wrap any codeblocks in `py` for syntax highlighting."""
-        code = ''.join(el.strings)
-        return f"```py\n{code}```"
-
-    def convert_a(self, el: PageElement, text: str) -> str:
-        """Resolve relative URLs to `self.page_url`."""
-        el["href"] = urljoin(self.page_url, el["href"])
-        return super().convert_a(el, text)
-
-    def convert_p(self, el: PageElement, text: str) -> str:
-        """Include only one newline instead of two when the parent is a li tag."""
-        parent = el.parent
-        if parent is not None and parent.name == "li":
-            return f"{text}\n"
-        return super().convert_p(el, text)
-
-
-def markdownify(html: str, *, url: str = "") -> str:
-    """Create a DocMarkdownConverter object from the input html."""
-    return DocMarkdownConverter(bullets='•', page_url=url).convert(html)
-
-
 class InventoryURL(commands.Converter):
     """
     Represents an Intersphinx inventory URL.
diff --git a/bot/cogs/doc/parsing.py b/bot/cogs/doc/parsing.py
index 725fe47cd..8f6688bd2 100644
--- a/bot/cogs/doc/parsing.py
+++ b/bot/cogs/doc/parsing.py
@@ -25,6 +25,40 @@ SEARCH_END_TAG_ATTRS = (
 )
 
 
+class DocMarkdownConverter(MarkdownConverter):
+    """Subclass markdownify's MarkdownCoverter to provide custom conversion methods."""
+
+    def __init__(self, *, page_url: str, **options):
+        super().__init__(**options)
+        self.page_url = page_url
+
+    def convert_code(self, el: PageElement, text: str) -> str:
+        """Undo `markdownify`s underscore escaping."""
+        return f"`{text}`".replace('\\', '')
+
+    def convert_pre(self, el: PageElement, text: str) -> str:
+        """Wrap any codeblocks in `py` for syntax highlighting."""
+        code = ''.join(el.strings)
+        return f"```py\n{code}```"
+
+    def convert_a(self, el: PageElement, text: str) -> str:
+        """Resolve relative URLs to `self.page_url`."""
+        el["href"] = urljoin(self.page_url, el["href"])
+        return super().convert_a(el, text)
+
+    def convert_p(self, el: PageElement, text: str) -> str:
+        """Include only one newline instead of two when the parent is a li tag."""
+        parent = el.parent
+        if parent is not None and parent.name == "li":
+            return f"{text}\n"
+        return super().convert_p(el, text)
+
+
+def markdownify(html: str, *, url: str = "") -> str:
+    """Create a DocMarkdownConverter object from the input html."""
+    return DocMarkdownConverter(bullets='•', page_url=url).convert(html)
+
+
 def find_elements_until_tag(
         start_element: PageElement,
         tag_filter: Union[Tuple[str, ...], Callable[[Tag], bool]],
-- 
cgit v1.2.3
From 6795a7f05e3720f375a9195182b996a14d754ea0 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Wed, 22 Jul 2020 02:06:50 +0200
Subject: Fix ordered list indices in markdown converter.
markdownify relies on the parent tag's index method,
which goes through all of its contents, if there is anything else
in the contents apart from the li tags, those indices are then shifted.
---
 bot/cogs/doc/parsing.py | 18 ++++++++++++++++++
 1 file changed, 18 insertions(+)
diff --git a/bot/cogs/doc/parsing.py b/bot/cogs/doc/parsing.py
index 8f6688bd2..25001b83d 100644
--- a/bot/cogs/doc/parsing.py
+++ b/bot/cogs/doc/parsing.py
@@ -3,10 +3,12 @@ import re
 import string
 from functools import partial
 from typing import Callable, List, Optional, Tuple, Union
+from urllib.parse import urljoin
 
 from aiohttp import ClientSession
 from bs4 import BeautifulSoup
 from bs4.element import PageElement, Tag
+from markdownify import MarkdownConverter
 
 from .cache import async_cache
 
@@ -32,6 +34,22 @@ class DocMarkdownConverter(MarkdownConverter):
         super().__init__(**options)
         self.page_url = page_url
 
+    def convert_li(self, el: PageElement, text: str) -> str:
+        """Fix markdownify's erroneous indexing in ol tags."""
+        parent = el.parent
+        if parent is not None and parent.name == 'ol':
+            li_tags = parent.find_all("li")
+            bullet = '%s.' % (li_tags.index(el)+1)
+        else:
+            depth = -1
+            while el:
+                if el.name == 'ul':
+                    depth += 1
+                el = el.parent
+            bullets = self.options['bullets']
+            bullet = bullets[depth % len(bullets)]
+        return '%s %s\n' % (bullet, text or '')
+
     def convert_code(self, el: PageElement, text: str) -> str:
         """Undo `markdownify`s underscore escaping."""
         return f"`{text}`".replace('\\', '')
-- 
cgit v1.2.3
From 4e9ffb210f6a8f0184ac97cb16703777cc1e0ca0 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Wed, 22 Jul 2020 02:34:11 +0200
Subject: Create a function for getting the result markdown.
---
 bot/cogs/doc/parsing.py | 21 +++++++++++++++++++++
 1 file changed, 21 insertions(+)
diff --git a/bot/cogs/doc/parsing.py b/bot/cogs/doc/parsing.py
index 25001b83d..8756e0694 100644
--- a/bot/cogs/doc/parsing.py
+++ b/bot/cogs/doc/parsing.py
@@ -1,6 +1,7 @@
 import logging
 import re
 import string
+import textwrap
 from functools import partial
 from typing import Callable, List, Optional, Tuple, Union
 from urllib.parse import urljoin
@@ -15,6 +16,8 @@ from .cache import async_cache
 log = logging.getLogger(__name__)
 
 UNWANTED_SIGNATURE_SYMBOLS_RE = re.compile(r"\[source]|\\\\|¶")
+WHITESPACE_AFTER_NEWLINES_RE = re.compile(r"(?<=\n\n)(\s+)")
+
 SEARCH_END_TAG_ATTRS = (
     "data",
     "function",
@@ -175,6 +178,24 @@ def truncate_markdown(markdown: str, max_length: int) -> str:
     return markdown
 
 
+def _parse_into_markdown(signatures: Optional[List[str]], description: str, url: str) -> str:
+    """
+    Create a markdown string with the signatures at the top, and the converted html description below them.
+
+    The signatures are wrapped in python codeblocks, separated from the description by a newline.
+    The result string is truncated to be max 1000 symbols long.
+    """
+    description = truncate_markdown(markdownify(description, url=url), 1000)
+    description = WHITESPACE_AFTER_NEWLINES_RE.sub('', description)
+    if signatures is not None:
+        formatted_markdown = "".join(f"```py\n{textwrap.shorten(signature, 500)}```" for signature in signatures)
+    else:
+        formatted_markdown = ""
+    formatted_markdown += f"\n{description}"
+
+    return formatted_markdown
+
+
 @async_cache(arg_offset=1)
 async def get_soup_from_url(http_session: ClientSession, url: str) -> BeautifulSoup:
     """Create a BeautifulSoup object from the HTML data in `url` with the head tag removed."""
-- 
cgit v1.2.3
From f562c4b4551caa8ed3710ac5e9841150cb8a2492 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Wed, 22 Jul 2020 02:35:13 +0200
Subject: Create the parsing interface function.
Other functions from the module are not intended to be used directly,
with the interface of it being the added function which accepts the
symbol and calls internals.
All other names except imports and log had the underscore prefix added
to accommodate this.
---
 bot/cogs/doc/parsing.py | 92 ++++++++++++++++++++++++++++++++++++++-----------
 1 file changed, 71 insertions(+), 21 deletions(-)
diff --git a/bot/cogs/doc/parsing.py b/bot/cogs/doc/parsing.py
index 8756e0694..a2c6564b3 100644
--- a/bot/cogs/doc/parsing.py
+++ b/bot/cogs/doc/parsing.py
@@ -3,7 +3,7 @@ import re
 import string
 import textwrap
 from functools import partial
-from typing import Callable, List, Optional, Tuple, Union
+from typing import Callable, List, Optional, TYPE_CHECKING, Tuple, Union
 from urllib.parse import urljoin
 
 from aiohttp import ClientSession
@@ -12,13 +12,15 @@ from bs4.element import PageElement, Tag
 from markdownify import MarkdownConverter
 
 from .cache import async_cache
+if TYPE_CHECKING:
+    from .cog import DocItem
 
 log = logging.getLogger(__name__)
 
-UNWANTED_SIGNATURE_SYMBOLS_RE = re.compile(r"\[source]|\\\\|¶")
-WHITESPACE_AFTER_NEWLINES_RE = re.compile(r"(?<=\n\n)(\s+)")
+_UNWANTED_SIGNATURE_SYMBOLS_RE = re.compile(r"\[source]|\\\\|¶")
+_WHITESPACE_AFTER_NEWLINES_RE = re.compile(r"(?<=\n\n)(\s+)")
 
-SEARCH_END_TAG_ATTRS = (
+_SEARCH_END_TAG_ATTRS = (
     "data",
     "function",
     "class",
@@ -29,8 +31,17 @@ SEARCH_END_TAG_ATTRS = (
     "sphinxsidebar",
 )
 
+_NO_SIGNATURE_GROUPS = {
+    "attribute",
+    "envvar",
+    "setting",
+    "tempaltefilter",
+    "templatetag",
+    "term",
+}
 
-class DocMarkdownConverter(MarkdownConverter):
+
+class _DocMarkdownConverter(MarkdownConverter):
     """Subclass markdownify's MarkdownCoverter to provide custom conversion methods."""
 
     def __init__(self, *, page_url: str, **options):
@@ -75,12 +86,12 @@ class DocMarkdownConverter(MarkdownConverter):
         return super().convert_p(el, text)
 
 
-def markdownify(html: str, *, url: str = "") -> str:
+def _markdownify(html: str, *, url: str = "") -> str:
     """Create a DocMarkdownConverter object from the input html."""
-    return DocMarkdownConverter(bullets='•', page_url=url).convert(html)
+    return _DocMarkdownConverter(bullets='•', page_url=url).convert(html)
 
 
-def find_elements_until_tag(
+def _find_elements_until_tag(
         start_element: PageElement,
         tag_filter: Union[Tuple[str, ...], Callable[[Tag], bool]],
         *,
@@ -109,9 +120,9 @@ def find_elements_until_tag(
     return elements
 
 
-find_next_children_until_tag = partial(find_elements_until_tag, func=partial(BeautifulSoup.find_all, recursive=False))
-find_next_siblings_until_tag = partial(find_elements_until_tag, func=BeautifulSoup.find_next_siblings)
-find_previous_siblings_until_tag = partial(find_elements_until_tag, func=BeautifulSoup.find_previous_siblings)
+_find_next_children_until_tag = partial(_find_elements_until_tag, func=partial(BeautifulSoup.find_all, recursive=False))
+_find_next_siblings_until_tag = partial(_find_elements_until_tag, func=BeautifulSoup.find_next_siblings)
+_find_previous_siblings_until_tag = partial(_find_elements_until_tag, func=BeautifulSoup.find_previous_siblings)
 
 
 def get_module_description(start_element: PageElement) -> Optional[str]:
@@ -123,12 +134,19 @@ def get_module_description(start_element: PageElement) -> Optional[str]:
     """
     header = start_element.find("a", attrs={"class": "headerlink"})
     start_tag = header.parent if header is not None else start_element
-    description = "".join(str(tag) for tag in find_next_siblings_until_tag(start_tag, _match_end_tag))
+    description = "".join(str(tag) for tag in _find_next_siblings_until_tag(start_tag, _match_end_tag))
 
     return description
 
 
-def get_signatures(start_signature: PageElement) -> List[str]:
+def _get_symbol_description(symbol: PageElement) -> str:
+    """Get the string contents of the next dd tag, up to a dt or a dl tag."""
+    description_tag = symbol.find_next("dd")
+    description_contents = _find_next_children_until_tag(description_tag, ("dt", "dl"))
+    return "".join(str(tag) for tag in description_contents)
+
+
+def _get_signatures(start_signature: PageElement) -> List[str]:
     """
     Collect up to 3 signatures from dt tags around the `start_signature` dt tag.
 
@@ -137,11 +155,11 @@ def get_signatures(start_signature: PageElement) -> List[str]:
     """
     signatures = []
     for element in (
-            *reversed(find_previous_siblings_until_tag(start_signature, ("dd",), limit=2)),
+            *reversed(_find_previous_siblings_until_tag(start_signature, ("dd",), limit=2)),
             start_signature,
-            *find_next_siblings_until_tag(start_signature, ("dd",), limit=2),
+            *_find_next_siblings_until_tag(start_signature, ("dd",), limit=2),
     )[-3:]:
-        signature = UNWANTED_SIGNATURE_SYMBOLS_RE.sub("", element.text)
+        signature = _UNWANTED_SIGNATURE_SYMBOLS_RE.sub("", element.text)
 
         if signature:
             signatures.append(signature)
@@ -149,7 +167,7 @@ def get_signatures(start_signature: PageElement) -> List[str]:
     return signatures
 
 
-def truncate_markdown(markdown: str, max_length: int) -> str:
+def _truncate_markdown(markdown: str, max_length: int) -> str:
     """
     Truncate `markdown` to be at most `max_length` characters.
 
@@ -185,8 +203,8 @@ def _parse_into_markdown(signatures: Optional[List[str]], description: str, url:
     The signatures are wrapped in python codeblocks, separated from the description by a newline.
     The result string is truncated to be max 1000 symbols long.
     """
-    description = truncate_markdown(markdownify(description, url=url), 1000)
-    description = WHITESPACE_AFTER_NEWLINES_RE.sub('', description)
+    description = _truncate_markdown(_markdownify(description, url=url), 1000)
+    description = _WHITESPACE_AFTER_NEWLINES_RE.sub('', description)
     if signatures is not None:
         formatted_markdown = "".join(f"```py\n{textwrap.shorten(signature, 500)}```" for signature in signatures)
     else:
@@ -197,7 +215,7 @@ def _parse_into_markdown(signatures: Optional[List[str]], description: str, url:
 
 
 @async_cache(arg_offset=1)
-async def get_soup_from_url(http_session: ClientSession, url: str) -> BeautifulSoup:
+async def _get_soup_from_url(http_session: ClientSession, url: str) -> BeautifulSoup:
     """Create a BeautifulSoup object from the HTML data in `url` with the head tag removed."""
     log.trace(f"Sending a request to {url}.")
     async with http_session.get(url) as response:
@@ -208,8 +226,40 @@ async def get_soup_from_url(http_session: ClientSession, url: str) -> BeautifulS
 
 def _match_end_tag(tag: Tag) -> bool:
     """Matches `tag` if its class value is in `SEARCH_END_TAG_ATTRS` or the tag is table."""
-    for attr in SEARCH_END_TAG_ATTRS:
+    for attr in _SEARCH_END_TAG_ATTRS:
         if attr in tag.get("class", ()):
             return True
 
     return tag.name == "table"
+
+
+async def get_symbol_markdown(http_session: ClientSession, symbol_data: "DocItem") -> str:
+    """
+    Return parsed markdown of the passed symbol, truncated to 1000 characters.
+
+    A request through `http_session` is made to the url associated with `symbol_data` for the html contents;
+    the contents are then parsed depending on what group the symbol belongs to.
+    """
+    if "#" in symbol_data.url:
+        request_url, symbol_id = symbol_data.url.rsplit('#')
+    else:
+        request_url = symbol_data.url
+        symbol_id = None
+
+    soup = await _get_soup_from_url(http_session, request_url)
+    symbol_heading = soup.find(id=symbol_id)
+
+    # Handle doc symbols as modules, because they either link to the page of a module,
+    # or don't contain any useful info to be parsed.
+    signature = None
+    if symbol_data.group in {"module", "doc"}:
+        description = get_module_description(symbol_heading)
+
+    elif symbol_data.group in _NO_SIGNATURE_GROUPS:
+        description = _get_symbol_description(symbol_heading)
+
+    else:
+        signature = _get_signatures(symbol_heading)
+        description = _get_symbol_description(symbol_heading)
+
+    return _parse_into_markdown(signature, description, symbol_data.url)
-- 
cgit v1.2.3
From 6f4731714aa9df086ec287f768556a4c4443b635 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Wed, 22 Jul 2020 02:50:49 +0200
Subject: Change DocCog to use the new parsing module fully.
The parsing module provides an interface for fetching the markdown
from the symbol data provided to it. Because it's now fully done
in an another module we can remove the needless parts from the cog.
---
 bot/cogs/doc/cog.py | 69 ++++++-----------------------------------------------
 1 file changed, 7 insertions(+), 62 deletions(-)
diff --git a/bot/cogs/doc/cog.py b/bot/cogs/doc/cog.py
index a7dcd9020..6cd066f1b 100644
--- a/bot/cogs/doc/cog.py
+++ b/bot/cogs/doc/cog.py
@@ -3,17 +3,13 @@ import functools
 import logging
 import re
 import sys
-import textwrap
 from collections import OrderedDict
 from contextlib import suppress
 from types import SimpleNamespace
-from typing import Dict, NamedTuple, Optional, Tuple
-from urllib.parse import urljoin
+from typing import Dict, NamedTuple, Optional
 
 import discord
-from bs4.element import PageElement
 from discord.ext import commands
-from markdownify import MarkdownConverter
 from requests import ConnectTimeout, ConnectionError, HTTPError
 from sphinx.ext import intersphinx
 from urllib3.exceptions import ProtocolError
@@ -25,7 +21,7 @@ from bot.decorators import with_role
 from bot.pagination import LinePaginator
 from bot.utils.messages import wait_for_deletion
 from .cache import async_cache
-from .parsing import get_soup_from_url, parse_module_symbol, parse_symbol, truncate_markdown
+from .parsing import get_symbol_markdown
 
 log = logging.getLogger(__name__)
 logging.getLogger('urllib3').setLevel(logging.WARNING)
@@ -187,40 +183,6 @@ class DocCog(commands.Cog):
         ]
         await asyncio.gather(*coros)
 
-    async def get_symbol_html(self, symbol: str) -> Optional[Tuple[list, str]]:
-        """
-        Given a Python symbol, return its signature and description.
-
-        The first tuple element is the signature of the given symbol as a markup-free string, and
-        the second tuple element is the description of the given symbol with HTML markup included.
-
-        If the given symbol is a module, returns a tuple `(None, str)`
-        else if the symbol could not be found, returns `None`.
-        """
-        symbol_info = self.doc_symbols.get(symbol)
-        if symbol_info is None:
-            return None
-        request_url, symbol_id = symbol_info.url.rsplit('#')
-
-        soup = await get_soup_from_url(self.bot.http_session, request_url)
-        symbol_heading = soup.find(id=symbol_id)
-        search_html = str(soup)
-
-        if symbol_heading is None:
-            return None
-
-        if symbol_info.group == "module":
-            parsed_module = parse_module_symbol(symbol_heading)
-            if parsed_module is None:
-                return [], ""
-            else:
-                signatures, description = parsed_module
-
-        else:
-            signatures, description = parse_symbol(symbol_heading, search_html)
-
-        return signatures, description.replace('¶', '')
-
     @async_cache(arg_offset=1)
     async def get_symbol_embed(self, symbol: str) -> Optional[discord.Embed]:
         """
@@ -228,32 +190,15 @@ class DocCog(commands.Cog):
 
         If the symbol is known, an Embed with documentation about it is returned.
         """
-        scraped_html = await self.get_symbol_html(symbol)
-        if scraped_html is None:
+        symbol_info = self.doc_symbols.get(symbol)
+        if symbol_info is None:
             return None
-
-        symbol_obj = self.doc_symbols[symbol]
-        self.bot.stats.incr(f"doc_fetches.{symbol_obj.package.lower()}")
-        signatures = scraped_html[0]
-        permalink = symbol_obj.url
-        description = truncate_markdown(markdownify(scraped_html[1], url=permalink), 1000)
-        description = WHITESPACE_AFTER_NEWLINES_RE.sub('', description)
-        if signatures is None:
-            # If symbol is a module, don't show signature.
-            embed_description = description
-
-        elif not signatures:
-            # It's some "meta-page", for example:
-            # https://docs.djangoproject.com/en/dev/ref/views/#module-django.views
-            embed_description = "This appears to be a generic page not tied to a specific symbol."
-
-        else:
-            embed_description = "".join(f"```py\n{textwrap.shorten(signature, 500)}```" for signature in signatures)
-            embed_description += f"\n{description}"
+        self.bot.stats.incr(f"doc_fetches.{symbol_info.package.lower()}")
+        embed_description = await get_symbol_markdown(self.bot.http_session, symbol_info)
 
         embed = discord.Embed(
             title=discord.utils.escape_markdown(symbol),
-            url=permalink,
+            url=symbol_info.url,
             description=embed_description
         )
         # Show all symbols with the same name that were renamed in the footer.
-- 
cgit v1.2.3
From e875142a0f937ab190208523ef17068e5988dca3 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Wed, 22 Jul 2020 14:25:47 +0200
Subject: Remove caching from get_symbol_embed.
The web request is already cached, and parsing doesn't much more time,
but without moving the logic around
the cache prevents the stat increase when a symbol is requested.
---
 bot/cogs/doc/cog.py | 1 -
 1 file changed, 1 deletion(-)
diff --git a/bot/cogs/doc/cog.py b/bot/cogs/doc/cog.py
index 6cd066f1b..05cedcaaf 100644
--- a/bot/cogs/doc/cog.py
+++ b/bot/cogs/doc/cog.py
@@ -183,7 +183,6 @@ class DocCog(commands.Cog):
         ]
         await asyncio.gather(*coros)
 
-    @async_cache(arg_offset=1)
     async def get_symbol_embed(self, symbol: str) -> Optional[discord.Embed]:
         """
         Attempt to scrape and fetch the data for the given `symbol`, and build an embed from its contents.
-- 
cgit v1.2.3
From 6731de62e3a3f5d188e73538a718d2b30cc2f442 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Wed, 22 Jul 2020 14:28:07 +0200
Subject: Hold url parts in DocItem separately.
This allows us to save up some memory by not creating unique strings
with the base url repeated between them.
---
 bot/cogs/doc/cog.py | 11 ++++++++---
 1 file changed, 8 insertions(+), 3 deletions(-)
diff --git a/bot/cogs/doc/cog.py b/bot/cogs/doc/cog.py
index 05cedcaaf..bd27dde01 100644
--- a/bot/cogs/doc/cog.py
+++ b/bot/cogs/doc/cog.py
@@ -55,10 +55,16 @@ NOT_FOUND_DELETE_DELAY = RedirectOutput.delete_delay
 class DocItem(NamedTuple):
     """Holds inventory symbol information."""
 
+    base_url: str
+    relative_url: str
     package: str
-    url: str
     group: str
 
+    @property
+    def url(self) -> str:
+        """Return the absolute url to the symbol."""
+        return self.base_url + self.relative_url
+
 
 class InventoryURL(commands.Converter):
     """
@@ -131,7 +137,6 @@ class DocCog(commands.Cog):
             for symbol, (_package_name, _version, relative_doc_url, _) in value.items():
                 if "/" in symbol:
                     continue  # skip unreachable symbols with slashes
-                absolute_doc_url = base_url + relative_doc_url
                 # Intern the group names since they're reused in all the DocItems
                 # to remove unnecessary memory consumption from them being unique objects
                 group_name = sys.intern(group.split(":")[1])
@@ -158,7 +163,7 @@ class DocCog(commands.Cog):
                         symbol = f"{api_package_name}.{symbol}"
                         self.renamed_symbols.add(symbol)
 
-                self.doc_symbols[symbol] = DocItem(api_package_name, absolute_doc_url, group_name)
+                self.doc_symbols[symbol] = DocItem(base_url, relative_doc_url, api_package_name, group_name)
 
         log.trace(f"Fetched inventory for {api_package_name}.")
 
-- 
cgit v1.2.3
From 6ca72a68a75a1e5f56cb6a6ebec5a5b533c77eff Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Wed, 22 Jul 2020 14:52:04 +0200
Subject: Remove paragraph chars from descriptions
---
 bot/cogs/doc/parsing.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/bot/cogs/doc/parsing.py b/bot/cogs/doc/parsing.py
index a2c6564b3..79f3bbf69 100644
--- a/bot/cogs/doc/parsing.py
+++ b/bot/cogs/doc/parsing.py
@@ -262,4 +262,4 @@ async def get_symbol_markdown(http_session: ClientSession, symbol_data: "DocItem
         signature = _get_signatures(symbol_heading)
         description = _get_symbol_description(symbol_heading)
 
-    return _parse_into_markdown(signature, description, symbol_data.url)
+    return _parse_into_markdown(signature, description.replace('¶', ''), symbol_data.url)
-- 
cgit v1.2.3
From 9f4d602bfa02fce088aaed28ee598c116b655683 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Wed, 22 Jul 2020 16:20:48 +0200
Subject: Change ValidPythonIdentifier tests to PackageName.
---
 tests/bot/test_converters.py | 21 ++++++++++-----------
 1 file changed, 10 insertions(+), 11 deletions(-)
diff --git a/tests/bot/test_converters.py b/tests/bot/test_converters.py
index ca8cb6825..a3c071168 100644
--- a/tests/bot/test_converters.py
+++ b/tests/bot/test_converters.py
@@ -10,9 +10,9 @@ from bot.converters import (
     Duration,
     HushDurationConverter,
     ISODateTime,
+    PackageName,
     TagContentConverter,
     TagNameConverter,
-    ValidPythonIdentifier,
 )
 
 
@@ -78,24 +78,23 @@ class ConverterTests(unittest.TestCase):
                 with self.assertRaises(BadArgument, msg=exception_message):
                     asyncio.run(TagNameConverter.convert(self.context, invalid_name))
 
-    def test_valid_python_identifier_for_valid(self):
-        """ValidPythonIdentifier returns valid identifiers unchanged."""
-        test_values = ('foo', 'lemon')
+    def test_package_name_for_valid(self):
+        """PackageName returns valid package names unchanged."""
+        test_values = ('foo', 'le_mon')
 
         for name in test_values:
             with self.subTest(identifier=name):
-                conversion = asyncio.run(ValidPythonIdentifier.convert(self.context, name))
+                conversion = asyncio.run(PackageName.convert(self.context, name))
                 self.assertEqual(name, conversion)
 
-    def test_valid_python_identifier_for_invalid(self):
-        """ValidPythonIdentifier raises the proper exception for invalid identifiers."""
-        test_values = ('nested.stuff', '#####')
+    def test_package_name_for_invalid(self):
+        """PackageName raises the proper exception for invalid package names."""
+        test_values = ('text_with_a_dot.', 'UpperCaseName', "num83r")
 
         for name in test_values:
             with self.subTest(identifier=name):
-                exception_message = f'`{name}` is not a valid Python identifier'
-                with self.assertRaises(BadArgument, msg=exception_message):
-                    asyncio.run(ValidPythonIdentifier.convert(self.context, name))
+                with self.assertRaises(BadArgument):
+                    asyncio.run(PackageName.convert(self.context, name))
 
     def test_duration_converter_for_valid(self):
         """Duration returns the correct `datetime` for valid duration strings."""
-- 
cgit v1.2.3
From 7e367ce4a5df3fbd768c6dce1acc39e786a376ea Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Sat, 25 Jul 2020 03:13:20 +0200
Subject: Ensure all renamed symbols are kept
After the restructure behaviour change in
d790c404ca3dba3843f351d6f42e766956aa73a1, the add to renamed_symbols
was not readded and symbols that only passed the first check were
being missed.
---
 bot/cogs/doc/cog.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/bot/cogs/doc/cog.py b/bot/cogs/doc/cog.py
index bd27dde01..e52ee95c1 100644
--- a/bot/cogs/doc/cog.py
+++ b/bot/cogs/doc/cog.py
@@ -148,6 +148,7 @@ class DocCog(commands.Cog):
                         or any(package in symbol_base_url for package in NO_OVERRIDE_PACKAGES)
                     ):
                         symbol = f"{group_name}.{symbol}"
+                        self.renamed_symbols.add(symbol)
 
                     elif (overridden_symbol_group := self.doc_symbols[symbol].group) in NO_OVERRIDE_GROUPS:
                         overridden_symbol = f"{overridden_symbol_group}.{symbol}"
@@ -158,7 +159,7 @@ class DocCog(commands.Cog):
                         self.renamed_symbols.add(overridden_symbol)
 
                     # If renamed `symbol` already exists, add library name in front to differentiate between them.
-                    if symbol in self.renamed_symbols:
+                    elif symbol in self.renamed_symbols:
                         # Split `package_name` because of packages like Pillow that have spaces in them.
                         symbol = f"{api_package_name}.{symbol}"
                         self.renamed_symbols.add(symbol)
-- 
cgit v1.2.3
From 2cc7ec9e26b013b2967841372898f1f8954d8f8f Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Sun, 26 Jul 2020 15:06:35 +0200
Subject: Parse NavigableStrings in symbol descriptions.
When a symbol, such as [term.numpy](https://matplotlib.org/3.1.1/glossary/index.html#term-numpy) had NavigableStrings as direct
children, they were not included as bs4's SoupStrainer won't include
both strings and tags in its filters.
The implementation goes around the limitation by introducing a new
optional flag, bypassing the default check which skips matching tags
when the `text` argument is present.
---
 bot/cogs/doc/html.py    | 33 +++++++++++++++++++++++++++++++++
 bot/cogs/doc/parsing.py | 36 ++++++++++++++++++++++--------------
 2 files changed, 55 insertions(+), 14 deletions(-)
 create mode 100644 bot/cogs/doc/html.py
diff --git a/bot/cogs/doc/html.py b/bot/cogs/doc/html.py
new file mode 100644
index 000000000..bc705130d
--- /dev/null
+++ b/bot/cogs/doc/html.py
@@ -0,0 +1,33 @@
+from collections.abc import Iterable
+from typing import List, Union
+
+from bs4.element import NavigableString, PageElement, SoupStrainer, Tag
+
+
+class Strainer(SoupStrainer):
+    """Subclass of SoupStrainer to allow matching of both `Tag`s and `NavigableString`s."""
+
+    def __init__(self, *, include_strings: bool, **kwargs):
+        self.include_strings = include_strings
+        super().__init__(**kwargs)
+
+    markup_hint = Union[PageElement, List["markup_hint"]]
+
+    def search(self, markup: markup_hint) -> Union[PageElement, str]:
+        """Extend default SoupStrainer behaviour to allow matching both `Tag`s` and `NavigableString`s."""
+        if isinstance(markup, Iterable) and not isinstance(markup, (Tag, str)):
+            for element in markup:
+                if isinstance(element, NavigableString) and self.search(element):
+                    return element
+        elif isinstance(markup, Tag):
+            # Also include tags while we're searching for strings and tags.
+            if self.include_strings or (not self.text or self.name or self.attrs):
+                return self.search_tag(markup)
+
+        elif isinstance(markup, str):
+            # Let everything through the text filter if we're including strings and tags.
+            text_filter = None if not self.include_strings else True
+            if not self.name and not self.attrs and self._matches(markup, text_filter):
+                return markup
+        else:
+            raise Exception(f"I don't know how to match against a {markup.__class__}")
diff --git a/bot/cogs/doc/parsing.py b/bot/cogs/doc/parsing.py
index 79f3bbf69..050c49447 100644
--- a/bot/cogs/doc/parsing.py
+++ b/bot/cogs/doc/parsing.py
@@ -8,10 +8,11 @@ from urllib.parse import urljoin
 
 from aiohttp import ClientSession
 from bs4 import BeautifulSoup
-from bs4.element import PageElement, Tag
+from bs4.element import NavigableString, PageElement, Tag
 from markdownify import MarkdownConverter
 
 from .cache import async_cache
+from .html import Strainer
 if TYPE_CHECKING:
     from .cog import DocItem
 
@@ -96,25 +97,30 @@ def _find_elements_until_tag(
         tag_filter: Union[Tuple[str, ...], Callable[[Tag], bool]],
         *,
         func: Callable,
+        include_strings: bool = False,
         limit: int = None,
-) -> List[Tag]:
+) -> List[Union[Tag, NavigableString]]:
     """
-    Get all tags until a tag matching `tag_filter` is found.
+    Get all elements up to `limit` or until a tag matching `tag_filter` is found.
 
     `tag_filter` can be either a tuple of string names to check against,
-    or a filtering t.Callable that's applied to the tags.
+    or a filtering callable that's applied to tags.
+
+    When `include_strings` is True, `NavigableString`s from the document will be included in the result along `Tag`s.
 
     `func` takes in a BeautifulSoup unbound method for finding multiple elements, such as `BeautifulSoup.find_all`.
-    That method is then iterated over and all tags until the matching tag are added to the return list as strings.
+    The method is then iterated over and all elements until the matching tag or the limit are added to the return list.
     """
+    use_tuple_filter = isinstance(tag_filter, tuple)
     elements = []
 
-    for element in func(start_element, limit=limit):
-        if isinstance(tag_filter, tuple):
-            if element.name in tag_filter:
+    for element in func(start_element, name=Strainer(include_strings=include_strings), limit=limit):
+        if isinstance(element, Tag):
+            if use_tuple_filter:
+                if element.name in tag_filter:
+                    break
+            elif tag_filter(element):
                 break
-        elif tag_filter(element):
-            break
         elements.append(element)
 
     return elements
@@ -125,7 +131,7 @@ _find_next_siblings_until_tag = partial(_find_elements_until_tag, func=Beautiful
 _find_previous_siblings_until_tag = partial(_find_elements_until_tag, func=BeautifulSoup.find_previous_siblings)
 
 
-def get_module_description(start_element: PageElement) -> Optional[str]:
+def _get_module_description(start_element: PageElement) -> Optional[str]:
     """
     Get page content to a table or a tag with its class in `SEARCH_END_TAG_ATTRS`.
 
@@ -134,7 +140,9 @@ def get_module_description(start_element: PageElement) -> Optional[str]:
     """
     header = start_element.find("a", attrs={"class": "headerlink"})
     start_tag = header.parent if header is not None else start_element
-    description = "".join(str(tag) for tag in _find_next_siblings_until_tag(start_tag, _match_end_tag))
+    description = "".join(
+        str(tag) for tag in _find_next_siblings_until_tag(start_tag, _match_end_tag, include_strings=True)
+    )
 
     return description
 
@@ -142,7 +150,7 @@ def get_module_description(start_element: PageElement) -> Optional[str]:
 def _get_symbol_description(symbol: PageElement) -> str:
     """Get the string contents of the next dd tag, up to a dt or a dl tag."""
     description_tag = symbol.find_next("dd")
-    description_contents = _find_next_children_until_tag(description_tag, ("dt", "dl"))
+    description_contents = _find_next_children_until_tag(description_tag, ("dt", "dl"), include_strings=True)
     return "".join(str(tag) for tag in description_contents)
 
 
@@ -253,7 +261,7 @@ async def get_symbol_markdown(http_session: ClientSession, symbol_data: "DocItem
     # or don't contain any useful info to be parsed.
     signature = None
     if symbol_data.group in {"module", "doc"}:
-        description = get_module_description(symbol_heading)
+        description = _get_module_description(symbol_heading)
 
     elif symbol_data.group in _NO_SIGNATURE_GROUPS:
         description = _get_symbol_description(symbol_heading)
-- 
cgit v1.2.3
From 6ea6f732e719f93f88588f1d6c435262261e2650 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Sun, 26 Jul 2020 15:09:53 +0200
Subject: Fix markdownify's handling of h tags.
Discord only allows `**` for bolding while the markdown from the
default MarkdownConverter tries to use # time n with h*n* tags for
different font weights.
---
 bot/cogs/doc/parsing.py | 4 ++++
 1 file changed, 4 insertions(+)
diff --git a/bot/cogs/doc/parsing.py b/bot/cogs/doc/parsing.py
index 050c49447..ac8a94e3f 100644
--- a/bot/cogs/doc/parsing.py
+++ b/bot/cogs/doc/parsing.py
@@ -65,6 +65,10 @@ class _DocMarkdownConverter(MarkdownConverter):
             bullet = bullets[depth % len(bullets)]
         return '%s %s\n' % (bullet, text or '')
 
+    def convert_hn(self, _n: int, el: PageElement, text: str) -> str:
+        """Convert h tags to bold text with ** instead of adding #."""
+        return f"**{text}**\n\n"
+
     def convert_code(self, el: PageElement, text: str) -> str:
         """Undo `markdownify`s underscore escaping."""
         return f"`{text}`".replace('\\', '')
-- 
cgit v1.2.3
From 13030b8c54dd2ed37047349c5b09e4ded2c83391 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Sun, 26 Jul 2020 15:11:45 +0200
Subject: Move MarkdownConverter subclass to separate module
---
 bot/cogs/doc/markdown.py | 58 +++++++++++++++++++++++++++++++++++++++++++++++
 bot/cogs/doc/parsing.py  | 59 ++----------------------------------------------
 2 files changed, 60 insertions(+), 57 deletions(-)
 create mode 100644 bot/cogs/doc/markdown.py
diff --git a/bot/cogs/doc/markdown.py b/bot/cogs/doc/markdown.py
new file mode 100644
index 000000000..dca477d35
--- /dev/null
+++ b/bot/cogs/doc/markdown.py
@@ -0,0 +1,58 @@
+from urllib.parse import urljoin
+
+from bs4.element import PageElement
+from markdownify import MarkdownConverter
+
+
+class _DocMarkdownConverter(MarkdownConverter):
+    """Subclass markdownify's MarkdownCoverter to provide custom conversion methods."""
+
+    def __init__(self, *, page_url: str, **options):
+        super().__init__(**options)
+        self.page_url = page_url
+
+    def convert_li(self, el: PageElement, text: str) -> str:
+        """Fix markdownify's erroneous indexing in ol tags."""
+        parent = el.parent
+        if parent is not None and parent.name == 'ol':
+            li_tags = parent.find_all("li")
+            bullet = '%s.' % (li_tags.index(el)+1)
+        else:
+            depth = -1
+            while el:
+                if el.name == 'ul':
+                    depth += 1
+                el = el.parent
+            bullets = self.options['bullets']
+            bullet = bullets[depth % len(bullets)]
+        return '%s %s\n' % (bullet, text or '')
+
+    def convert_hn(self, _n: int, el: PageElement, text: str) -> str:
+        """Convert h tags to bold text with ** instead of adding #."""
+        return f"**{text}**\n\n"
+
+    def convert_code(self, el: PageElement, text: str) -> str:
+        """Undo `markdownify`s underscore escaping."""
+        return f"`{text}`".replace('\\', '')
+
+    def convert_pre(self, el: PageElement, text: str) -> str:
+        """Wrap any codeblocks in `py` for syntax highlighting."""
+        code = ''.join(el.strings)
+        return f"```py\n{code}```"
+
+    def convert_a(self, el: PageElement, text: str) -> str:
+        """Resolve relative URLs to `self.page_url`."""
+        el["href"] = urljoin(self.page_url, el["href"])
+        return super().convert_a(el, text)
+
+    def convert_p(self, el: PageElement, text: str) -> str:
+        """Include only one newline instead of two when the parent is a li tag."""
+        parent = el.parent
+        if parent is not None and parent.name == "li":
+            return f"{text}\n"
+        return super().convert_p(el, text)
+
+
+def markdownify(html: str, *, url: str = "") -> str:
+    """Create a DocMarkdownConverter object from the input html."""
+    return _DocMarkdownConverter(bullets='•', page_url=url).convert(html)
diff --git a/bot/cogs/doc/parsing.py b/bot/cogs/doc/parsing.py
index ac8a94e3f..93daf3faf 100644
--- a/bot/cogs/doc/parsing.py
+++ b/bot/cogs/doc/parsing.py
@@ -4,15 +4,14 @@ import string
 import textwrap
 from functools import partial
 from typing import Callable, List, Optional, TYPE_CHECKING, Tuple, Union
-from urllib.parse import urljoin
 
 from aiohttp import ClientSession
 from bs4 import BeautifulSoup
 from bs4.element import NavigableString, PageElement, Tag
-from markdownify import MarkdownConverter
 
 from .cache import async_cache
 from .html import Strainer
+from .markdown import markdownify
 if TYPE_CHECKING:
     from .cog import DocItem
 
@@ -42,60 +41,6 @@ _NO_SIGNATURE_GROUPS = {
 }
 
 
-class _DocMarkdownConverter(MarkdownConverter):
-    """Subclass markdownify's MarkdownCoverter to provide custom conversion methods."""
-
-    def __init__(self, *, page_url: str, **options):
-        super().__init__(**options)
-        self.page_url = page_url
-
-    def convert_li(self, el: PageElement, text: str) -> str:
-        """Fix markdownify's erroneous indexing in ol tags."""
-        parent = el.parent
-        if parent is not None and parent.name == 'ol':
-            li_tags = parent.find_all("li")
-            bullet = '%s.' % (li_tags.index(el)+1)
-        else:
-            depth = -1
-            while el:
-                if el.name == 'ul':
-                    depth += 1
-                el = el.parent
-            bullets = self.options['bullets']
-            bullet = bullets[depth % len(bullets)]
-        return '%s %s\n' % (bullet, text or '')
-
-    def convert_hn(self, _n: int, el: PageElement, text: str) -> str:
-        """Convert h tags to bold text with ** instead of adding #."""
-        return f"**{text}**\n\n"
-
-    def convert_code(self, el: PageElement, text: str) -> str:
-        """Undo `markdownify`s underscore escaping."""
-        return f"`{text}`".replace('\\', '')
-
-    def convert_pre(self, el: PageElement, text: str) -> str:
-        """Wrap any codeblocks in `py` for syntax highlighting."""
-        code = ''.join(el.strings)
-        return f"```py\n{code}```"
-
-    def convert_a(self, el: PageElement, text: str) -> str:
-        """Resolve relative URLs to `self.page_url`."""
-        el["href"] = urljoin(self.page_url, el["href"])
-        return super().convert_a(el, text)
-
-    def convert_p(self, el: PageElement, text: str) -> str:
-        """Include only one newline instead of two when the parent is a li tag."""
-        parent = el.parent
-        if parent is not None and parent.name == "li":
-            return f"{text}\n"
-        return super().convert_p(el, text)
-
-
-def _markdownify(html: str, *, url: str = "") -> str:
-    """Create a DocMarkdownConverter object from the input html."""
-    return _DocMarkdownConverter(bullets='•', page_url=url).convert(html)
-
-
 def _find_elements_until_tag(
         start_element: PageElement,
         tag_filter: Union[Tuple[str, ...], Callable[[Tag], bool]],
@@ -215,7 +160,7 @@ def _parse_into_markdown(signatures: Optional[List[str]], description: str, url:
     The signatures are wrapped in python codeblocks, separated from the description by a newline.
     The result string is truncated to be max 1000 symbols long.
     """
-    description = _truncate_markdown(_markdownify(description, url=url), 1000)
+    description = _truncate_markdown(markdownify(description, url=url), 1000)
     description = _WHITESPACE_AFTER_NEWLINES_RE.sub('', description)
     if signatures is not None:
         formatted_markdown = "".join(f"```py\n{textwrap.shorten(signature, 500)}```" for signature in signatures)
-- 
cgit v1.2.3
From 994b828254cc8e40a52cf604910d5aa3eba2293d Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Sun, 26 Jul 2020 15:21:40 +0200
Subject: Add more logging
---
 bot/cogs/doc/parsing.py | 4 ++++
 1 file changed, 4 insertions(+)
diff --git a/bot/cogs/doc/parsing.py b/bot/cogs/doc/parsing.py
index 93daf3faf..2ea21ed98 100644
--- a/bot/cogs/doc/parsing.py
+++ b/bot/cogs/doc/parsing.py
@@ -197,6 +197,7 @@ async def get_symbol_markdown(http_session: ClientSession, symbol_data: "DocItem
     A request through `http_session` is made to the url associated with `symbol_data` for the html contents;
     the contents are then parsed depending on what group the symbol belongs to.
     """
+    log.trace(f"Parsing symbol from url {symbol_data.url}.")
     if "#" in symbol_data.url:
         request_url, symbol_id = symbol_data.url.rsplit('#')
     else:
@@ -210,12 +211,15 @@ async def get_symbol_markdown(http_session: ClientSession, symbol_data: "DocItem
     # or don't contain any useful info to be parsed.
     signature = None
     if symbol_data.group in {"module", "doc"}:
+        log.trace("Symbol is a module or doc, parsing as module.")
         description = _get_module_description(symbol_heading)
 
     elif symbol_data.group in _NO_SIGNATURE_GROUPS:
+        log.trace("Symbol's group is in the group signature blacklist, skipping parsing of signature.")
         description = _get_symbol_description(symbol_heading)
 
     else:
+        log.trace("Parsing both signature and description of symbol.")
         signature = _get_signatures(symbol_heading)
         description = _get_symbol_description(symbol_heading)
 
-- 
cgit v1.2.3
From 83989d28fb83801acdea4b6f51cf48e974e21891 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Sun, 26 Jul 2020 15:29:09 +0200
Subject: Rename description functions to be more general
---
 bot/cogs/doc/parsing.py | 16 ++++++++--------
 1 file changed, 8 insertions(+), 8 deletions(-)
diff --git a/bot/cogs/doc/parsing.py b/bot/cogs/doc/parsing.py
index 2ea21ed98..96bb1dfb4 100644
--- a/bot/cogs/doc/parsing.py
+++ b/bot/cogs/doc/parsing.py
@@ -80,14 +80,14 @@ _find_next_siblings_until_tag = partial(_find_elements_until_tag, func=Beautiful
 _find_previous_siblings_until_tag = partial(_find_elements_until_tag, func=BeautifulSoup.find_previous_siblings)
 
 
-def _get_module_description(start_element: PageElement) -> Optional[str]:
+def _get_general_description(start_element: PageElement) -> Optional[str]:
     """
     Get page content to a table or a tag with its class in `SEARCH_END_TAG_ATTRS`.
 
-    A headerlink a tag is attempted to be found to skip repeating the module name in the description,
-    if it's found it's used as the tag to search from instead of the `start_element`.
+    A headerlink a tag is attempted to be found to skip repeating the symbol information in the description,
+    if it's found it's used as the tag to start the search from instead of the `start_element`.
     """
-    header = start_element.find("a", attrs={"class": "headerlink"})
+    header = start_element.find_next("a", attrs={"class": "headerlink"})
     start_tag = header.parent if header is not None else start_element
     description = "".join(
         str(tag) for tag in _find_next_siblings_until_tag(start_tag, _match_end_tag, include_strings=True)
@@ -96,7 +96,7 @@ def _get_module_description(start_element: PageElement) -> Optional[str]:
     return description
 
 
-def _get_symbol_description(symbol: PageElement) -> str:
+def _get_dd_description(symbol: PageElement) -> str:
     """Get the string contents of the next dd tag, up to a dt or a dl tag."""
     description_tag = symbol.find_next("dd")
     description_contents = _find_next_children_until_tag(description_tag, ("dt", "dl"), include_strings=True)
@@ -212,15 +212,15 @@ async def get_symbol_markdown(http_session: ClientSession, symbol_data: "DocItem
     signature = None
     if symbol_data.group in {"module", "doc"}:
         log.trace("Symbol is a module or doc, parsing as module.")
-        description = _get_module_description(symbol_heading)
+        description = _get_general_description(symbol_heading)
 
     elif symbol_data.group in _NO_SIGNATURE_GROUPS:
         log.trace("Symbol's group is in the group signature blacklist, skipping parsing of signature.")
-        description = _get_symbol_description(symbol_heading)
+        description = _get_dd_description(symbol_heading)
 
     else:
         log.trace("Parsing both signature and description of symbol.")
         signature = _get_signatures(symbol_heading)
-        description = _get_symbol_description(symbol_heading)
+        description = _get_dd_description(symbol_heading)
 
     return _parse_into_markdown(signature, description.replace('¶', ''), symbol_data.url)
-- 
cgit v1.2.3
From 5290fcf0fff23e4979746c51b77be9a51fe82ae7 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Sun, 26 Jul 2020 15:51:34 +0200
Subject: Properly parse labels add fallback for non dt tags
Labels point to tags that aren't in description lists, like modules
or doc symbols which we already handle.
If by chance we get a symbol that we don't have in the group for
general parsing and which isn't a dt tag, log it and don't attempt to
parse signature and use general description parsing instead of parsing a
dd tag.
---
 bot/cogs/doc/parsing.py | 18 +++++++++++++-----
 1 file changed, 13 insertions(+), 5 deletions(-)
diff --git a/bot/cogs/doc/parsing.py b/bot/cogs/doc/parsing.py
index 96bb1dfb4..1271953d4 100644
--- a/bot/cogs/doc/parsing.py
+++ b/bot/cogs/doc/parsing.py
@@ -206,12 +206,20 @@ async def get_symbol_markdown(http_session: ClientSession, symbol_data: "DocItem
 
     soup = await _get_soup_from_url(http_session, request_url)
     symbol_heading = soup.find(id=symbol_id)
-
-    # Handle doc symbols as modules, because they either link to the page of a module,
-    # or don't contain any useful info to be parsed.
     signature = None
-    if symbol_data.group in {"module", "doc"}:
-        log.trace("Symbol is a module or doc, parsing as module.")
+    # Modules, doc pages and labels don't point to description list tags but to tags like divs,
+    # no special parsing can be done so we only try to include what's under them.
+    if symbol_data.group in {"module", "doc", "label"}:
+        log.trace("Symbol is a module, doc or a label; using general description parsing.")
+        description = _get_general_description(symbol_heading)
+
+    elif symbol_heading.name != "dt":
+        # Use the general parsing for symbols that aren't modules, docs or labels and aren't dt tags,
+        # log info the tag can be looked at.
+        log.info(
+            f"Symbol heading at url {symbol_data.url} was not a dt tag or from known groups that lack it,"
+            f"handling as general description."
+        )
         description = _get_general_description(symbol_heading)
 
     elif symbol_data.group in _NO_SIGNATURE_GROUPS:
-- 
cgit v1.2.3
From ddb3c230cc7e1b38dbb57be10b1684c4ecb2ac7b Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Wed, 16 Sep 2020 00:14:58 +0200
Subject: Remove old comment
---
 bot/cogs/doc/cog.py | 1 -
 1 file changed, 1 deletion(-)
diff --git a/bot/cogs/doc/cog.py b/bot/cogs/doc/cog.py
index e52ee95c1..2f4c99252 100644
--- a/bot/cogs/doc/cog.py
+++ b/bot/cogs/doc/cog.py
@@ -160,7 +160,6 @@ class DocCog(commands.Cog):
 
                     # If renamed `symbol` already exists, add library name in front to differentiate between them.
                     elif symbol in self.renamed_symbols:
-                        # Split `package_name` because of packages like Pillow that have spaces in them.
                         symbol = f"{api_package_name}.{symbol}"
                         self.renamed_symbols.add(symbol)
 
-- 
cgit v1.2.3
From cb89cbaa36102c111c0204eb7c8bc27cecc1d4cd Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Wed, 16 Sep 2020 00:18:51 +0200
Subject: Don't return fragment in DocItem url
The fragment is only needed for the user and required sparingly
returning only the url while keeping the fragment behind symbol_id
simplifies the uses of the url without it.
---
 bot/cogs/doc/cog.py | 18 +++++++++---------
 1 file changed, 9 insertions(+), 9 deletions(-)
diff --git a/bot/cogs/doc/cog.py b/bot/cogs/doc/cog.py
index 2f4c99252..2e49fcd38 100644
--- a/bot/cogs/doc/cog.py
+++ b/bot/cogs/doc/cog.py
@@ -55,15 +55,16 @@ NOT_FOUND_DELETE_DELAY = RedirectOutput.delete_delay
 class DocItem(NamedTuple):
     """Holds inventory symbol information."""
 
-    base_url: str
-    relative_url: str
     package: str
     group: str
+    base_url: str
+    relative_url_path: str
+    symbol_id: str
 
     @property
     def url(self) -> str:
         """Return the absolute url to the symbol."""
-        return self.base_url + self.relative_url
+        return "".join((self.base_url, self.relative_url_path))
 
 
 class InventoryURL(commands.Converter):
@@ -141,21 +142,20 @@ class DocCog(commands.Cog):
                 # to remove unnecessary memory consumption from them being unique objects
                 group_name = sys.intern(group.split(":")[1])
 
-                if symbol in self.doc_symbols:
-                    symbol_base_url = self.doc_symbols[symbol].url.split("/", 3)[2]
+                if (original_symbol := self.doc_symbols.get(symbol)) is not None:
                     if (
                         group_name in NO_OVERRIDE_GROUPS
-                        or any(package in symbol_base_url for package in NO_OVERRIDE_PACKAGES)
+                        or any(package == original_symbol.package for package in NO_OVERRIDE_PACKAGES)
                     ):
                         symbol = f"{group_name}.{symbol}"
                         self.renamed_symbols.add(symbol)
 
-                    elif (overridden_symbol_group := self.doc_symbols[symbol].group) in NO_OVERRIDE_GROUPS:
+                    elif (overridden_symbol_group := original_symbol.group) in NO_OVERRIDE_GROUPS:
                         overridden_symbol = f"{overridden_symbol_group}.{symbol}"
                         if overridden_symbol in self.renamed_symbols:
                             overridden_symbol = f"{api_package_name}.{overridden_symbol}"
 
-                        self.doc_symbols[overridden_symbol] = self.doc_symbols[symbol]
+                        self.doc_symbols[overridden_symbol] = original_symbol
                         self.renamed_symbols.add(overridden_symbol)
 
                     # If renamed `symbol` already exists, add library name in front to differentiate between them.
@@ -202,7 +202,7 @@ class DocCog(commands.Cog):
 
         embed = discord.Embed(
             title=discord.utils.escape_markdown(symbol),
-            url=symbol_info.url,
+            url=f"{symbol_info.url}#{symbol_info.symbol_id}",
             description=embed_description
         )
         # Show all symbols with the same name that were renamed in the footer.
-- 
cgit v1.2.3
From 75f95a110ce96734cb64f89321f9a6eeb0d79463 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Sun, 20 Sep 2020 03:06:59 +0200
Subject: Replace caching of soups with new class.
Storing BeautifulSoup objects could lead to memory problems because
of their large footprint, the new class replaces the long term storage
by parsing all items on the first fetch of the page and only storing
their markdown string.
---
 bot/cogs/doc/cog.py     | 122 +++++++++++++++++++++++++++++++++++++++++++++---
 bot/cogs/doc/parsing.py |  36 ++------------
 2 files changed, 119 insertions(+), 39 deletions(-)
diff --git a/bot/cogs/doc/cog.py b/bot/cogs/doc/cog.py
index 2e49fcd38..d57e76ebd 100644
--- a/bot/cogs/doc/cog.py
+++ b/bot/cogs/doc/cog.py
@@ -1,14 +1,18 @@
+from __future__ import annotations
+
 import asyncio
 import functools
 import logging
 import re
 import sys
-from collections import OrderedDict
+from collections import defaultdict
 from contextlib import suppress
 from types import SimpleNamespace
-from typing import Dict, NamedTuple, Optional
+from typing import Dict, List, NamedTuple, Optional, Union
 
 import discord
+from aiohttp import ClientSession
+from bs4 import BeautifulSoup
 from discord.ext import commands
 from requests import ConnectTimeout, ConnectionError, HTTPError
 from sphinx.ext import intersphinx
@@ -20,7 +24,6 @@ from bot.converters import PackageName, ValidURL
 from bot.decorators import with_role
 from bot.pagination import LinePaginator
 from bot.utils.messages import wait_for_deletion
-from .cache import async_cache
 from .parsing import get_symbol_markdown
 
 log = logging.getLogger(__name__)
@@ -67,6 +70,108 @@ class DocItem(NamedTuple):
         return "".join((self.base_url, self.relative_url_path))
 
 
+class QueueItem(NamedTuple):
+    """Contains a symbol and the BeautifulSoup object needed to parse it."""
+
+    symbol: DocItem
+    soup: BeautifulSoup
+
+    def __eq__(self, other: Union[QueueItem, DocItem]):
+        if isinstance(other, DocItem):
+            return self.symbol == other
+        return NamedTuple.__eq__(self, other)
+
+
+class CachedParser:
+    """
+    Get symbol markdown from pages with smarter caching.
+
+    DocItems are added through the `add_item` method which adds them to the `_page_symbols` dict.
+    `get_markdown` is used to fetch the markdown; when this is used for the first time on a page,
+    all of the symbols are queued to be parsed to avoid multiple web requests to the same page.
+    """
+
+    def __init__(self):
+        self._queue: List[QueueItem] = []
+        self._results = {}
+        self._page_symbols: Dict[str, List[DocItem]] = defaultdict(list)
+        self._item_events: Dict[DocItem, asyncio.Event] = {}
+        self._parse_task = None
+
+    async def get_markdown(self, client_session: ClientSession, doc_item: DocItem) -> str:
+        """
+        Get result markdown of `doc_item`.
+
+        If no symbols were fetched from `doc_item`s page before,
+        the HTML has to be fetched before parsing can be queued.
+        """
+        if (symbol := self._results.get(doc_item)) is not None:
+            return symbol
+
+        if (symbols_to_queue := self._page_symbols.get(doc_item.url)) is not None:
+            async with client_session.get(doc_item.url) as response:
+                soup = BeautifulSoup(await response.text(encoding="utf8"), "lxml")
+
+            self._queue.extend(QueueItem(symbol, soup) for symbol in symbols_to_queue)
+            del self._page_symbols[doc_item.url]
+            log.debug(f"Added symbols from {doc_item.url} to parse queue.")
+
+            if self._parse_task is None:
+                self._parse_task = asyncio.create_task(self._parse_queue())
+
+        self._move_to_front(doc_item)
+        self._item_events[doc_item] = item_event = asyncio.Event()
+        await item_event.wait()
+        return self._results[doc_item]
+
+    async def _parse_queue(self) -> None:
+        """
+        Parse all item from the queue, setting associated events for symbols if present.
+
+        The coroutine will run as long as the queue is not empty, resetting `self._parse_task` to None when finished.
+        """
+        log.trace("Starting queue parsing.")
+        while self._queue:
+            item, soup = self._queue.pop()
+            self._results[item] = get_symbol_markdown(soup, item)
+            if (event := self._item_events.get(item)) is not None:
+                event.set()
+            await asyncio.sleep(0.1)
+
+        self._parse_task = None
+        log.trace("Finished parsing queue.")
+
+    def _move_to_front(self, item: Union[QueueItem, DocItem]) -> None:
+        """Move `item` to the front of the parse queue."""
+        # The parse queue stores soups along with the doc symbols in QueueItem objects,
+        # in case we're moving a DocItem we have to get the associated QueueItem first and then move it.
+        item_index = self._queue.index(item)
+        queue_item = self._queue[item_index]
+
+        del self._queue[item_index]
+        self._queue.append(queue_item)
+
+    def add_item(self, doc_item: DocItem) -> None:
+        """Add a DocItem to `_page_symbols`."""
+        self._page_symbols[doc_item.url].append(doc_item)
+
+    async def clear(self) -> None:
+        """
+        Clear all internal symbol data.
+
+        All currently requested items are waited to be parsed before clearing.
+        """
+        for event in self._item_events.values():
+            await event.wait()
+        if self._parse_task is not None:
+            self._parse_task.cancel()
+            self._parse_task = None
+        self._queue.clear()
+        self._results.clear()
+        self._page_symbols.clear()
+        self._item_events.clear()
+
+
 class InventoryURL(commands.Converter):
     """
     Represents an Intersphinx inventory URL.
@@ -106,6 +211,7 @@ class DocCog(commands.Cog):
         self.base_urls = {}
         self.bot = bot
         self.doc_symbols: Dict[str, DocItem] = {}
+        self.item_fetcher = CachedParser()
         self.renamed_symbols = set()
 
         self.bot.loop.create_task(self.init_refresh_inventory())
@@ -163,7 +269,10 @@ class DocCog(commands.Cog):
                         symbol = f"{api_package_name}.{symbol}"
                         self.renamed_symbols.add(symbol)
 
-                self.doc_symbols[symbol] = DocItem(base_url, relative_doc_url, api_package_name, group_name)
+                relative_url_path, _, symbol_id = relative_doc_url.partition("#")
+                symbol_item = DocItem(api_package_name, group_name, base_url, relative_url_path, symbol_id)
+                self.doc_symbols[symbol] = symbol_item
+                self.item_fetcher.add_item(symbol_item)
 
         log.trace(f"Fetched inventory for {api_package_name}.")
 
@@ -177,7 +286,7 @@ class DocCog(commands.Cog):
         self.base_urls.clear()
         self.doc_symbols.clear()
         self.renamed_symbols.clear()
-        async_cache.cache = OrderedDict()
+        await self.item_fetcher.clear()
 
         # Run all coroutines concurrently - since each of them performs a HTTP
         # request, this speeds up fetching the inventory data heavily.
@@ -198,12 +307,11 @@ class DocCog(commands.Cog):
         if symbol_info is None:
             return None
         self.bot.stats.incr(f"doc_fetches.{symbol_info.package.lower()}")
-        embed_description = await get_symbol_markdown(self.bot.http_session, symbol_info)
 
         embed = discord.Embed(
             title=discord.utils.escape_markdown(symbol),
             url=f"{symbol_info.url}#{symbol_info.symbol_id}",
-            description=embed_description
+            description=await self.item_fetcher.get_markdown(self.bot.http_session, symbol_info)
         )
         # Show all symbols with the same name that were renamed in the footer.
         embed.set_footer(
diff --git a/bot/cogs/doc/parsing.py b/bot/cogs/doc/parsing.py
index 1271953d4..9fbce7bed 100644
--- a/bot/cogs/doc/parsing.py
+++ b/bot/cogs/doc/parsing.py
@@ -5,11 +5,9 @@ import textwrap
 from functools import partial
 from typing import Callable, List, Optional, TYPE_CHECKING, Tuple, Union
 
-from aiohttp import ClientSession
 from bs4 import BeautifulSoup
 from bs4.element import NavigableString, PageElement, Tag
 
-from .cache import async_cache
 from .html import Strainer
 from .markdown import markdownify
 if TYPE_CHECKING:
@@ -171,16 +169,6 @@ def _parse_into_markdown(signatures: Optional[List[str]], description: str, url:
     return formatted_markdown
 
 
-@async_cache(arg_offset=1)
-async def _get_soup_from_url(http_session: ClientSession, url: str) -> BeautifulSoup:
-    """Create a BeautifulSoup object from the HTML data in `url` with the head tag removed."""
-    log.trace(f"Sending a request to {url}.")
-    async with http_session.get(url) as response:
-        soup = BeautifulSoup(await response.text(encoding="utf8"), 'lxml')
-    soup.find("head").decompose()  # the head contains no useful data so we can remove it
-    return soup
-
-
 def _match_end_tag(tag: Tag) -> bool:
     """Matches `tag` if its class value is in `SEARCH_END_TAG_ATTRS` or the tag is table."""
     for attr in _SEARCH_END_TAG_ATTRS:
@@ -190,44 +178,28 @@ def _match_end_tag(tag: Tag) -> bool:
     return tag.name == "table"
 
 
-async def get_symbol_markdown(http_session: ClientSession, symbol_data: "DocItem") -> str:
+def get_symbol_markdown(soup: BeautifulSoup, symbol_data: "DocItem") -> str:
     """
-    Return parsed markdown of the passed symbol, truncated to 1000 characters.
+    Return parsed markdown of the passed symbol using the passed in soup, truncated to 1000 characters.
 
-    A request through `http_session` is made to the url associated with `symbol_data` for the html contents;
-    the contents are then parsed depending on what group the symbol belongs to.
+    The method of parsing and what information gets included depends on the symbol's group.
     """
-    log.trace(f"Parsing symbol from url {symbol_data.url}.")
-    if "#" in symbol_data.url:
-        request_url, symbol_id = symbol_data.url.rsplit('#')
-    else:
-        request_url = symbol_data.url
-        symbol_id = None
-
-    soup = await _get_soup_from_url(http_session, request_url)
-    symbol_heading = soup.find(id=symbol_id)
+    symbol_heading = soup.find(id=symbol_data.symbol_id)
     signature = None
     # Modules, doc pages and labels don't point to description list tags but to tags like divs,
     # no special parsing can be done so we only try to include what's under them.
     if symbol_data.group in {"module", "doc", "label"}:
-        log.trace("Symbol is a module, doc or a label; using general description parsing.")
         description = _get_general_description(symbol_heading)
 
     elif symbol_heading.name != "dt":
         # Use the general parsing for symbols that aren't modules, docs or labels and aren't dt tags,
         # log info the tag can be looked at.
-        log.info(
-            f"Symbol heading at url {symbol_data.url} was not a dt tag or from known groups that lack it,"
-            f"handling as general description."
-        )
         description = _get_general_description(symbol_heading)
 
     elif symbol_data.group in _NO_SIGNATURE_GROUPS:
-        log.trace("Symbol's group is in the group signature blacklist, skipping parsing of signature.")
         description = _get_dd_description(symbol_heading)
 
     else:
-        log.trace("Parsing both signature and description of symbol.")
         signature = _get_signatures(symbol_heading)
         description = _get_dd_description(symbol_heading)
 
-- 
cgit v1.2.3
From 38753114c0d056ba330296c9fea7a8f2312459f9 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Sun, 20 Sep 2020 03:08:36 +0200
Subject: Replace forward ref with future annotations import
---
 bot/cogs/doc/parsing.py | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/bot/cogs/doc/parsing.py b/bot/cogs/doc/parsing.py
index 9fbce7bed..21a3065f4 100644
--- a/bot/cogs/doc/parsing.py
+++ b/bot/cogs/doc/parsing.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
 import logging
 import re
 import string
@@ -178,7 +180,7 @@ def _match_end_tag(tag: Tag) -> bool:
     return tag.name == "table"
 
 
-def get_symbol_markdown(soup: BeautifulSoup, symbol_data: "DocItem") -> str:
+def get_symbol_markdown(soup: BeautifulSoup, symbol_data: DocItem) -> str:
     """
     Return parsed markdown of the passed symbol using the passed in soup, truncated to 1000 characters.
 
-- 
cgit v1.2.3
From de440ce8c4539972ea0f0538042e6cb41a4395dc Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Sun, 20 Sep 2020 03:09:24 +0200
Subject: Remove unused cache
---
 bot/cogs/doc/cache.py | 32 --------------------------------
 1 file changed, 32 deletions(-)
 delete mode 100644 bot/cogs/doc/cache.py
diff --git a/bot/cogs/doc/cache.py b/bot/cogs/doc/cache.py
deleted file mode 100644
index 9da2a1dab..000000000
--- a/bot/cogs/doc/cache.py
+++ /dev/null
@@ -1,32 +0,0 @@
-import functools
-from collections import OrderedDict
-from typing import Any, Callable
-
-
-def async_cache(max_size: int = 128, arg_offset: int = 0) -> Callable:
-    """
-    LRU cache implementation for coroutines.
-
-    Once the cache exceeds the maximum size, keys are deleted in FIFO order.
-
-    An offset may be optionally provided to be applied to the coroutine's arguments when creating the cache key.
-    """
-    # Assign the cache to the function itself so we can clear it from outside.
-    async_cache.cache = OrderedDict()
-
-    def decorator(function: Callable) -> Callable:
-        """Define the async_cache decorator."""
-        @functools.wraps(function)
-        async def wrapper(*args) -> Any:
-            """Decorator wrapper for the caching logic."""
-            key = ':'.join(args[arg_offset:])
-
-            value = async_cache.cache.get(key)
-            if value is None:
-                if len(async_cache.cache) > max_size:
-                    async_cache.cache.popitem(last=False)
-
-                async_cache.cache[key] = await function(*args)
-            return async_cache.cache[key]
-        return wrapper
-    return decorator
-- 
cgit v1.2.3
From 758dd3ef6ca5c1cd7615f0eb6688d7d2f19578ea Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Sun, 20 Sep 2020 23:46:54 +0200
Subject: Log exceptions from parsing task
---
 bot/cogs/doc/cog.py | 10 +++++++---
 1 file changed, 7 insertions(+), 3 deletions(-)
diff --git a/bot/cogs/doc/cog.py b/bot/cogs/doc/cog.py
index fc01dfb20..7c1bf2a5f 100644
--- a/bot/cogs/doc/cog.py
+++ b/bot/cogs/doc/cog.py
@@ -133,9 +133,13 @@ class CachedParser:
         log.trace("Starting queue parsing.")
         while self._queue:
             item, soup = self._queue.pop()
-            self._results[item] = get_symbol_markdown(soup, item)
-            if (event := self._item_events.get(item)) is not None:
-                event.set()
+            try:
+                self._results[item] = get_symbol_markdown(soup, item)
+            except Exception:
+                log.exception(f"Unexpected error when handling {item}")
+            else:
+                if (event := self._item_events.get(item)) is not None:
+                    event.set()
             await asyncio.sleep(0.1)
 
         self._parse_task = None
-- 
cgit v1.2.3
From 7ab949e09a22d7547f74caa447d81299f7b52e47 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Mon, 21 Sep 2020 00:30:08 +0200
Subject: Properly truncate description markdown
The previous truncating implementation used a naive method that
disregarded the actual markdown formatting, possibly resulting in
it getting cut out. With the introduction of proper href tags this
became impossible to manage without writing an actual parser; so the
process was moved to happen when the gathered bs4 elements are being
converted into markdown
---
 bot/cogs/doc/markdown.py |  7 +---
 bot/cogs/doc/parsing.py  | 86 +++++++++++++++++++++++++++---------------------
 2 files changed, 49 insertions(+), 44 deletions(-)
diff --git a/bot/cogs/doc/markdown.py b/bot/cogs/doc/markdown.py
index dca477d35..a95e94991 100644
--- a/bot/cogs/doc/markdown.py
+++ b/bot/cogs/doc/markdown.py
@@ -4,7 +4,7 @@ from bs4.element import PageElement
 from markdownify import MarkdownConverter
 
 
-class _DocMarkdownConverter(MarkdownConverter):
+class DocMarkdownConverter(MarkdownConverter):
     """Subclass markdownify's MarkdownCoverter to provide custom conversion methods."""
 
     def __init__(self, *, page_url: str, **options):
@@ -51,8 +51,3 @@ class _DocMarkdownConverter(MarkdownConverter):
         if parent is not None and parent.name == "li":
             return f"{text}\n"
         return super().convert_p(el, text)
-
-
-def markdownify(html: str, *, url: str = "") -> str:
-    """Create a DocMarkdownConverter object from the input html."""
-    return _DocMarkdownConverter(bullets='•', page_url=url).convert(html)
diff --git a/bot/cogs/doc/parsing.py b/bot/cogs/doc/parsing.py
index 21a3065f4..ed6343cd8 100644
--- a/bot/cogs/doc/parsing.py
+++ b/bot/cogs/doc/parsing.py
@@ -5,13 +5,13 @@ import re
 import string
 import textwrap
 from functools import partial
-from typing import Callable, List, Optional, TYPE_CHECKING, Tuple, Union
+from typing import Callable, Iterable, List, Optional, TYPE_CHECKING, Tuple, Union
 
 from bs4 import BeautifulSoup
 from bs4.element import NavigableString, PageElement, Tag
 
 from .html import Strainer
-from .markdown import markdownify
+from .markdown import DocMarkdownConverter
 if TYPE_CHECKING:
     from .cog import DocItem
 
@@ -39,6 +39,8 @@ _NO_SIGNATURE_GROUPS = {
     "templatetag",
     "term",
 }
+_MAX_DESCRIPTION_LENGTH = 1800
+_TRUNCATE_STRIP_CHARACTERS = "!?:;." + string.whitespace
 
 
 def _find_elements_until_tag(
@@ -80,7 +82,7 @@ _find_next_siblings_until_tag = partial(_find_elements_until_tag, func=Beautiful
 _find_previous_siblings_until_tag = partial(_find_elements_until_tag, func=BeautifulSoup.find_previous_siblings)
 
 
-def _get_general_description(start_element: PageElement) -> Optional[str]:
+def _get_general_description(start_element: PageElement) -> Iterable[Union[Tag, NavigableString]]:
     """
     Get page content to a table or a tag with its class in `SEARCH_END_TAG_ATTRS`.
 
@@ -89,18 +91,13 @@ def _get_general_description(start_element: PageElement) -> Optional[str]:
     """
     header = start_element.find_next("a", attrs={"class": "headerlink"})
     start_tag = header.parent if header is not None else start_element
-    description = "".join(
-        str(tag) for tag in _find_next_siblings_until_tag(start_tag, _match_end_tag, include_strings=True)
-    )
+    return _find_next_siblings_until_tag(start_tag, _match_end_tag, include_strings=True)
 
-    return description
 
-
-def _get_dd_description(symbol: PageElement) -> str:
-    """Get the string contents of the next dd tag, up to a dt or a dl tag."""
+def _get_dd_description(symbol: PageElement) -> List[Union[Tag, NavigableString]]:
+    """Get the contents of the next dd tag, up to a dt or a dl tag."""
     description_tag = symbol.find_next("dd")
-    description_contents = _find_next_children_until_tag(description_tag, ("dt", "dl"), include_strings=True)
-    return "".join(str(tag) for tag in description_contents)
+    return _find_next_children_until_tag(description_tag, ("dt", "dl"), include_strings=True)
 
 
 def _get_signatures(start_signature: PageElement) -> List[str]:
@@ -124,43 +121,57 @@ def _get_signatures(start_signature: PageElement) -> List[str]:
     return signatures
 
 
-def _truncate_markdown(markdown: str, max_length: int) -> str:
+def _get_truncated_description(
+        elements: Iterable[Union[Tag, NavigableString]],
+        markdown_converter: DocMarkdownConverter,
+        max_length: int,
+) -> str:
     """
-    Truncate `markdown` to be at most `max_length` characters.
+    Truncate markdown from `elements` to be at most `max_length` characters visually.
 
-    The markdown string is searched for substrings to cut at, to keep its structure,
-    but if none are found the string is simply sliced.
+    `max_length` limits the length of the rendered characters in the string,
+    with the real string length limited to `_MAX_DESCRIPTION_LENGTH` to accommodate discord length limits
     """
-    if len(markdown) > max_length:
-        shortened = markdown[:max_length]
-        description_cutoff = shortened.rfind('\n\n', 100)
-        if description_cutoff == -1:
-            # Search the shortened version for cutoff points in decreasing desirability,
-            # cutoff at 1000 if none are found.
-            for cutoff_string in (". ", ", ", ",", " "):
-                description_cutoff = shortened.rfind(cutoff_string)
-                if description_cutoff != -1:
-                    break
+    visual_length = 0
+    real_length = 0
+    result = []
+    shortened = False
+
+    for element in elements:
+        is_tag = isinstance(element, Tag)
+        element_length = len(element.text) if is_tag else len(element)
+        if visual_length + element_length < max_length:
+            if is_tag:
+                element_markdown = markdown_converter.process_tag(element)
+            else:
+                element_markdown = markdown_converter.process_text(element)
+
+            element_markdown_length = len(element_markdown)
+            if real_length + element_markdown_length < _MAX_DESCRIPTION_LENGTH:
+                result.append(element_markdown)
             else:
-                description_cutoff = max_length
-        markdown = markdown[:description_cutoff]
+                shortened = True
+                break
+            real_length += element_markdown_length
+            visual_length += element_length
+        else:
+            shortened = True
+            break
 
-        # If there is an incomplete code block, cut it out
-        if markdown.count("```") % 2:
-            codeblock_start = markdown.rfind('```py')
-            markdown = markdown[:codeblock_start].rstrip()
-        markdown = markdown.rstrip(string.punctuation) + "..."
-    return markdown
+    markdown_string = "".join(result)
+    if shortened:
+        markdown_string = markdown_string.rstrip(_TRUNCATE_STRIP_CHARACTERS) + "..."
+    return markdown_string
 
 
-def _parse_into_markdown(signatures: Optional[List[str]], description: str, url: str) -> str:
+def _parse_into_markdown(signatures: Optional[List[str]], description: Iterable[Tag], url: str) -> str:
     """
     Create a markdown string with the signatures at the top, and the converted html description below them.
 
     The signatures are wrapped in python codeblocks, separated from the description by a newline.
     The result string is truncated to be max 1000 symbols long.
     """
-    description = _truncate_markdown(markdownify(description, url=url), 1000)
+    description = _get_truncated_description(description, DocMarkdownConverter(bullets="•", page_url=url), 750)
     description = _WHITESPACE_AFTER_NEWLINES_RE.sub('', description)
     if signatures is not None:
         formatted_markdown = "".join(f"```py\n{textwrap.shorten(signature, 500)}```" for signature in signatures)
@@ -204,5 +215,4 @@ def get_symbol_markdown(soup: BeautifulSoup, symbol_data: DocItem) -> str:
     else:
         signature = _get_signatures(symbol_heading)
         description = _get_dd_description(symbol_heading)
-
-    return _parse_into_markdown(signature, description.replace('¶', ''), symbol_data.url)
+    return _parse_into_markdown(signature, description, symbol_data.url).replace('¶', '')
-- 
cgit v1.2.3
From 3eed4af70fa24e5daef6c5e6d2d145094b9e672f Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Mon, 21 Sep 2020 00:39:15 +0200
Subject: Use f strings instead of c style on copied code
The code copied over from MarkdownConverter's implementation used
c style string formatting, there is no reason to keep the style
of strings in our code
---
 bot/cogs/doc/markdown.py | 14 +++++++-------
 1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/bot/cogs/doc/markdown.py b/bot/cogs/doc/markdown.py
index a95e94991..ba35a84c4 100644
--- a/bot/cogs/doc/markdown.py
+++ b/bot/cogs/doc/markdown.py
@@ -14,18 +14,18 @@ class DocMarkdownConverter(MarkdownConverter):
     def convert_li(self, el: PageElement, text: str) -> str:
         """Fix markdownify's erroneous indexing in ol tags."""
         parent = el.parent
-        if parent is not None and parent.name == 'ol':
+        if parent is not None and parent.name == "ol":
             li_tags = parent.find_all("li")
-            bullet = '%s.' % (li_tags.index(el)+1)
+            bullet = f"{li_tags.index(el)+1}."
         else:
             depth = -1
             while el:
-                if el.name == 'ul':
+                if el.name == "ul":
                     depth += 1
                 el = el.parent
-            bullets = self.options['bullets']
+            bullets = self.options["bullets"]
             bullet = bullets[depth % len(bullets)]
-        return '%s %s\n' % (bullet, text or '')
+        return f"{bullet} {text}\n"
 
     def convert_hn(self, _n: int, el: PageElement, text: str) -> str:
         """Convert h tags to bold text with ** instead of adding #."""
@@ -33,11 +33,11 @@ class DocMarkdownConverter(MarkdownConverter):
 
     def convert_code(self, el: PageElement, text: str) -> str:
         """Undo `markdownify`s underscore escaping."""
-        return f"`{text}`".replace('\\', '')
+        return f"`{text}`".replace("\\", "")
 
     def convert_pre(self, el: PageElement, text: str) -> str:
         """Wrap any codeblocks in `py` for syntax highlighting."""
-        code = ''.join(el.strings)
+        code = "".join(el.strings)
         return f"```py\n{code}```"
 
     def convert_a(self, el: PageElement, text: str) -> str:
-- 
cgit v1.2.3
From b6ef6b6bc30b02e0a6797dd9feae167da2cb6e5b Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Mon, 21 Sep 2020 00:52:40 +0200
Subject: Handle cases with outdated bot inventories.
---
 bot/cogs/doc/parsing.py | 3 +++
 1 file changed, 3 insertions(+)
diff --git a/bot/cogs/doc/parsing.py b/bot/cogs/doc/parsing.py
index ed6343cd8..939f963f1 100644
--- a/bot/cogs/doc/parsing.py
+++ b/bot/cogs/doc/parsing.py
@@ -198,6 +198,9 @@ def get_symbol_markdown(soup: BeautifulSoup, symbol_data: DocItem) -> str:
     The method of parsing and what information gets included depends on the symbol's group.
     """
     symbol_heading = soup.find(id=symbol_data.symbol_id)
+    if symbol_heading is None:
+        log.warning("Symbol present in loaded inventories not found on site, consider refreshing inventories.")
+        return "Unable to parse the requested symbol."
     signature = None
     # Modules, doc pages and labels don't point to description list tags but to tags like divs,
     # no special parsing can be done so we only try to include what's under them.
-- 
cgit v1.2.3
From ba73313adaff363bef9e3a505bf66373ea915997 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Mon, 21 Sep 2020 22:36:18 +0200
Subject: Use List typehint that has a narrower scope
---
 bot/cogs/doc/parsing.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/bot/cogs/doc/parsing.py b/bot/cogs/doc/parsing.py
index 939f963f1..9c82a1c13 100644
--- a/bot/cogs/doc/parsing.py
+++ b/bot/cogs/doc/parsing.py
@@ -82,7 +82,7 @@ _find_next_siblings_until_tag = partial(_find_elements_until_tag, func=Beautiful
 _find_previous_siblings_until_tag = partial(_find_elements_until_tag, func=BeautifulSoup.find_previous_siblings)
 
 
-def _get_general_description(start_element: PageElement) -> Iterable[Union[Tag, NavigableString]]:
+def _get_general_description(start_element: PageElement) -> List[Union[Tag, NavigableString]]:
     """
     Get page content to a table or a tag with its class in `SEARCH_END_TAG_ATTRS`.
 
-- 
cgit v1.2.3
From 730f30197c43cc170aaecde664712f6f4aaea246 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Sat, 26 Sep 2020 17:49:43 +0200
Subject: Collapse signatures between args instead of spaces
The signature length needed more logic and shorter limits
to ensure messages would fit in a discord message in a nice way.
---
 bot/cogs/doc/parsing.py | 95 +++++++++++++++++++++++++++++++++++++++++++++++--
 1 file changed, 92 insertions(+), 3 deletions(-)
diff --git a/bot/cogs/doc/parsing.py b/bot/cogs/doc/parsing.py
index 9c82a1c13..7dddadf43 100644
--- a/bot/cogs/doc/parsing.py
+++ b/bot/cogs/doc/parsing.py
@@ -5,7 +5,7 @@ import re
 import string
 import textwrap
 from functools import partial
-from typing import Callable, Iterable, List, Optional, TYPE_CHECKING, Tuple, Union
+from typing import Callable, Collection, Iterable, List, Optional, TYPE_CHECKING, Tuple, Union
 
 from bs4 import BeautifulSoup
 from bs4.element import NavigableString, PageElement, Tag
@@ -19,6 +19,7 @@ log = logging.getLogger(__name__)
 
 _UNWANTED_SIGNATURE_SYMBOLS_RE = re.compile(r"\[source]|\\\\|¶")
 _WHITESPACE_AFTER_NEWLINES_RE = re.compile(r"(?<=\n\n)(\s+)")
+_PARAMETERS_RE = re.compile(r"\((.+)\)")
 
 _SEARCH_END_TAG_ATTRS = (
     "data",
@@ -39,8 +40,59 @@ _NO_SIGNATURE_GROUPS = {
     "templatetag",
     "term",
 }
-_MAX_DESCRIPTION_LENGTH = 1800
+_EMBED_CODE_BLOCK_LENGTH = 61
+# Three code block wrapped lines with py syntax highlight
+_MAX_SIGNATURES_LENGTH = (_EMBED_CODE_BLOCK_LENGTH + 8) * 3
+# Maximum discord message length - signatures on top
+_MAX_DESCRIPTION_LENGTH = 2000 - _MAX_SIGNATURES_LENGTH
 _TRUNCATE_STRIP_CHARACTERS = "!?:;." + string.whitespace
+_BRACKET_PAIRS = {
+    "{": "}",
+    "(": ")",
+    "[": "]",
+}
+
+
+def _split_parameters(parameters_string: str) -> List[str]:
+    """
+    Split parameters of a signature into individual parameter strings on commas.
+
+    Long string literals are not accounted for.
+    """
+    parameters_list = []
+    last_split = 0
+    depth = 0
+    expected_end = None
+    current_search = None
+    previous_character = ""
+
+    for index, character in enumerate(parameters_string):
+        if character in _BRACKET_PAIRS:
+            if current_search is None:
+                current_search = character
+                expected_end = _BRACKET_PAIRS[character]
+            if character == current_search:
+                depth += 1
+
+        elif character in {"'", '"'}:
+            if depth == 0:
+                depth += 1
+            elif not previous_character == "\\":
+                depth -= 1
+
+        elif character == expected_end:
+            depth -= 1
+            if depth == 0:
+                current_search = None
+                expected_end = None
+
+        elif depth == 0 and character == ",":
+            parameters_list.append(parameters_string[last_split:index])
+            last_split = index + 1
+        previous_character = character
+
+    parameters_list.append(parameters_string[last_split:])
+    return parameters_list
 
 
 def _find_elements_until_tag(
@@ -121,6 +173,43 @@ def _get_signatures(start_signature: PageElement) -> List[str]:
     return signatures
 
 
+def _truncate_signatures(signatures: Collection[str]) -> Union[List[str], Collection[str]]:
+    """
+    Truncate passed signatures to not exceed `_MAX_SIGNAUTRES_LENGTH`.
+
+    If the signatures need to be truncated, parameters are collapsed until they fit withing the limit.
+    Individual signatures can consist of max 1, 2 or 3 lines of text, inversely  proportional to the amount of them.
+    A maximum of 3 signatures is assumed to be passed.
+    """
+    if not sum(len(signature) for signature in signatures) > _MAX_SIGNATURES_LENGTH:
+        return signatures
+
+    max_signature_length = _EMBED_CODE_BLOCK_LENGTH * (4 - len(signatures))
+    formatted_signatures = []
+    for signature in signatures:
+        signature = signature.strip()
+        if len(signature) > max_signature_length:
+            if (parameters_match := _PARAMETERS_RE.search(signature)) is None:
+                formatted_signatures.append(textwrap.shorten(signature, max_signature_length))
+                continue
+
+            truncated_signature = []
+            parameters_string = parameters_match[1]
+            running_length = len(signature) - len(parameters_string)
+            for parameter in _split_parameters(parameters_string):
+                if (len(parameter) + running_length) <= max_signature_length - 4:  # account for comma and placeholder
+                    truncated_signature.append(parameter)
+                    running_length += len(parameter) + 1
+                else:
+                    truncated_signature.append(" ...")
+                    formatted_signatures.append(signature.replace(parameters_string, ",".join(truncated_signature)))
+                    break
+        else:
+            formatted_signatures.append(signature)
+
+    return formatted_signatures
+
+
 def _get_truncated_description(
         elements: Iterable[Union[Tag, NavigableString]],
         markdown_converter: DocMarkdownConverter,
@@ -174,7 +263,7 @@ def _parse_into_markdown(signatures: Optional[List[str]], description: Iterable[
     description = _get_truncated_description(description, DocMarkdownConverter(bullets="•", page_url=url), 750)
     description = _WHITESPACE_AFTER_NEWLINES_RE.sub('', description)
     if signatures is not None:
-        formatted_markdown = "".join(f"```py\n{textwrap.shorten(signature, 500)}```" for signature in signatures)
+        formatted_markdown = "".join(f"```py\n{signature}```" for signature in _truncate_signatures(signatures))
     else:
         formatted_markdown = ""
     formatted_markdown += f"\n{description}"
-- 
cgit v1.2.3
From e10f91fce08f26f92776c3641ddd26f961a0c8b8 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Sat, 26 Sep 2020 17:51:52 +0200
Subject: Make amount of included signatures configurable
---
 bot/cogs/doc/parsing.py | 17 ++++++++++-------
 1 file changed, 10 insertions(+), 7 deletions(-)
diff --git a/bot/cogs/doc/parsing.py b/bot/cogs/doc/parsing.py
index 7dddadf43..cf1124936 100644
--- a/bot/cogs/doc/parsing.py
+++ b/bot/cogs/doc/parsing.py
@@ -17,6 +17,8 @@ if TYPE_CHECKING:
 
 log = logging.getLogger(__name__)
 
+_MAX_SIGNATURE_AMOUNT = 3
+
 _UNWANTED_SIGNATURE_SYMBOLS_RE = re.compile(r"\[source]|\\\\|¶")
 _WHITESPACE_AFTER_NEWLINES_RE = re.compile(r"(?<=\n\n)(\s+)")
 _PARAMETERS_RE = re.compile(r"\((.+)\)")
@@ -41,8 +43,8 @@ _NO_SIGNATURE_GROUPS = {
     "term",
 }
 _EMBED_CODE_BLOCK_LENGTH = 61
-# Three code block wrapped lines with py syntax highlight
-_MAX_SIGNATURES_LENGTH = (_EMBED_CODE_BLOCK_LENGTH + 8) * 3
+# _MAX_SIGNATURE_AMOUNT code block wrapped lines with py syntax highlight
+_MAX_SIGNATURES_LENGTH = (_EMBED_CODE_BLOCK_LENGTH + 8) * _MAX_SIGNATURE_AMOUNT
 # Maximum discord message length - signatures on top
 _MAX_DESCRIPTION_LENGTH = 2000 - _MAX_SIGNATURES_LENGTH
 _TRUNCATE_STRIP_CHARACTERS = "!?:;." + string.whitespace
@@ -154,7 +156,7 @@ def _get_dd_description(symbol: PageElement) -> List[Union[Tag, NavigableString]
 
 def _get_signatures(start_signature: PageElement) -> List[str]:
     """
-    Collect up to 3 signatures from dt tags around the `start_signature` dt tag.
+    Collect up to `_MAX_SIGNATURE_AMOUNT` signatures from dt tags around the `start_signature` dt tag.
 
     First the signatures under the `start_signature` are included;
     if less than 2 are found, tags above the start signature are added to the result if any are present.
@@ -164,7 +166,7 @@ def _get_signatures(start_signature: PageElement) -> List[str]:
             *reversed(_find_previous_siblings_until_tag(start_signature, ("dd",), limit=2)),
             start_signature,
             *_find_next_siblings_until_tag(start_signature, ("dd",), limit=2),
-    )[-3:]:
+    )[-_MAX_SIGNATURE_AMOUNT:]:
         signature = _UNWANTED_SIGNATURE_SYMBOLS_RE.sub("", element.text)
 
         if signature:
@@ -178,13 +180,14 @@ def _truncate_signatures(signatures: Collection[str]) -> Union[List[str], Collec
     Truncate passed signatures to not exceed `_MAX_SIGNAUTRES_LENGTH`.
 
     If the signatures need to be truncated, parameters are collapsed until they fit withing the limit.
-    Individual signatures can consist of max 1, 2 or 3 lines of text, inversely  proportional to the amount of them.
-    A maximum of 3 signatures is assumed to be passed.
+    Individual signatures can consist of max 1, 2, ..., `_MAX_SIGNATURE_AMOUNT` lines of text,
+    inversely proportional to the amount of signatures.
+    A maximum of `_MAX_SIGNATURE_AMOUNT` signatures is assumed to be passed.
     """
     if not sum(len(signature) for signature in signatures) > _MAX_SIGNATURES_LENGTH:
         return signatures
 
-    max_signature_length = _EMBED_CODE_BLOCK_LENGTH * (4 - len(signatures))
+    max_signature_length = _EMBED_CODE_BLOCK_LENGTH * (_MAX_SIGNATURE_AMOUNT + 1 - len(signatures))
     formatted_signatures = []
     for signature in signatures:
         signature = signature.strip()
-- 
cgit v1.2.3
From a2e7db718fbeb6fabb5e261ef4414038477abfb2 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Mon, 28 Sep 2020 23:43:58 +0200
Subject: Add parentheses for clarity
---
 bot/cogs/doc/parsing.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/bot/cogs/doc/parsing.py b/bot/cogs/doc/parsing.py
index cf1124936..7cf4ec7ba 100644
--- a/bot/cogs/doc/parsing.py
+++ b/bot/cogs/doc/parsing.py
@@ -166,7 +166,7 @@ def _get_signatures(start_signature: PageElement) -> List[str]:
             *reversed(_find_previous_siblings_until_tag(start_signature, ("dd",), limit=2)),
             start_signature,
             *_find_next_siblings_until_tag(start_signature, ("dd",), limit=2),
-    )[-_MAX_SIGNATURE_AMOUNT:]:
+    )[-(_MAX_SIGNATURE_AMOUNT):]:
         signature = _UNWANTED_SIGNATURE_SYMBOLS_RE.sub("", element.text)
 
         if signature:
-- 
cgit v1.2.3
From 2b97cfad08f7dac0ea1ce6119bab004b4c2452e7 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Tue, 29 Sep 2020 23:03:36 +0200
Subject: Add async implementation of sphinx fetch_inventory
The sphinx version of the function does a lot of checks that are
unnecessary for the bot because it's not working with anything else
related to docs. The custom implementation means we can throw some of
the code out and get rid of sphinx as a dependency.
---
 LICENSE-THIRD-PARTY              | 30 ++++++++++++++
 bot/cogs/doc/inventory_parser.py | 87 ++++++++++++++++++++++++++++++++++++++++
 2 files changed, 117 insertions(+)
 create mode 100644 LICENSE-THIRD-PARTY
 create mode 100644 bot/cogs/doc/inventory_parser.py
diff --git a/LICENSE-THIRD-PARTY b/LICENSE-THIRD-PARTY
new file mode 100644
index 000000000..f78491fc1
--- /dev/null
+++ b/LICENSE-THIRD-PARTY
@@ -0,0 +1,30 @@
+License for Sphinx
+Applies to:
+    - bot/cogs/doc/inventory_parser.py: _load_v1, _load_v2 and ZlibStreamReader.__aiter__.
+==================
+
+Copyright (c) 2007-2020 by the Sphinx team (see AUTHORS file).
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+* Redistributions of source code must retain the above copyright
+  notice, this list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright
+  notice, this list of conditions and the following disclaimer in the
+  documentation and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/bot/cogs/doc/inventory_parser.py b/bot/cogs/doc/inventory_parser.py
new file mode 100644
index 000000000..6c2b63d5e
--- /dev/null
+++ b/bot/cogs/doc/inventory_parser.py
@@ -0,0 +1,87 @@
+import re
+import zlib
+from collections import defaultdict
+from typing import AsyncIterator, DefaultDict, List, Tuple
+
+import aiohttp
+
+_V2_LINE_RE = re.compile(r'(?x)(.+?)\s+(\S*:\S*)\s+(-?\d+)\s+?(\S*)\s+(.*)')
+
+
+class ZlibStreamReader:
+    """Class used for decoding zlib data of a stream line by line."""
+
+    READ_CHUNK_SIZE = 16 * 1024
+
+    def __init__(self, stream: aiohttp.StreamReader) -> None:
+        self.stream = stream
+
+    async def _read_compressed_chunks(self) -> AsyncIterator[bytes]:
+        """Read zlib data in `READ_CHUNK_SIZE` sized chunks and decompress."""
+        decompressor = zlib.decompressobj()
+        async for chunk in self.stream.iter_chunked(self.READ_CHUNK_SIZE):
+            yield decompressor.decompress(chunk)
+
+        yield decompressor.flush()
+
+    async def __aiter__(self) -> AsyncIterator[str]:
+        """Yield lines of decompressed text."""
+        buf = b''
+        async for chunk in self._read_compressed_chunks():
+            buf += chunk
+            pos = buf.find(b'\n')
+            while pos != -1:
+                yield buf[:pos].decode()
+                buf = buf[pos + 1:]
+                pos = buf.find(b'\n')
+
+
+async def _load_v1(stream: aiohttp.StreamReader) -> DefaultDict[str, List[Tuple[str, str]]]:
+    invdata = defaultdict(list)
+
+    async for line in stream:
+        name, type_, location = line.decode().rstrip().split(maxsplit=2)
+        # version 1 did not add anchors to the location
+        if type_ == 'mod':
+            type_ = 'py:module'
+            location += '#module-' + name
+        else:
+            type_ = 'py:' + type_
+            location += '#' + name
+        invdata[type_].append((name, location))
+    return invdata
+
+
+async def _load_v2(stream: aiohttp.StreamReader) -> DefaultDict[str, List[Tuple[str, str]]]:
+    invdata = defaultdict(list)
+
+    async for line in ZlibStreamReader(stream):
+        m = _V2_LINE_RE.match(line.rstrip())
+        name, type_, _prio, location, _dispname = m.groups()  # ignore the parsed items we don't need
+        if location.endswith('$'):
+            location = location[:-1] + name
+
+        invdata[type_].append((name, location))
+    return invdata
+
+
+async def fetch_inventory(client_session: aiohttp.ClientSession, url: str) -> DefaultDict[str, List[Tuple[str, str]]]:
+    """Fetch, parse and return an intersphinx inventory file from an url."""
+    timeout = aiohttp.ClientTimeout(sock_connect=5, sock_read=5)
+    async with client_session.get(url, timeout=timeout, raise_for_status=True) as response:
+        stream = response.content
+
+        inventory_header = (await stream.readline()).decode().rstrip()
+        inventory_version = int(inventory_header[-1:])
+        await stream.readline()  # skip project name
+        await stream.readline()  # skip project version
+
+        if inventory_version == 1:
+            return await _load_v1(stream)
+
+        elif inventory_version == 2:
+            if b"zlib" not in await stream.readline():
+                raise ValueError(f"Invalid inventory file at url {url}.")
+            return await _load_v2(stream)
+
+        raise ValueError(f"Invalid inventory file at url {url}.")
-- 
cgit v1.2.3
From d8c36ac9f189ba9638ef91df7628f95845161f8e Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Wed, 30 Sep 2020 00:19:39 +0200
Subject: Handle errors on inventory fetching
---
 bot/cogs/doc/inventory_parser.py | 37 +++++++++++++++++++++++++++++++++++--
 1 file changed, 35 insertions(+), 2 deletions(-)
diff --git a/bot/cogs/doc/inventory_parser.py b/bot/cogs/doc/inventory_parser.py
index 6c2b63d5e..23931869b 100644
--- a/bot/cogs/doc/inventory_parser.py
+++ b/bot/cogs/doc/inventory_parser.py
@@ -1,10 +1,14 @@
+import logging
 import re
 import zlib
 from collections import defaultdict
-from typing import AsyncIterator, DefaultDict, List, Tuple
+from typing import AsyncIterator, DefaultDict, List, Optional, Tuple
 
 import aiohttp
 
+log = logging.getLogger(__name__)
+
+FAILED_REQUEST_ATTEMPTS = 3
 _V2_LINE_RE = re.compile(r'(?x)(.+?)\s+(\S*:\S*)\s+(-?\d+)\s+?(\S*)\s+(.*)')
 
 
@@ -65,7 +69,7 @@ async def _load_v2(stream: aiohttp.StreamReader) -> DefaultDict[str, List[Tuple[
     return invdata
 
 
-async def fetch_inventory(client_session: aiohttp.ClientSession, url: str) -> DefaultDict[str, List[Tuple[str, str]]]:
+async def _fetch_inventory(client_session: aiohttp.ClientSession, url: str) -> DefaultDict[str, List[Tuple[str, str]]]:
     """Fetch, parse and return an intersphinx inventory file from an url."""
     timeout = aiohttp.ClientTimeout(sock_connect=5, sock_read=5)
     async with client_session.get(url, timeout=timeout, raise_for_status=True) as response:
@@ -85,3 +89,32 @@ async def fetch_inventory(client_session: aiohttp.ClientSession, url: str) -> De
             return await _load_v2(stream)
 
         raise ValueError(f"Invalid inventory file at url {url}.")
+
+
+async def fetch_inventory(
+        client_session: aiohttp.ClientSession,
+        url: str
+) -> Optional[DefaultDict[str, List[Tuple[str, str]]]]:
+    """Get inventory from `url`, retrying `FAILED_REQUEST_ATTEMPTS` times on errors."""
+    for attempt in range(1, FAILED_REQUEST_ATTEMPTS+1):
+        try:
+            inventory = await _fetch_inventory(client_session, url)
+        except aiohttp.ClientConnectorError:
+            log.warning(
+                f"Failed to connect to inventory url at {url}, "
+                f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})."
+            )
+        except aiohttp.ClientError:
+            log.error(
+                f"Failed to get inventory from {url}, "
+                f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})."
+            )
+        except Exception:
+            log.exception(
+                f"An unexpected error has occurred during fetching of {url}, "
+                f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})."
+            )
+        else:
+            return inventory
+
+    return None
-- 
cgit v1.2.3
From 3bf04d8a353056944ac335b1d387d71464a81aa1 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Wed, 30 Sep 2020 00:38:24 +0200
Subject: Use new async inventory fetching
---
 bot/cogs/doc/cog.py | 71 ++++++-----------------------------------------------
 1 file changed, 7 insertions(+), 64 deletions(-)
diff --git a/bot/cogs/doc/cog.py b/bot/cogs/doc/cog.py
index 7c1bf2a5f..2cb296d53 100644
--- a/bot/cogs/doc/cog.py
+++ b/bot/cogs/doc/cog.py
@@ -1,22 +1,17 @@
 from __future__ import annotations
 
 import asyncio
-import functools
 import logging
 import re
 import sys
 from collections import defaultdict
 from contextlib import suppress
-from types import SimpleNamespace
 from typing import Dict, List, NamedTuple, Optional, Union
 
 import discord
 from aiohttp import ClientSession
 from bs4 import BeautifulSoup
 from discord.ext import commands
-from requests import ConnectTimeout, ConnectionError, HTTPError
-from sphinx.ext import intersphinx
-from urllib3.exceptions import ProtocolError
 
 from bot.bot import Bot
 from bot.constants import MODERATION_ROLES, RedirectOutput
@@ -24,20 +19,10 @@ from bot.converters import PackageName, ValidURL
 from bot.decorators import with_role
 from bot.pagination import LinePaginator
 from bot.utils.messages import wait_for_deletion
+from .inventory_parser import FAILED_REQUEST_ATTEMPTS, fetch_inventory
 from .parsing import get_symbol_markdown
 
 log = logging.getLogger(__name__)
-logging.getLogger('urllib3').setLevel(logging.WARNING)
-
-# Since Intersphinx is intended to be used with Sphinx,
-# we need to mock its configuration.
-SPHINX_MOCK_APP = SimpleNamespace(
-    config=SimpleNamespace(
-        intersphinx_timeout=3,
-        tls_verify=True,
-        user_agent="python3:python-discord/bot:1.0.0"
-    )
-)
 
 NO_OVERRIDE_GROUPS = (
     "2to3fixer",
@@ -51,7 +36,6 @@ NO_OVERRIDE_PACKAGES = (
 )
 
 WHITESPACE_AFTER_NEWLINES_RE = re.compile(r"(?<=\n\n)(\s+)")
-FAILED_REQUEST_RETRY_AMOUNT = 3
 NOT_FOUND_DELETE_DELAY = RedirectOutput.delete_delay
 
 
@@ -190,21 +174,8 @@ class InventoryURL(commands.Converter):
     async def convert(ctx: commands.Context, url: str) -> str:
         """Convert url to Intersphinx inventory URL."""
         await ctx.trigger_typing()
-        try:
-            intersphinx.fetch_inventory(SPHINX_MOCK_APP, '', url)
-        except AttributeError:
-            raise commands.BadArgument(f"Failed to fetch Intersphinx inventory from URL `{url}`.")
-        except ConnectionError:
-            if url.startswith('https'):
-                raise commands.BadArgument(
-                    f"Cannot establish a connection to `{url}`. Does it support HTTPS?"
-                )
-            raise commands.BadArgument(f"Cannot connect to host with URL `{url}`.")
-        except ValueError:
-            raise commands.BadArgument(
-                f"Failed to read Intersphinx inventory from URL `{url}`. "
-                "Are you sure that it's a valid inventory file?"
-            )
+        if await fetch_inventory(ctx.bot.http_session, url) is None:
+            raise commands.BadArgument(f"Failed to fetch inventory file after {FAILED_REQUEST_ATTEMPTS}.")
         return url
 
 
@@ -235,17 +206,16 @@ class DocCog(commands.Cog):
             * `package_name` is the package name to use, appears in the log
             * `base_url` is the root documentation URL for the specified package, used to build
                 absolute paths that link to specific symbols
-            * `inventory_url` is the absolute URL to the intersphinx inventory, fetched by running
-                `intersphinx.fetch_inventory` in an executor on the bot's event loop
+            * `inventory_url` is the absolute URL to the intersphinx inventory.
         """
         self.base_urls[api_package_name] = base_url
 
-        package = await self._fetch_inventory(inventory_url)
+        package = await fetch_inventory(self.bot.http_session, inventory_url)
         if not package:
             return None
 
-        for group, value in package.items():
-            for symbol, (_package_name, _version, relative_doc_url, _) in value.items():
+        for group, items in package.items():
+            for symbol, relative_doc_url in items:
                 if "/" in symbol:
                     continue  # skip unreachable symbols with slashes
                 # Intern the group names since they're reused in all the DocItems
@@ -455,30 +425,3 @@ class DocCog(commands.Cog):
             description=f"```diff\n{added}\n{removed}```" if added or removed else ""
         )
         await ctx.send(embed=embed)
-
-    async def _fetch_inventory(self, inventory_url: str) -> Optional[dict]:
-        """Get and return inventory from `inventory_url`. If fetching fails, return None."""
-        fetch_func = functools.partial(intersphinx.fetch_inventory, SPHINX_MOCK_APP, '', inventory_url)
-        for retry in range(1, FAILED_REQUEST_RETRY_AMOUNT+1):
-            try:
-                package = await self.bot.loop.run_in_executor(None, fetch_func)
-            except ConnectTimeout:
-                log.error(
-                    f"Fetching of inventory {inventory_url} timed out,"
-                    f" trying again. ({retry}/{FAILED_REQUEST_RETRY_AMOUNT})"
-                )
-            except ProtocolError:
-                log.error(
-                    f"Connection lost while fetching inventory {inventory_url},"
-                    f" trying again. ({retry}/{FAILED_REQUEST_RETRY_AMOUNT})"
-                )
-            except HTTPError as e:
-                log.error(f"Fetching of inventory {inventory_url} failed with status code {e.response.status_code}.")
-                return None
-            except ConnectionError:
-                log.error(f"Couldn't establish connection to inventory {inventory_url}.")
-                return None
-            else:
-                return package
-        log.error(f"Fetching of inventory {inventory_url} failed.")
-        return None
-- 
cgit v1.2.3
From 46ee70533328eed3790ebb93d1257b5d4e598802 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Wed, 30 Sep 2020 00:42:55 +0200
Subject: Remove sphinx and requests from Pipfile
With our own implementation of sphinx's inventory fetching we no longer
need the sphinx package, and requests which were used inside of it.
---
 Pipfile | 2 --
 1 file changed, 2 deletions(-)
diff --git a/Pipfile b/Pipfile
index 6fff2223e..1e54c9212 100644
--- a/Pipfile
+++ b/Pipfile
@@ -21,9 +21,7 @@ markdownify = "~=0.4"
 more_itertools = "~=8.2"
 python-dateutil = "~=2.8"
 pyyaml = "~=5.1"
-requests = "~=2.22"
 sentry-sdk = "~=0.14"
-sphinx = "~=2.2"
 statsd = "~=3.3"
 
 [dev-packages]
-- 
cgit v1.2.3
From c5aa0c0bd7e8933648fbedc92a7cd1f5ae199772 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Thu, 1 Oct 2020 00:04:53 +0200
Subject: Reschedule failed inventory updates
---
 bot/cogs/doc/cog.py | 39 +++++++++++++++++++++++++++++++++++----
 1 file changed, 35 insertions(+), 4 deletions(-)
diff --git a/bot/cogs/doc/cog.py b/bot/cogs/doc/cog.py
index 2cb296d53..41fca4584 100644
--- a/bot/cogs/doc/cog.py
+++ b/bot/cogs/doc/cog.py
@@ -19,6 +19,7 @@ from bot.converters import PackageName, ValidURL
 from bot.decorators import with_role
 from bot.pagination import LinePaginator
 from bot.utils.messages import wait_for_deletion
+from bot.utils.scheduling import Scheduler
 from .inventory_parser import FAILED_REQUEST_ATTEMPTS, fetch_inventory
 from .parsing import get_symbol_markdown
 
@@ -189,6 +190,9 @@ class DocCog(commands.Cog):
         self.item_fetcher = CachedParser()
         self.renamed_symbols = set()
 
+        self.inventory_scheduler = Scheduler(self.__class__.__name__)
+        self.scheduled_inventories = set()
+
         self.bot.loop.create_task(self.init_refresh_inventory())
 
     async def init_refresh_inventory(self) -> None:
@@ -198,7 +202,7 @@ class DocCog(commands.Cog):
 
     async def update_single(
         self, api_package_name: str, base_url: str, inventory_url: str
-    ) -> None:
+    ) -> bool:
         """
         Rebuild the inventory for a single package.
 
@@ -207,12 +211,27 @@ class DocCog(commands.Cog):
             * `base_url` is the root documentation URL for the specified package, used to build
                 absolute paths that link to specific symbols
             * `inventory_url` is the absolute URL to the intersphinx inventory.
+
+        If the inventory file is currently unreachable,
+        the update is rescheduled to execute in 2 minutes on the first attempt, and 5 minutes on subsequent attempts.
+
+        Return True on success; False if fetching failed and was rescheduled.
         """
         self.base_urls[api_package_name] = base_url
-
         package = await fetch_inventory(self.bot.http_session, inventory_url)
+
         if not package:
-            return None
+            delay = 2*60 if inventory_url not in self.scheduled_inventories else 5*60
+            log.info(f"Failed to fetch inventory, attempting again in {delay//60} minutes.")
+            self.inventory_scheduler.schedule_later(
+                delay,
+                api_package_name,
+                fetch_inventory(self.bot.http_session, inventory_url)
+            )
+            self.scheduled_inventories.add(api_package_name)
+            return False
+        with suppress(KeyError):
+            self.scheduled_inventories.discard(api_package_name)
 
         for group, items in package.items():
             for symbol, relative_doc_url in items:
@@ -249,6 +268,7 @@ class DocCog(commands.Cog):
                 self.item_fetcher.add_item(symbol_item)
 
         log.trace(f"Fetched inventory for {api_package_name}.")
+        return True
 
     async def refresh_inventory(self) -> None:
         """Refresh internal documentation inventory."""
@@ -260,6 +280,7 @@ class DocCog(commands.Cog):
         self.base_urls.clear()
         self.doc_symbols.clear()
         self.renamed_symbols.clear()
+        self.scheduled_inventories.clear()
         await self.item_fetcher.clear()
 
         # Run all coroutines concurrently - since each of them performs a HTTP
@@ -385,7 +406,11 @@ class DocCog(commands.Cog):
             f"Inventory URL: {inventory_url}"
         )
 
-        await self.update_single(package_name, base_url, inventory_url)
+        if await self.update_single(package_name, base_url, inventory_url) is None:
+            await ctx.send(
+                f"Added package `{package_name}` to database but failed to fetch inventory; rescheduled in 2 minutes."
+            )
+            return
         await ctx.send(f"Added package `{package_name}` to database and refreshed inventory.")
 
     @docs_group.command(name='deletedoc', aliases=('removedoc', 'rm', 'd'))
@@ -399,6 +424,9 @@ class DocCog(commands.Cog):
         """
         await self.bot.api_client.delete(f'bot/documentation-links/{package_name}')
 
+        if package_name in self.scheduled_inventories:
+            self.inventory_scheduler.cancel(package_name)
+
         async with ctx.typing():
             # Rebuild the inventory to ensure that everything
             # that was from this package is properly deleted.
@@ -409,6 +437,9 @@ class DocCog(commands.Cog):
     @with_role(*MODERATION_ROLES)
     async def refresh_command(self, ctx: commands.Context) -> None:
         """Refresh inventories and send differences to channel."""
+        for inventory in self.scheduled_inventories:
+            self.inventory_scheduler.cancel(inventory)
+
         old_inventories = set(self.base_urls)
         with ctx.typing():
             await self.refresh_inventory()
-- 
cgit v1.2.3
From f4924f0e8c26e373ddae8cb29f1f3935aaf00f4a Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Sat, 10 Oct 2020 21:47:34 +0200
Subject: Handle non dt fallback together with modules
---
 bot/exts/info/doc/_parsing.py | 7 +------
 1 file changed, 1 insertion(+), 6 deletions(-)
diff --git a/bot/exts/info/doc/_parsing.py b/bot/exts/info/doc/_parsing.py
index 83e35e2b1..a79332716 100644
--- a/bot/exts/info/doc/_parsing.py
+++ b/bot/exts/info/doc/_parsing.py
@@ -296,12 +296,7 @@ def get_symbol_markdown(soup: BeautifulSoup, symbol_data: DocItem) -> str:
     signature = None
     # Modules, doc pages and labels don't point to description list tags but to tags like divs,
     # no special parsing can be done so we only try to include what's under them.
-    if symbol_data.group in {"module", "doc", "label"}:
-        description = _get_general_description(symbol_heading)
-
-    elif symbol_heading.name != "dt":
-        # Use the general parsing for symbols that aren't modules, docs or labels and aren't dt tags,
-        # log info the tag can be looked at.
+    if symbol_data.group in {"module", "doc", "label"} or symbol_heading.name != "dt":
         description = _get_general_description(symbol_heading)
 
     elif symbol_data.group in _NO_SIGNATURE_GROUPS:
-- 
cgit v1.2.3
From 2744b10fae0f3b1d4ac198ba819c024e037e5660 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Sat, 10 Oct 2020 21:48:10 +0200
Subject: Use more descriptive name for end_tag_filter
---
 bot/exts/info/doc/_parsing.py | 10 +++++-----
 1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/bot/exts/info/doc/_parsing.py b/bot/exts/info/doc/_parsing.py
index a79332716..5f6c23c8d 100644
--- a/bot/exts/info/doc/_parsing.py
+++ b/bot/exts/info/doc/_parsing.py
@@ -99,7 +99,7 @@ def _split_parameters(parameters_string: str) -> List[str]:
 
 def _find_elements_until_tag(
         start_element: PageElement,
-        tag_filter: Union[Tuple[str, ...], Callable[[Tag], bool]],
+        end_tag_filter: Union[Tuple[str, ...], Callable[[Tag], bool]],
         *,
         func: Callable,
         include_strings: bool = False,
@@ -108,7 +108,7 @@ def _find_elements_until_tag(
     """
     Get all elements up to `limit` or until a tag matching `tag_filter` is found.
 
-    `tag_filter` can be either a tuple of string names to check against,
+    `end_tag_filter` can be either a tuple of string names to check against,
     or a filtering callable that's applied to tags.
 
     When `include_strings` is True, `NavigableString`s from the document will be included in the result along `Tag`s.
@@ -116,15 +116,15 @@ def _find_elements_until_tag(
     `func` takes in a BeautifulSoup unbound method for finding multiple elements, such as `BeautifulSoup.find_all`.
     The method is then iterated over and all elements until the matching tag or the limit are added to the return list.
     """
-    use_tuple_filter = isinstance(tag_filter, tuple)
+    use_tuple_filter = isinstance(end_tag_filter, tuple)
     elements = []
 
     for element in func(start_element, name=Strainer(include_strings=include_strings), limit=limit):
         if isinstance(element, Tag):
             if use_tuple_filter:
-                if element.name in tag_filter:
+                if element.name in end_tag_filter:
                     break
-            elif tag_filter(element):
+            elif end_tag_filter(element):
                 break
         elements.append(element)
 
-- 
cgit v1.2.3
From 9e4832965957eec291a3ccde198252ab28ce13e2 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Sat, 10 Oct 2020 21:50:37 +0200
Subject: Exclude headerlinks outside of current section
---
 bot/exts/info/doc/_parsing.py | 22 +++++++++++++---------
 1 file changed, 13 insertions(+), 9 deletions(-)
diff --git a/bot/exts/info/doc/_parsing.py b/bot/exts/info/doc/_parsing.py
index 5f6c23c8d..d31f26060 100644
--- a/bot/exts/info/doc/_parsing.py
+++ b/bot/exts/info/doc/_parsing.py
@@ -132,20 +132,22 @@ def _find_elements_until_tag(
 
 
 _find_next_children_until_tag = partial(_find_elements_until_tag, func=partial(BeautifulSoup.find_all, recursive=False))
+_find_recursive_children_until_tag = partial(_find_elements_until_tag, func=BeautifulSoup.find_all)
 _find_next_siblings_until_tag = partial(_find_elements_until_tag, func=BeautifulSoup.find_next_siblings)
 _find_previous_siblings_until_tag = partial(_find_elements_until_tag, func=BeautifulSoup.find_previous_siblings)
 
 
-def _get_general_description(start_element: PageElement) -> List[Union[Tag, NavigableString]]:
+def _get_general_description(start_element: Tag) -> List[Union[Tag, NavigableString]]:
     """
     Get page content to a table or a tag with its class in `SEARCH_END_TAG_ATTRS`.
 
     A headerlink a tag is attempted to be found to skip repeating the symbol information in the description,
     if it's found it's used as the tag to start the search from instead of the `start_element`.
     """
-    header = start_element.find_next("a", attrs={"class": "headerlink"})
+    child_tags = _find_recursive_children_until_tag(start_element, _class_filter_factory(["section"]), limit=100)
+    header = next(filter(_class_filter_factory(["headerlink"]), child_tags), None)
     start_tag = header.parent if header is not None else start_element
-    return _find_next_siblings_until_tag(start_tag, _match_end_tag, include_strings=True)
+    return _find_next_siblings_until_tag(start_tag, _class_filter_factory(_SEARCH_END_TAG_ATTRS), include_strings=True)
 
 
 def _get_dd_description(symbol: PageElement) -> List[Union[Tag, NavigableString]]:
@@ -274,13 +276,15 @@ def _parse_into_markdown(signatures: Optional[List[str]], description: Iterable[
     return formatted_markdown
 
 
-def _match_end_tag(tag: Tag) -> bool:
-    """Matches `tag` if its class value is in `SEARCH_END_TAG_ATTRS` or the tag is table."""
-    for attr in _SEARCH_END_TAG_ATTRS:
-        if attr in tag.get("class", ()):
-            return True
+def _class_filter_factory(class_names: Iterable[str]) -> Callable[[Tag], bool]:
+    """Create callable that returns True when the passed in tag's class is in `class_names` or when it's is a table."""
+    def match_tag(tag: Tag) -> bool:
+        for attr in class_names:
+            if attr in tag.get("class", ()):
+                return True
+        return tag.name == "table"
 
-    return tag.name == "table"
+    return match_tag
 
 
 def get_symbol_markdown(soup: BeautifulSoup, symbol_data: DocItem) -> str:
-- 
cgit v1.2.3
From 59f1fffb656447668f6e5a34fcc52697b152780a Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Sun, 18 Oct 2020 03:04:29 +0200
Subject: Handle escaped backslashes in strings
---
 bot/exts/info/doc/_parsing.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/bot/exts/info/doc/_parsing.py b/bot/exts/info/doc/_parsing.py
index d31f26060..0883b9f42 100644
--- a/bot/exts/info/doc/_parsing.py
+++ b/bot/exts/info/doc/_parsing.py
@@ -66,7 +66,6 @@ def _split_parameters(parameters_string: str) -> List[str]:
     depth = 0
     expected_end = None
     current_search = None
-    previous_character = ""
 
     for index, character in enumerate(parameters_string):
         if character in _BRACKET_PAIRS:
@@ -79,7 +78,9 @@ def _split_parameters(parameters_string: str) -> List[str]:
         elif character in {"'", '"'}:
             if depth == 0:
                 depth += 1
-            elif not previous_character == "\\":
+            elif parameters_string[index-1] != "\\":
+                depth -= 1
+            elif parameters_string[index-2] == "\\":
                 depth -= 1
 
         elif character == expected_end:
@@ -91,7 +92,6 @@ def _split_parameters(parameters_string: str) -> List[str]:
         elif depth == 0 and character == ",":
             parameters_list.append(parameters_string[last_split:index])
             last_split = index + 1
-        previous_character = character
 
     parameters_list.append(parameters_string[last_split:])
     return parameters_list
-- 
cgit v1.2.3
From c9fe7b1d6b98334c29f516b682b93b4c1c3946a1 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Tue, 10 Nov 2020 01:14:31 +0100
Subject: Cache user fetched symbols through redis.
---
 bot/exts/info/doc/_cog.py         | 22 ++++++++++++++++++++--
 bot/exts/info/doc/_redis_cache.py | 23 +++++++++++++++++++++++
 2 files changed, 43 insertions(+), 2 deletions(-)
 create mode 100644 bot/exts/info/doc/_redis_cache.py
diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py
index 257435e95..ab3ad159a 100644
--- a/bot/exts/info/doc/_cog.py
+++ b/bot/exts/info/doc/_cog.py
@@ -4,6 +4,7 @@ import asyncio
 import logging
 import re
 import sys
+import urllib.parse
 from collections import defaultdict
 from contextlib import suppress
 from typing import Dict, List, NamedTuple, Optional, Union
@@ -21,6 +22,7 @@ from bot.utils.messages import wait_for_deletion
 from bot.utils.scheduling import Scheduler
 from ._inventory_parser import FAILED_REQUEST_ATTEMPTS, fetch_inventory
 from ._parsing import get_symbol_markdown
+from ._redis_cache import DocRedisCache
 
 log = logging.getLogger(__name__)
 
@@ -182,6 +184,8 @@ class InventoryURL(commands.Converter):
 class DocCog(commands.Cog):
     """A set of commands for querying & displaying documentation."""
 
+    doc_cache = DocRedisCache()
+
     def __init__(self, bot: Bot):
         self.base_urls = {}
         self.bot = bot
@@ -296,16 +300,30 @@ class DocCog(commands.Cog):
         Attempt to scrape and fetch the data for the given `symbol`, and build an embed from its contents.
 
         If the symbol is known, an Embed with documentation about it is returned.
+
+        First check the DocRedisCache before querying the cog's `CachedParser`,
+        if not present also create a redis entry for the symbol.
         """
+        log.trace(f"Building embed for symbol `{symbol}`")
         symbol_info = self.doc_symbols.get(symbol)
         if symbol_info is None:
+            log.debug("Symbol does not exist.")
             return None
         self.bot.stats.incr(f"doc_fetches.{symbol_info.package.lower()}")
 
+        item_url = f"{symbol_info.url}#{symbol_info.symbol_id}"
+        redis_key = "".join(urllib.parse.urlparse(item_url)[1:])  # url without scheme
+
+        markdown = await self.doc_cache.get(redis_key)
+        if markdown is None:
+            log.debug(f"Redis cache miss for symbol `{symbol}`.")
+            markdown = await self.item_fetcher.get_markdown(self.bot.http_session, symbol_info)
+            await self.doc_cache.set(redis_key, markdown)
+
         embed = discord.Embed(
             title=discord.utils.escape_markdown(symbol),
-            url=f"{symbol_info.url}#{symbol_info.symbol_id}",
-            description=await self.item_fetcher.get_markdown(self.bot.http_session, symbol_info)
+            url=item_url,
+            description=markdown
         )
         # Show all symbols with the same name that were renamed in the footer.
         embed.set_footer(
diff --git a/bot/exts/info/doc/_redis_cache.py b/bot/exts/info/doc/_redis_cache.py
new file mode 100644
index 000000000..147394ba6
--- /dev/null
+++ b/bot/exts/info/doc/_redis_cache.py
@@ -0,0 +1,23 @@
+from typing import Optional
+
+from async_rediscache.types.base import RedisObject, namespace_lock
+
+
+class DocRedisCache(RedisObject):
+    """Interface for redis functionality needed by the Doc cog."""
+
+    @namespace_lock
+    async def set(self, key: str, value: str) -> None:
+        """
+        Set markdown `value` for `key`.
+
+        Keys expire after a week to keep data up to date.
+        """
+        with await self._get_pool_connection() as connection:
+            await connection.setex(f"{self.namespace}:{key}", 7*24*60*60, value)
+
+    @namespace_lock
+    async def get(self, key: str) -> Optional[str]:
+        """Get markdown contents for `key`."""
+        with await self._get_pool_connection() as connection:
+            return await connection.get(f"{self.namespace}:{key}", encoding="utf8")
-- 
cgit v1.2.3
From b8c12d08c9b8dc4e0bf39fcc242d67a3532d0fd0 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Tue, 10 Nov 2020 03:16:35 +0100
Subject: Add package in front of symbol as default fallback
Previously weo nly added the package name for symbols
that shared are named name with an another symbol, but
in some edge cases we can get to this point with symbols
that weren't renamed but have name conflicts, causing some
to get overwritten completely without the capturing condition
---
 bot/exts/info/doc/_cog.py | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py
index ab3ad159a..264d6e31e 100644
--- a/bot/exts/info/doc/_cog.py
+++ b/bot/exts/info/doc/_cog.py
@@ -260,8 +260,7 @@ class DocCog(commands.Cog):
                         self.doc_symbols[overridden_symbol] = original_symbol
                         self.renamed_symbols.add(overridden_symbol)
 
-                    # If renamed `symbol` already exists, add library name in front to differentiate between them.
-                    elif symbol in self.renamed_symbols:
+                    else:
                         symbol = f"{api_package_name}.{symbol}"
                         self.renamed_symbols.add(symbol)
 
-- 
cgit v1.2.3
From 89169f5c0b203be1963cfe569c216e0094674c4f Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Tue, 10 Nov 2020 03:56:29 +0100
Subject: Simplify duplicate symbol name handling code
With the catchall else condition and symbols from FORCE_PREFIX_GROUPS
getting renamed even when being overwritten, we can ignore the package
handling and let it go to the else which adds the package prefix
instead of a group
---
 bot/exts/info/doc/_cog.py | 14 ++++----------
 1 file changed, 4 insertions(+), 10 deletions(-)
diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py
index 264d6e31e..ee89f5384 100644
--- a/bot/exts/info/doc/_cog.py
+++ b/bot/exts/info/doc/_cog.py
@@ -26,17 +26,14 @@ from ._redis_cache import DocRedisCache
 
 log = logging.getLogger(__name__)
 
-NO_OVERRIDE_GROUPS = (
+# symbols with a group contained here will get the group prefixed on duplicates
+FORCE_PREFIX_GROUPS = (
     "2to3fixer",
     "token",
     "label",
     "pdbcommand",
     "term",
 )
-NO_OVERRIDE_PACKAGES = (
-    "python",
-)
-
 WHITESPACE_AFTER_NEWLINES_RE = re.compile(r"(?<=\n\n)(\s+)")
 NOT_FOUND_DELETE_DELAY = RedirectOutput.delete_delay
 
@@ -245,14 +242,11 @@ class DocCog(commands.Cog):
                 group_name = sys.intern(group.split(":")[1])
 
                 if (original_symbol := self.doc_symbols.get(symbol)) is not None:
-                    if (
-                        group_name in NO_OVERRIDE_GROUPS
-                        or any(package == original_symbol.package for package in NO_OVERRIDE_PACKAGES)
-                    ):
+                    if group_name in FORCE_PREFIX_GROUPS:
                         symbol = f"{group_name}.{symbol}"
                         self.renamed_symbols.add(symbol)
 
-                    elif (overridden_symbol_group := original_symbol.group) in NO_OVERRIDE_GROUPS:
+                    elif (overridden_symbol_group := original_symbol.group) in FORCE_PREFIX_GROUPS:
                         overridden_symbol = f"{overridden_symbol_group}.{symbol}"
                         if overridden_symbol in self.renamed_symbols:
                             overridden_symbol = f"{api_package_name}.{overridden_symbol}"
-- 
cgit v1.2.3
From faaa85d2d00a2bc7496965fad3f5f53f56718e9c Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Tue, 10 Nov 2020 04:03:23 +0100
Subject: Move InventoryURL converer to the converters file
---
 bot/converters.py         | 20 ++++++++++++++++++++
 bot/exts/info/doc/_cog.py | 23 ++---------------------
 2 files changed, 22 insertions(+), 21 deletions(-)
diff --git a/bot/converters.py b/bot/converters.py
index 6c87a50fe..3066eaabb 100644
--- a/bot/converters.py
+++ b/bot/converters.py
@@ -15,6 +15,7 @@ from discord.utils import DISCORD_EPOCH, snowflake_time
 
 from bot.api import ResponseCodeError
 from bot.constants import URLs
+from bot.exts.info.doc import _inventory_parser
 from bot.utils.regex import INVITE_RE
 
 log = logging.getLogger(__name__)
@@ -175,6 +176,25 @@ class ValidURL(Converter):
         return url
 
 
+class InventoryURL(Converter):
+    """
+    Represents an Intersphinx inventory URL.
+
+    This converter checks whether intersphinx accepts the given inventory URL, and raises
+    `BadArgument` if that is not the case.
+
+    Otherwise, it simply passes through the given URL.
+    """
+
+    @staticmethod
+    async def convert(ctx: Context, url: str) -> str:
+        """Convert url to Intersphinx inventory URL."""
+        await ctx.trigger_typing()
+        if await _inventory_parser.fetch_inventory(ctx.bot.http_session, url) is None:
+            raise BadArgument(f"Failed to fetch inventory file after {_inventory_parser.FAILED_REQUEST_ATTEMPTS}.")
+        return url
+
+
 class Snowflake(IDConverter):
     """
     Converts to an int if the argument is a valid Discord snowflake.
diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py
index ee89f5384..25477fe07 100644
--- a/bot/exts/info/doc/_cog.py
+++ b/bot/exts/info/doc/_cog.py
@@ -16,11 +16,11 @@ from discord.ext import commands
 
 from bot.bot import Bot
 from bot.constants import MODERATION_ROLES, RedirectOutput
-from bot.converters import PackageName, ValidURL
+from bot.converters import InventoryURL, PackageName, ValidURL
 from bot.pagination import LinePaginator
 from bot.utils.messages import wait_for_deletion
 from bot.utils.scheduling import Scheduler
-from ._inventory_parser import FAILED_REQUEST_ATTEMPTS, fetch_inventory
+from ._inventory_parser import fetch_inventory
 from ._parsing import get_symbol_markdown
 from ._redis_cache import DocRedisCache
 
@@ -159,25 +159,6 @@ class CachedParser:
         self._item_events.clear()
 
 
-class InventoryURL(commands.Converter):
-    """
-    Represents an Intersphinx inventory URL.
-
-    This converter checks whether intersphinx accepts the given inventory URL, and raises
-    `BadArgument` if that is not the case.
-
-    Otherwise, it simply passes through the given URL.
-    """
-
-    @staticmethod
-    async def convert(ctx: commands.Context, url: str) -> str:
-        """Convert url to Intersphinx inventory URL."""
-        await ctx.trigger_typing()
-        if await fetch_inventory(ctx.bot.http_session, url) is None:
-            raise commands.BadArgument(f"Failed to fetch inventory file after {FAILED_REQUEST_ATTEMPTS}.")
-        return url
-
-
 class DocCog(commands.Cog):
     """A set of commands for querying & displaying documentation."""
 
-- 
cgit v1.2.3
From 2836ce6f24d66949376a1defbf3813ffae8b7f47 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Tue, 10 Nov 2020 13:45:43 +0100
Subject: Relock Pipfile.lock
---
 Pipfile.lock | 434 +++++++++++++++++++----------------------------------------
 1 file changed, 136 insertions(+), 298 deletions(-)
diff --git a/Pipfile.lock b/Pipfile.lock
index becd85c55..f622d9e01 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
 {
     "_meta": {
         "hash": {
-            "sha256": "073fd0c51749aafa188fdbe96c5b90dd157cb1d23bdd144801fb0d0a369ffa88"
+            "sha256": "35130d225126e341941fe36e4193fe53aa253e193a50505054a87f48ab7f7c8c"
         },
         "pipfile-spec": 6,
         "requires": {
@@ -34,21 +34,22 @@
         },
         "aiohttp": {
             "hashes": [
-                "sha256:1e984191d1ec186881ffaed4581092ba04f7c61582a177b187d3a2f07ed9719e",
-                "sha256:259ab809ff0727d0e834ac5e8a283dc5e3e0ecc30c4d80b3cd17a4139ce1f326",
-                "sha256:2f4d1a4fdce595c947162333353d4a44952a724fba9ca3205a3df99a33d1307a",
-                "sha256:32e5f3b7e511aa850829fbe5aa32eb455e5534eaa4b1ce93231d00e2f76e5654",
-                "sha256:344c780466b73095a72c616fac5ea9c4665add7fc129f285fbdbca3cccf4612a",
-                "sha256:460bd4237d2dbecc3b5ed57e122992f60188afe46e7319116da5eb8a9dfedba4",
-                "sha256:4c6efd824d44ae697814a2a85604d8e992b875462c6655da161ff18fd4f29f17",
-                "sha256:50aaad128e6ac62e7bf7bd1f0c0a24bc968a0c0590a726d5a955af193544bcec",
-                "sha256:6206a135d072f88da3e71cc501c59d5abffa9d0bb43269a6dcd28d66bfafdbdd",
-                "sha256:65f31b622af739a802ca6fd1a3076fd0ae523f8485c52924a89561ba10c49b48",
-                "sha256:ae55bac364c405caa23a4f2d6cfecc6a0daada500274ffca4a9230e7129eac59",
-                "sha256:b778ce0c909a2653741cb4b1ac7015b5c130ab9c897611df43ae6a58523cb965"
+                "sha256:1a4160579ffbc1b69e88cb6ca8bb0fbd4947dfcbf9fb1e2a4fc4c7a4a986c1fe",
+                "sha256:206c0ccfcea46e1bddc91162449c20c72f308aebdcef4977420ef329c8fcc599",
+                "sha256:2ad493de47a8f926386fa6d256832de3095ba285f325db917c7deae0b54a9fc8",
+                "sha256:319b490a5e2beaf06891f6711856ea10591cfe84fe9f3e71a721aa8f20a0872a",
+                "sha256:470e4c90da36b601676fe50c49a60d34eb8c6593780930b1aa4eea6f508dfa37",
+                "sha256:60f4caa3b7f7a477f66ccdd158e06901e1d235d572283906276e3803f6b098f5",
+                "sha256:66d64486172b032db19ea8522328b19cfb78a3e1e5b62ab6a0567f93f073dea0",
+                "sha256:687461cd974722110d1763b45c5db4d2cdee8d50f57b00c43c7590d1dd77fc5c",
+                "sha256:698cd7bc3c7d1b82bb728bae835724a486a8c376647aec336aa21a60113c3645",
+                "sha256:797456399ffeef73172945708810f3277f794965eb6ec9bd3a0c007c0476be98",
+                "sha256:a885432d3cabc1287bcf88ea94e1826d3aec57fd5da4a586afae4591b061d40d",
+                "sha256:c506853ba52e516b264b106321c424d03f3ddef2813246432fa9d1cefd361c81",
+                "sha256:fb83326d8295e8840e4ba774edf346e87eca78ba8a89c55d2690352842c15ba5"
             ],
             "index": "pypi",
-            "version": "==3.6.2"
+            "version": "==3.6.3"
         },
         "aioping": {
             "hashes": [
@@ -68,18 +69,11 @@
         },
         "aiormq": {
             "hashes": [
-                "sha256:106695a836f19c1af6c46b58e8aac80e00f86c5b3287a3c6483a1ee369cc95c9",
-                "sha256:9f6dbf6155fe2b7a3d24bf68de97fb812db0fac0a54e96bc1af14ea95078ba7f"
+                "sha256:8218dd9f7198d6e7935855468326bbacf0089f926c70baa8dd92944cb2496573",
+                "sha256:e584dac13a242589aaf42470fd3006cb0dc5aed6506cbd20357c7ec8bbe4a89e"
             ],
             "markers": "python_version >= '3.6'",
-            "version": "==3.2.3"
-        },
-        "alabaster": {
-            "hashes": [
-                "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359",
-                "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"
-            ],
-            "version": "==0.7.12"
+            "version": "==3.3.1"
         },
         "async-rediscache": {
             "extras": [
@@ -103,35 +97,27 @@
         },
         "attrs": {
             "hashes": [
-                "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594",
-                "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc"
+                "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6",
+                "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"
             ],
             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
-            "version": "==20.2.0"
-        },
-        "babel": {
-            "hashes": [
-                "sha256:1aac2ae2d0d8ea368fa90906567f5c08463d98ade155c0c4bfedd6a0f7160e38",
-                "sha256:d670ea0b10f8b723672d3a6abeb87b565b244da220d76b4dba1b66269ec152d4"
-            ],
-            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
-            "version": "==2.8.0"
+            "version": "==20.3.0"
         },
         "beautifulsoup4": {
             "hashes": [
-                "sha256:1edf5e39f3a5bc6e38b235b369128416c7239b34f692acccececb040233032a1",
-                "sha256:5dfe44f8fddc89ac5453f02659d3ab1668f2c0d9684839f0785037e8c6d9ac8d",
-                "sha256:645d833a828722357038299b7f6879940c11dddd95b900fe5387c258b72bb883"
+                "sha256:4c98143716ef1cb40bf7f39a8e3eec8f8b009509e74904ba3a7b315431577e35",
+                "sha256:84729e322ad1d5b4d25f805bfa05b902dd96450f43842c4e99067d5e1369eb25",
+                "sha256:fff47e031e34ec82bf17e00da8f592fe7de69aeea38be00523c04623c04fb666"
             ],
             "index": "pypi",
-            "version": "==4.9.2"
+            "version": "==4.9.3"
         },
         "certifi": {
             "hashes": [
-                "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3",
-                "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41"
+                "sha256:1f422849db327d534e3d0c5f02a263458c3955ec0aae4ff09b95f195c59f4edd",
+                "sha256:f05def092c44fbf25834a51509ef6e631dc19765ab8a57b4e7ab85531f0a9cf4"
             ],
-            "version": "==2020.6.20"
+            "version": "==2020.11.8"
         },
         "cffi": {
             "hashes": [
@@ -183,11 +169,12 @@
         },
         "colorama": {
             "hashes": [
-                "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff",
-                "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"
+                "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b",
+                "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"
             ],
+            "index": "pypi",
             "markers": "sys_platform == 'win32'",
-            "version": "==0.4.3"
+            "version": "==0.4.4"
         },
         "coloredlogs": {
             "hashes": [
@@ -207,26 +194,18 @@
         },
         "discord.py": {
             "hashes": [
-                "sha256:3acb61fde0d862ed346a191d69c46021e6063673f63963bc984ae09a685ab211",
-                "sha256:e71089886aa157341644bdecad63a72ff56b44406b1a6467b66db31c8e5a5a15"
+                "sha256:2367359e31f6527f8a936751fc20b09d7495dd6a76b28c8fb13d4ca6c55b7563",
+                "sha256:def00dc50cf36d21346d71bc89f0cad8f18f9a3522978dc18c7796287d47de8b"
             ],
             "index": "pypi",
-            "version": "==1.5.0"
-        },
-        "docutils": {
-            "hashes": [
-                "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af",
-                "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"
-            ],
-            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
-            "version": "==0.16"
+            "version": "==1.5.1"
         },
         "fakeredis": {
             "hashes": [
-                "sha256:7ea0866ba5edb40fe2e9b1722535df0c7e6b91d518aa5f50d96c2fff3ea7f4c2",
-                "sha256:aad8836ffe0319ffbba66dcf872ac6e7e32d1f19790e31296ba58445efb0a5c7"
+                "sha256:8070b7fce16f828beaef2c757a4354af91698685d5232404f1aeeb233529c7a5",
+                "sha256:f8c8ea764d7b6fd801e7f5486e3edd32ca991d506186f1923a01fc072e33c271"
             ],
-            "version": "==1.4.3"
+            "version": "==1.4.4"
         },
         "feedparser": {
             "hashes": [
@@ -313,58 +292,48 @@
             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
             "version": "==2.10"
         },
-        "imagesize": {
-            "hashes": [
-                "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1",
-                "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1"
-            ],
-            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
-            "version": "==1.2.0"
-        },
-        "jinja2": {
-            "hashes": [
-                "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0",
-                "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"
-            ],
-            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
-            "version": "==2.11.2"
-        },
         "lxml": {
             "hashes": [
-                "sha256:05a444b207901a68a6526948c7cc8f9fe6d6f24c70781488e32fd74ff5996e3f",
-                "sha256:08fc93257dcfe9542c0a6883a25ba4971d78297f63d7a5a26ffa34861ca78730",
-                "sha256:107781b213cf7201ec3806555657ccda67b1fccc4261fb889ef7fc56976db81f",
-                "sha256:121b665b04083a1e85ff1f5243d4a93aa1aaba281bc12ea334d5a187278ceaf1",
-                "sha256:1fa21263c3aba2b76fd7c45713d4428dbcc7644d73dcf0650e9d344e433741b3",
-                "sha256:2b30aa2bcff8e958cd85d907d5109820b01ac511eae5b460803430a7404e34d7",
-                "sha256:4b4a111bcf4b9c948e020fd207f915c24a6de3f1adc7682a2d92660eb4e84f1a",
-                "sha256:5591c4164755778e29e69b86e425880f852464a21c7bb53c7ea453bbe2633bbe",
-                "sha256:59daa84aef650b11bccd18f99f64bfe44b9f14a08a28259959d33676554065a1",
-                "sha256:5a9c8d11aa2c8f8b6043d845927a51eb9102eb558e3f936df494e96393f5fd3e",
-                "sha256:5dd20538a60c4cc9a077d3b715bb42307239fcd25ef1ca7286775f95e9e9a46d",
-                "sha256:74f48ec98430e06c1fa8949b49ebdd8d27ceb9df8d3d1c92e1fdc2773f003f20",
-                "sha256:786aad2aa20de3dbff21aab86b2fb6a7be68064cbbc0219bde414d3a30aa47ae",
-                "sha256:7ad7906e098ccd30d8f7068030a0b16668ab8aa5cda6fcd5146d8d20cbaa71b5",
-                "sha256:80a38b188d20c0524fe8959c8ce770a8fdf0e617c6912d23fc97c68301bb9aba",
-                "sha256:8f0ec6b9b3832e0bd1d57af41f9238ea7709bbd7271f639024f2fc9d3bb01293",
-                "sha256:92282c83547a9add85ad658143c76a64a8d339028926d7dc1998ca029c88ea6a",
-                "sha256:94150231f1e90c9595ccc80d7d2006c61f90a5995db82bccbca7944fd457f0f6",
-                "sha256:9dc9006dcc47e00a8a6a029eb035c8f696ad38e40a27d073a003d7d1443f5d88",
-                "sha256:a76979f728dd845655026ab991df25d26379a1a8fc1e9e68e25c7eda43004bed",
-                "sha256:aa8eba3db3d8761db161003e2d0586608092e217151d7458206e243be5a43843",
-                "sha256:bea760a63ce9bba566c23f726d72b3c0250e2fa2569909e2d83cda1534c79443",
-                "sha256:c3f511a3c58676147c277eff0224c061dd5a6a8e1373572ac817ac6324f1b1e0",
-                "sha256:c9d317efde4bafbc1561509bfa8a23c5cab66c44d49ab5b63ff690f5159b2304",
-                "sha256:cc411ad324a4486b142c41d9b2b6a722c534096963688d879ea6fa8a35028258",
-                "sha256:cdc13a1682b2a6241080745b1953719e7fe0850b40a5c71ca574f090a1391df6",
-                "sha256:cfd7c5dd3c35c19cec59c63df9571c67c6d6e5c92e0fe63517920e97f61106d1",
-                "sha256:e1cacf4796b20865789083252186ce9dc6cc59eca0c2e79cca332bdff24ac481",
-                "sha256:e70d4e467e243455492f5de463b72151cc400710ac03a0678206a5f27e79ddef",
-                "sha256:ecc930ae559ea8a43377e8b60ca6f8d61ac532fc57efb915d899de4a67928efd",
-                "sha256:f161af26f596131b63b236372e4ce40f3167c1b5b5d459b29d2514bd8c9dc9ee"
-            ],
-            "index": "pypi",
-            "version": "==4.5.2"
+                "sha256:098fb713b31050463751dcc694878e1d39f316b86366fb9fe3fbbe5396ac9fab",
+                "sha256:0e89f5d422988c65e6936e4ec0fe54d6f73f3128c80eb7ecc3b87f595523607b",
+                "sha256:189ad47203e846a7a4951c17694d845b6ade7917c47c64b29b86526eefc3adf5",
+                "sha256:1d87936cb5801c557f3e981c9c193861264c01209cb3ad0964a16310ca1b3301",
+                "sha256:211b3bcf5da70c2d4b84d09232534ad1d78320762e2c59dedc73bf01cb1fc45b",
+                "sha256:2358809cc64394617f2719147a58ae26dac9e21bae772b45cfb80baa26bfca5d",
+                "sha256:23c83112b4dada0b75789d73f949dbb4e8f29a0a3511647024a398ebd023347b",
+                "sha256:24e811118aab6abe3ce23ff0d7d38932329c513f9cef849d3ee88b0f848f2aa9",
+                "sha256:2d5896ddf5389560257bbe89317ca7bcb4e54a02b53a3e572e1ce4226512b51b",
+                "sha256:2d6571c48328be4304aee031d2d5046cbc8aed5740c654575613c5a4f5a11311",
+                "sha256:2e311a10f3e85250910a615fe194839a04a0f6bc4e8e5bb5cac221344e3a7891",
+                "sha256:302160eb6e9764168e01d8c9ec6becddeb87776e81d3fcb0d97954dd51d48e0a",
+                "sha256:3a7a380bfecc551cfd67d6e8ad9faa91289173bdf12e9cfafbd2bdec0d7b1ec1",
+                "sha256:3d9b2b72eb0dbbdb0e276403873ecfae870599c83ba22cadff2db58541e72856",
+                "sha256:475325e037fdf068e0c2140b818518cf6bc4aa72435c407a798b2db9f8e90810",
+                "sha256:4b7572145054330c8e324a72d808c8c8fbe12be33368db28c39a255ad5f7fb51",
+                "sha256:4fff34721b628cce9eb4538cf9a73d02e0f3da4f35a515773cce6f5fe413b360",
+                "sha256:56eff8c6fb7bc4bcca395fdff494c52712b7a57486e4fbde34c31bb9da4c6cc4",
+                "sha256:573b2f5496c7e9f4985de70b9bbb4719ffd293d5565513e04ac20e42e6e5583f",
+                "sha256:7ecaef52fd9b9535ae5f01a1dd2651f6608e4ec9dc136fc4dfe7ebe3c3ddb230",
+                "sha256:803a80d72d1f693aa448566be46ffd70882d1ad8fc689a2e22afe63035eb998a",
+                "sha256:8862d1c2c020cb7a03b421a9a7b4fe046a208db30994fc8ff68c627a7915987f",
+                "sha256:9b06690224258db5cd39a84e993882a6874676f5de582da57f3df3a82ead9174",
+                "sha256:a71400b90b3599eb7bf241f947932e18a066907bf84617d80817998cee81e4bf",
+                "sha256:bb252f802f91f59767dcc559744e91efa9df532240a502befd874b54571417bd",
+                "sha256:be1ebf9cc25ab5399501c9046a7dcdaa9e911802ed0e12b7d620cd4bbf0518b3",
+                "sha256:be7c65e34d1b50ab7093b90427cbc488260e4b3a38ef2435d65b62e9fa3d798a",
+                "sha256:c0dac835c1a22621ffa5e5f999d57359c790c52bbd1c687fe514ae6924f65ef5",
+                "sha256:c152b2e93b639d1f36ec5a8ca24cde4a8eefb2b6b83668fcd8e83a67badcb367",
+                "sha256:d182eada8ea0de61a45a526aa0ae4bcd222f9673424e65315c35820291ff299c",
+                "sha256:d18331ea905a41ae71596502bd4c9a2998902328bbabd29e3d0f5f8569fabad1",
+                "sha256:d20d32cbb31d731def4b1502294ca2ee99f9249b63bc80e03e67e8f8e126dea8",
+                "sha256:d4ad7fd3269281cb471ad6c7bafca372e69789540d16e3755dd717e9e5c9d82f",
+                "sha256:d6f8c23f65a4bfe4300b85f1f40f6c32569822d08901db3b6454ab785d9117cc",
+                "sha256:d84d741c6e35c9f3e7406cb7c4c2e08474c2a6441d59322a00dcae65aac6315d",
+                "sha256:e65c221b2115a91035b55a593b6eb94aa1206fa3ab374f47c6dc10d364583ff9",
+                "sha256:f98b6f256be6cec8dd308a8563976ddaff0bdc18b730720f6f4bee927ffe926f"
+            ],
+            "index": "pypi",
+            "version": "==4.6.1"
         },
         "markdownify": {
             "hashes": [
@@ -374,52 +343,13 @@
             "index": "pypi",
             "version": "==0.5.3"
         },
-        "markupsafe": {
-            "hashes": [
-                "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473",
-                "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161",
-                "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235",
-                "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5",
-                "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42",
-                "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff",
-                "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b",
-                "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1",
-                "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e",
-                "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183",
-                "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66",
-                "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b",
-                "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1",
-                "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15",
-                "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1",
-                "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e",
-                "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b",
-                "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905",
-                "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735",
-                "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d",
-                "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e",
-                "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d",
-                "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c",
-                "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21",
-                "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2",
-                "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5",
-                "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b",
-                "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6",
-                "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f",
-                "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f",
-                "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2",
-                "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7",
-                "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"
-            ],
-            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
-            "version": "==1.1.1"
-        },
         "more-itertools": {
             "hashes": [
-                "sha256:6f83822ae94818eae2612063a5101a7311e68ae8002005b5e05f03fd74a86a20",
-                "sha256:9b30f12df9393f0d28af9210ff8efe48d10c94f73e5daf886f10c4b0b0b4f03c"
+                "sha256:8e1a2a43b2f2727425f2b5839587ae37093f19153dc26c0927d1048ff6557330",
+                "sha256:b3a9005928e5bed54076e6e549c792b306fddfe72b2d1d22dd63d42d5d3899cf"
             ],
             "index": "pypi",
-            "version": "==8.5.0"
+            "version": "==8.6.0"
         },
         "multidict": {
             "hashes": [
@@ -451,14 +381,6 @@
             "markers": "python_version >= '3.5'",
             "version": "==4.0.2"
         },
-        "packaging": {
-            "hashes": [
-                "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8",
-                "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"
-            ],
-            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
-            "version": "==20.4"
-        },
         "pamqp": {
             "hashes": [
                 "sha256:2f81b5c186f668a67f165193925b6bfd83db4363a6222f599517f29ecee60b02",
@@ -508,21 +430,14 @@
             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
             "version": "==2.20"
         },
-        "pygments": {
-            "hashes": [
-                "sha256:307543fe65c0947b126e83dd5a61bd8acbd84abec11f43caebaf5534cbc17998",
-                "sha256:926c3f319eda178d1bd90851e4317e6d8cdb5e292a3386aac9bd75eca29cf9c7"
-            ],
-            "markers": "python_version >= '3.5'",
-            "version": "==2.7.1"
-        },
-        "pyparsing": {
+        "pyreadline": {
             "hashes": [
-                "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1",
-                "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"
+                "sha256:4530592fc2e85b25b1a9f79664433da09237c1a270e4d78ea5aa3a2c7229e2d1",
+                "sha256:65540c21bfe14405a3a77e4c085ecfce88724743a4ead47c66b84defcf82c32e",
+                "sha256:9ce5fa65b8992dfa373bddc5b6e0864ead8f291c94fbfec05fbd5c836162e67b"
             ],
-            "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
-            "version": "==2.4.7"
+            "markers": "sys_platform == 'win32'",
+            "version": "==2.1"
         },
         "python-dateutil": {
             "hashes": [
@@ -532,13 +447,6 @@
             "index": "pypi",
             "version": "==2.8.1"
         },
-        "pytz": {
-            "hashes": [
-                "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed",
-                "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048"
-            ],
-            "version": "==2020.1"
-        },
         "pyyaml": {
             "hashes": [
                 "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97",
@@ -564,21 +472,13 @@
             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
             "version": "==3.5.3"
         },
-        "requests": {
-            "hashes": [
-                "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b",
-                "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898"
-            ],
-            "index": "pypi",
-            "version": "==2.24.0"
-        },
         "sentry-sdk": {
             "hashes": [
-                "sha256:c9c0fa1412bad87104c4eee8dd36c7bbf60b0d92ae917ab519094779b22e6d9a",
-                "sha256:e159f7c919d19ae86e5a4ff370fccc45149fab461fbeb93fb5a735a0b33a9cb1"
+                "sha256:17b725df2258354ccb39618ae4ead29651aa92c01a92acf72f98efe06ee2e45a",
+                "sha256:9040539485226708b5cad0401d76628fba4eed9154bf301c50579767afe344fd"
             ],
             "index": "pypi",
-            "version": "==0.17.8"
+            "version": "==0.19.2"
         },
         "six": {
             "hashes": [
@@ -588,19 +488,12 @@
             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
             "version": "==1.15.0"
         },
-        "snowballstemmer": {
-            "hashes": [
-                "sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0",
-                "sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52"
-            ],
-            "version": "==2.0.0"
-        },
         "sortedcontainers": {
             "hashes": [
-                "sha256:4e73a757831fc3ca4de2859c422564239a31d8213d09a2a666e375807034d2ba",
-                "sha256:c633ebde8580f241f274c1f8994a665c0e54a17724fecd0cae2f079e09c36d3f"
+                "sha256:37257a32add0a3ee490bb170b599e93095eed89a55da91fa9f48753ea12fd73f",
+                "sha256:59cc937650cf60d677c16775597c89a960658a09cf7c1a668f86e1e4464b10a1"
             ],
-            "version": "==2.2.2"
+            "version": "==2.3.0"
         },
         "soupsieve": {
             "hashes": [
@@ -610,62 +503,6 @@
             "markers": "python_version >= '3.0'",
             "version": "==2.0.1"
         },
-        "sphinx": {
-            "hashes": [
-                "sha256:b4c750d546ab6d7e05bdff6ac24db8ae3e8b8253a3569b754e445110a0a12b66",
-                "sha256:fc312670b56cb54920d6cc2ced455a22a547910de10b3142276495ced49231cb"
-            ],
-            "index": "pypi",
-            "version": "==2.4.4"
-        },
-        "sphinxcontrib-applehelp": {
-            "hashes": [
-                "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a",
-                "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58"
-            ],
-            "markers": "python_version >= '3.5'",
-            "version": "==1.0.2"
-        },
-        "sphinxcontrib-devhelp": {
-            "hashes": [
-                "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e",
-                "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"
-            ],
-            "markers": "python_version >= '3.5'",
-            "version": "==1.0.2"
-        },
-        "sphinxcontrib-htmlhelp": {
-            "hashes": [
-                "sha256:3c0bc24a2c41e340ac37c85ced6dafc879ab485c095b1d65d2461ac2f7cca86f",
-                "sha256:e8f5bb7e31b2dbb25b9cc435c8ab7a79787ebf7f906155729338f3156d93659b"
-            ],
-            "markers": "python_version >= '3.5'",
-            "version": "==1.0.3"
-        },
-        "sphinxcontrib-jsmath": {
-            "hashes": [
-                "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178",
-                "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"
-            ],
-            "markers": "python_version >= '3.5'",
-            "version": "==1.0.1"
-        },
-        "sphinxcontrib-qthelp": {
-            "hashes": [
-                "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72",
-                "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"
-            ],
-            "markers": "python_version >= '3.5'",
-            "version": "==1.0.3"
-        },
-        "sphinxcontrib-serializinghtml": {
-            "hashes": [
-                "sha256:eaa0eccc86e982a9b939b2b82d12cc5d013385ba5eadcc7e4fed23f4405f77bc",
-                "sha256:f242a81d423f59617a8e5cf16f5d4d74e28ee9a66f9e5b637a18082991db5a9a"
-            ],
-            "markers": "python_version >= '3.5'",
-            "version": "==1.1.4"
-        },
         "statsd": {
             "hashes": [
                 "sha256:c610fb80347fca0ef62666d241bce64184bd7cc1efe582f9690e045c25535eaa",
@@ -676,34 +513,34 @@
         },
         "urllib3": {
             "hashes": [
-                "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a",
-                "sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461"
+                "sha256:8d7eaa5a82a1cac232164990f04874c594c9453ec55eef02eab885aa02fc17a2",
+                "sha256:f5321fbe4bf3fefa0efd0bfe7fb14e90909eb62a48ccda331726b4319897dd5e"
             ],
             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
-            "version": "==1.25.10"
+            "version": "==1.25.11"
         },
         "yarl": {
             "hashes": [
-                "sha256:04a54f126a0732af75e5edc9addeaa2113e2ca7c6fce8974a63549a70a25e50e",
-                "sha256:3cc860d72ed989f3b1f3abbd6ecf38e412de722fb38b8f1b1a086315cf0d69c5",
-                "sha256:5d84cc36981eb5a8533be79d6c43454c8e6a39ee3118ceaadbd3c029ab2ee580",
-                "sha256:5e447e7f3780f44f890360ea973418025e8c0cdcd7d6a1b221d952600fd945dc",
-                "sha256:61d3ea3c175fe45f1498af868879c6ffeb989d4143ac542163c45538ba5ec21b",
-                "sha256:67c5ea0970da882eaf9efcf65b66792557c526f8e55f752194eff8ec722c75c2",
-                "sha256:6f6898429ec3c4cfbef12907047136fd7b9e81a6ee9f105b45505e633427330a",
-                "sha256:7ce35944e8e61927a8f4eb78f5bc5d1e6da6d40eadd77e3f79d4e9399e263921",
-                "sha256:b7c199d2cbaf892ba0f91ed36d12ff41ecd0dde46cbf64ff4bfe997a3ebc925e",
-                "sha256:c15d71a640fb1f8e98a1423f9c64d7f1f6a3a168f803042eaf3a5b5022fde0c1",
-                "sha256:c22607421f49c0cb6ff3ed593a49b6a99c6ffdeaaa6c944cdda83c2393c8864d",
-                "sha256:c604998ab8115db802cc55cb1b91619b2831a6128a62ca7eea577fc8ea4d3131",
-                "sha256:d088ea9319e49273f25b1c96a3763bf19a882cff774d1792ae6fba34bd40550a",
-                "sha256:db9eb8307219d7e09b33bcb43287222ef35cbcf1586ba9472b0a4b833666ada1",
-                "sha256:e31fef4e7b68184545c3d68baec7074532e077bd1906b040ecfba659737df188",
-                "sha256:e32f0fb443afcfe7f01f95172b66f279938fbc6bdaebe294b0ff6747fb6db020",
-                "sha256:fcbe419805c9b20db9a51d33b942feddbf6e7fb468cb20686fd7089d4164c12a"
+                "sha256:040b237f58ff7d800e6e0fd89c8439b841f777dd99b4a9cca04d6935564b9409",
+                "sha256:17668ec6722b1b7a3a05cc0167659f6c95b436d25a36c2d52db0eca7d3f72593",
+                "sha256:3a584b28086bc93c888a6c2aa5c92ed1ae20932f078c46509a66dce9ea5533f2",
+                "sha256:4439be27e4eee76c7632c2427ca5e73703151b22cae23e64adb243a9c2f565d8",
+                "sha256:48e918b05850fffb070a496d2b5f97fc31d15d94ca33d3d08a4f86e26d4e7c5d",
+                "sha256:9102b59e8337f9874638fcfc9ac3734a0cfadb100e47d55c20d0dc6087fb4692",
+                "sha256:9b930776c0ae0c691776f4d2891ebc5362af86f152dd0da463a6614074cb1b02",
+                "sha256:b3b9ad80f8b68519cc3372a6ca85ae02cc5a8807723ac366b53c0f089db19e4a",
+                "sha256:bc2f976c0e918659f723401c4f834deb8a8e7798a71be4382e024bcc3f7e23a8",
+                "sha256:c22c75b5f394f3d47105045ea551e08a3e804dc7e01b37800ca35b58f856c3d6",
+                "sha256:c52ce2883dc193824989a9b97a76ca86ecd1fa7955b14f87bf367a61b6232511",
+                "sha256:ce584af5de8830d8701b8979b18fcf450cef9a382b1a3c8ef189bedc408faf1e",
+                "sha256:da456eeec17fa8aa4594d9a9f27c0b1060b6a75f2419fe0c00609587b2695f4a",
+                "sha256:db6db0f45d2c63ddb1a9d18d1b9b22f308e52c83638c26b422d520a815c4b3fb",
+                "sha256:df89642981b94e7db5596818499c4b2219028f2a528c9c37cc1de45bf2fd3a3f",
+                "sha256:f18d68f2be6bf0e89f1521af2b1bb46e66ab0018faafa81d70f358153170a317",
+                "sha256:f379b7f83f23fe12823085cd6b906edc49df969eb99757f58ff382349a3303c6"
             ],
             "markers": "python_version >= '3.5'",
-            "version": "==1.6.0"
+            "version": "==1.5.1"
         }
     },
     "develop": {
@@ -716,11 +553,11 @@
         },
         "attrs": {
             "hashes": [
-                "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594",
-                "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc"
+                "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6",
+                "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"
             ],
             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
-            "version": "==20.2.0"
+            "version": "==20.3.0"
         },
         "cfgv": {
             "hashes": [
@@ -786,19 +623,19 @@
         },
         "flake8": {
             "hashes": [
-                "sha256:15e351d19611c887e482fb960eae4d44845013cc142d42896e9862f775d8cf5c",
-                "sha256:f04b9fcbac03b0a3e58c0ab3a0ecc462e023a9faf046d57794184028123aa208"
+                "sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839",
+                "sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b"
             ],
             "index": "pypi",
-            "version": "==3.8.3"
+            "version": "==3.8.4"
         },
         "flake8-annotations": {
             "hashes": [
-                "sha256:09fe1aa3f40cb8fef632a0ab3614050a7584bb884b6134e70cf1fc9eeee642fa",
-                "sha256:5bda552f074fd6e34276c7761756fa07d824ffac91ce9c0a8555eb2bc5b92d7a"
+                "sha256:0bcebb0792f1f96d617ded674dca7bf64181870bfe5dace353a1483551f8e5f1",
+                "sha256:bebd11a850f6987a943ce8cdff4159767e0f5f89b3c88aca64680c2175ee02df"
             ],
             "index": "pypi",
-            "version": "==2.4.0"
+            "version": "==2.4.1"
         },
         "flake8-bugbear": {
             "hashes": [
@@ -856,11 +693,11 @@
         },
         "identify": {
             "hashes": [
-                "sha256:7c22c384a2c9b32c5cc891d13f923f6b2653aa83e2d75d8f79be240d6c86c4f4",
-                "sha256:da683bfb7669fa749fc7731f378229e2dbf29a1d1337cbde04106f02236eb29d"
+                "sha256:5dd84ac64a9a115b8e0b27d1756b244b882ad264c3c423f42af8235a6e71ca12",
+                "sha256:c9504ba6a043ee2db0a9d69e43246bc138034895f6338d5aed1b41e4a73b1513"
             ],
             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
-            "version": "==1.5.5"
+            "version": "==1.5.9"
         },
         "mccabe": {
             "hashes": [
@@ -886,11 +723,11 @@
         },
         "pre-commit": {
             "hashes": [
-                "sha256:810aef2a2ba4f31eed1941fc270e72696a1ad5590b9751839c90807d0fff6b9a",
-                "sha256:c54fd3e574565fe128ecc5e7d2f91279772ddb03f8729645fa812fe809084a70"
+                "sha256:22e6aa3bd571debb01eb7d34483f11c01b65237be4eebbf30c3d4fb65762d315",
+                "sha256:905ebc9b534b991baec87e934431f2d0606ba27f2b90f7f652985f5a5b8b6ae6"
             ],
             "index": "pypi",
-            "version": "==2.7.1"
+            "version": "==2.8.2"
         },
         "pycodestyle": {
             "hashes": [
@@ -950,10 +787,11 @@
         },
         "toml": {
             "hashes": [
-                "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f",
-                "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"
+                "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b",
+                "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"
             ],
-            "version": "==0.10.1"
+            "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+            "version": "==0.10.2"
         },
         "unittest-xml-reporting": {
             "hashes": [
@@ -965,11 +803,11 @@
         },
         "virtualenv": {
             "hashes": [
-                "sha256:43add625c53c596d38f971a465553f6318decc39d98512bc100fa1b1e839c8dc",
-                "sha256:e0305af10299a7fb0d69393d8f04cb2965dda9351140d11ac8db4e5e3970451b"
+                "sha256:b0011228208944ce71052987437d3843e05690b2f23d1c7da4263fde104c97a2",
+                "sha256:b8d6110f493af256a40d65e29846c69340a947669eec8ce784fcf3dd3af28380"
             ],
             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
-            "version": "==20.0.31"
+            "version": "==20.1.0"
         }
     }
 }
-- 
cgit v1.2.3
From 70ee01b8726921e8389abd4f69ffb0e2ceee0773 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Tue, 10 Nov 2020 18:22:11 +0100
Subject: Generalise tag filter hint to accept all containers
---
 bot/exts/info/doc/_parsing.py | 10 +++++-----
 1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/bot/exts/info/doc/_parsing.py b/bot/exts/info/doc/_parsing.py
index 0883b9f42..93b6f0def 100644
--- a/bot/exts/info/doc/_parsing.py
+++ b/bot/exts/info/doc/_parsing.py
@@ -5,7 +5,7 @@ import re
 import string
 import textwrap
 from functools import partial
-from typing import Callable, Collection, Iterable, List, Optional, TYPE_CHECKING, Tuple, Union
+from typing import Callable, Collection, Container, Iterable, List, Optional, TYPE_CHECKING, Union
 
 from bs4 import BeautifulSoup
 from bs4.element import NavigableString, PageElement, Tag
@@ -99,7 +99,7 @@ def _split_parameters(parameters_string: str) -> List[str]:
 
 def _find_elements_until_tag(
         start_element: PageElement,
-        end_tag_filter: Union[Tuple[str, ...], Callable[[Tag], bool]],
+        end_tag_filter: Union[Container[str], Callable[[Tag], bool]],
         *,
         func: Callable,
         include_strings: bool = False,
@@ -108,7 +108,7 @@ def _find_elements_until_tag(
     """
     Get all elements up to `limit` or until a tag matching `tag_filter` is found.
 
-    `end_tag_filter` can be either a tuple of string names to check against,
+    `end_tag_filter` can be either a container of string names to check against,
     or a filtering callable that's applied to tags.
 
     When `include_strings` is True, `NavigableString`s from the document will be included in the result along `Tag`s.
@@ -116,12 +116,12 @@ def _find_elements_until_tag(
     `func` takes in a BeautifulSoup unbound method for finding multiple elements, such as `BeautifulSoup.find_all`.
     The method is then iterated over and all elements until the matching tag or the limit are added to the return list.
     """
-    use_tuple_filter = isinstance(end_tag_filter, tuple)
+    use_container_filter = not callable(end_tag_filter)
     elements = []
 
     for element in func(start_element, name=Strainer(include_strings=include_strings), limit=limit):
         if isinstance(element, Tag):
-            if use_tuple_filter:
+            if use_container_filter:
                 if element.name in end_tag_filter:
                     break
             elif end_tag_filter(element):
-- 
cgit v1.2.3
From beebeac45cf487e59ca4d76a84472c898bc23b06 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Tue, 10 Nov 2020 19:20:44 +0100
Subject: Rename variables for clarity
---
 bot/exts/info/doc/_cog.py     |  4 ++--
 bot/exts/info/doc/_parsing.py | 18 +++++++++---------
 2 files changed, 11 insertions(+), 11 deletions(-)
diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py
index 25477fe07..4e48e81e5 100644
--- a/bot/exts/info/doc/_cog.py
+++ b/bot/exts/info/doc/_cog.py
@@ -227,8 +227,8 @@ class DocCog(commands.Cog):
                         symbol = f"{group_name}.{symbol}"
                         self.renamed_symbols.add(symbol)
 
-                    elif (overridden_symbol_group := original_symbol.group) in FORCE_PREFIX_GROUPS:
-                        overridden_symbol = f"{overridden_symbol_group}.{symbol}"
+                    elif (original_symbol_group := original_symbol.group) in FORCE_PREFIX_GROUPS:
+                        overridden_symbol = f"{original_symbol_group}.{symbol}"
                         if overridden_symbol in self.renamed_symbols:
                             overridden_symbol = f"{api_package_name}.{overridden_symbol}"
 
diff --git a/bot/exts/info/doc/_parsing.py b/bot/exts/info/doc/_parsing.py
index 93b6f0def..9140f635a 100644
--- a/bot/exts/info/doc/_parsing.py
+++ b/bot/exts/info/doc/_parsing.py
@@ -42,9 +42,9 @@ _NO_SIGNATURE_GROUPS = {
     "templatetag",
     "term",
 }
-_EMBED_CODE_BLOCK_LENGTH = 61
+_EMBED_CODE_BLOCK_LINE_LENGTH = 61
 # _MAX_SIGNATURE_AMOUNT code block wrapped lines with py syntax highlight
-_MAX_SIGNATURES_LENGTH = (_EMBED_CODE_BLOCK_LENGTH + 8) * _MAX_SIGNATURE_AMOUNT
+_MAX_SIGNATURES_LENGTH = (_EMBED_CODE_BLOCK_LINE_LENGTH + 8) * _MAX_SIGNATURE_AMOUNT
 # Maximum discord message length - signatures on top
 _MAX_DESCRIPTION_LENGTH = 2000 - _MAX_SIGNATURES_LENGTH
 _TRUNCATE_STRIP_CHARACTERS = "!?:;." + string.whitespace
@@ -189,7 +189,7 @@ def _truncate_signatures(signatures: Collection[str]) -> Union[List[str], Collec
     if not sum(len(signature) for signature in signatures) > _MAX_SIGNATURES_LENGTH:
         return signatures
 
-    max_signature_length = _EMBED_CODE_BLOCK_LENGTH * (_MAX_SIGNATURE_AMOUNT + 1 - len(signatures))
+    max_signature_length = _EMBED_CODE_BLOCK_LINE_LENGTH * (_MAX_SIGNATURE_AMOUNT + 1 - len(signatures))
     formatted_signatures = []
     for signature in signatures:
         signature = signature.strip()
@@ -221,12 +221,12 @@ def _get_truncated_description(
         max_length: int,
 ) -> str:
     """
-    Truncate markdown from `elements` to be at most `max_length` characters visually.
+    Truncate markdown from `elements` to be at most `max_length` characters when rendered.
 
     `max_length` limits the length of the rendered characters in the string,
     with the real string length limited to `_MAX_DESCRIPTION_LENGTH` to accommodate discord length limits
     """
-    visual_length = 0
+    rendered_length = 0
     real_length = 0
     result = []
     shortened = False
@@ -234,7 +234,7 @@ def _get_truncated_description(
     for element in elements:
         is_tag = isinstance(element, Tag)
         element_length = len(element.text) if is_tag else len(element)
-        if visual_length + element_length < max_length:
+        if rendered_length + element_length < max_length:
             if is_tag:
                 element_markdown = markdown_converter.process_tag(element)
             else:
@@ -247,7 +247,7 @@ def _get_truncated_description(
                 shortened = True
                 break
             real_length += element_markdown_length
-            visual_length += element_length
+            rendered_length += element_length
         else:
             shortened = True
             break
@@ -258,7 +258,7 @@ def _get_truncated_description(
     return markdown_string
 
 
-def _parse_into_markdown(signatures: Optional[List[str]], description: Iterable[Tag], url: str) -> str:
+def _create_markdown(signatures: Optional[List[str]], description: Iterable[Tag], url: str) -> str:
     """
     Create a markdown string with the signatures at the top, and the converted html description below them.
 
@@ -309,4 +309,4 @@ def get_symbol_markdown(soup: BeautifulSoup, symbol_data: DocItem) -> str:
     else:
         signature = _get_signatures(symbol_heading)
         description = _get_dd_description(symbol_heading)
-    return _parse_into_markdown(signature, description, symbol_data.url).replace('¶', '')
+    return _create_markdown(signature, description, symbol_data.url).replace('¶', '')
-- 
cgit v1.2.3
From 7348b86bfedfc24c67d97a08d839a18956a6bff6 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Tue, 10 Nov 2020 22:17:15 +0100
Subject: Update outdated docstring
---
 bot/exts/info/doc/_parsing.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/bot/exts/info/doc/_parsing.py b/bot/exts/info/doc/_parsing.py
index 9140f635a..82b2ca808 100644
--- a/bot/exts/info/doc/_parsing.py
+++ b/bot/exts/info/doc/_parsing.py
@@ -263,7 +263,7 @@ def _create_markdown(signatures: Optional[List[str]], description: Iterable[Tag]
     Create a markdown string with the signatures at the top, and the converted html description below them.
 
     The signatures are wrapped in python codeblocks, separated from the description by a newline.
-    The result string is truncated to be max 1000 symbols long.
+    The result markdown string is max 750 rendered characters for the description with signatures at the start.
     """
     description = _get_truncated_description(description, DocMarkdownConverter(bullets="•", page_url=url), 750)
     description = _WHITESPACE_AFTER_NEWLINES_RE.sub('', description)
-- 
cgit v1.2.3
From ddb6b11575c05c8417f5607aec98fb1c09e351af Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Tue, 10 Nov 2020 22:22:27 +0100
Subject: Adjust unparseable symbol behaviour
With redis we need to make sure we don't send the "error"
string into the cache, returning None instead of the string
and then setting it manually in the caller makes this nicer
compared to checking against a string
---
 bot/exts/info/doc/_cog.py     | 5 ++++-
 bot/exts/info/doc/_parsing.py | 4 ++--
 2 files changed, 6 insertions(+), 3 deletions(-)
diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py
index 4e48e81e5..fa59bcc42 100644
--- a/bot/exts/info/doc/_cog.py
+++ b/bot/exts/info/doc/_cog.py
@@ -292,7 +292,10 @@ class DocCog(commands.Cog):
         if markdown is None:
             log.debug(f"Redis cache miss for symbol `{symbol}`.")
             markdown = await self.item_fetcher.get_markdown(self.bot.http_session, symbol_info)
-            await self.doc_cache.set(redis_key, markdown)
+            if markdown is not None:
+                await self.doc_cache.set(redis_key, markdown)
+            else:
+                markdown = "Unable to parse the requested symbol."
 
         embed = discord.Embed(
             title=discord.utils.escape_markdown(symbol),
diff --git a/bot/exts/info/doc/_parsing.py b/bot/exts/info/doc/_parsing.py
index 82b2ca808..72e81982a 100644
--- a/bot/exts/info/doc/_parsing.py
+++ b/bot/exts/info/doc/_parsing.py
@@ -287,7 +287,7 @@ def _class_filter_factory(class_names: Iterable[str]) -> Callable[[Tag], bool]:
     return match_tag
 
 
-def get_symbol_markdown(soup: BeautifulSoup, symbol_data: DocItem) -> str:
+def get_symbol_markdown(soup: BeautifulSoup, symbol_data: DocItem) -> Optional[str]:
     """
     Return parsed markdown of the passed symbol using the passed in soup, truncated to 1000 characters.
 
@@ -296,7 +296,7 @@ def get_symbol_markdown(soup: BeautifulSoup, symbol_data: DocItem) -> str:
     symbol_heading = soup.find(id=symbol_data.symbol_id)
     if symbol_heading is None:
         log.warning("Symbol present in loaded inventories not found on site, consider refreshing inventories.")
-        return "Unable to parse the requested symbol."
+        return None
     signature = None
     # Modules, doc pages and labels don't point to description list tags but to tags like divs,
     # no special parsing can be done so we only try to include what's under them.
-- 
cgit v1.2.3
From d936e5bc049e2e93beca3c62430d048d9f9cf47b Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Wed, 11 Nov 2020 18:23:01 +0100
Subject: Cancel scheduled inventory updates on all refreshes
---
 bot/exts/info/doc/_cog.py | 8 ++------
 1 file changed, 2 insertions(+), 6 deletions(-)
diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py
index fa59bcc42..822f682bf 100644
--- a/bot/exts/info/doc/_cog.py
+++ b/bot/exts/info/doc/_cog.py
@@ -250,6 +250,8 @@ class DocCog(commands.Cog):
     async def refresh_inventory(self) -> None:
         """Refresh internal documentation inventory."""
         log.debug("Refreshing documentation inventory...")
+        for inventory in self.scheduled_inventories:
+            self.inventory_scheduler.cancel(inventory)
 
         # Clear the old base URLS and doc symbols to ensure
         # that we start from a fresh local dataset.
@@ -418,9 +420,6 @@ class DocCog(commands.Cog):
         """
         await self.bot.api_client.delete(f'bot/documentation-links/{package_name}')
 
-        if package_name in self.scheduled_inventories:
-            self.inventory_scheduler.cancel(package_name)
-
         async with ctx.typing():
             # Rebuild the inventory to ensure that everything
             # that was from this package is properly deleted.
@@ -431,9 +430,6 @@ class DocCog(commands.Cog):
     @commands.has_any_role(*MODERATION_ROLES)
     async def refresh_command(self, ctx: commands.Context) -> None:
         """Refresh inventories and send differences to channel."""
-        for inventory in self.scheduled_inventories:
-            self.inventory_scheduler.cancel(inventory)
-
         old_inventories = set(self.base_urls)
         with ctx.typing():
             await self.refresh_inventory()
-- 
cgit v1.2.3
From 2bae8eeed0eae75d782da097e78826650e1ac498 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Thu, 12 Nov 2020 19:44:26 +0100
Subject: Intern relative url paths
Group name interning was also moved to the DocItem creation
to group the behaviour
---
 bot/exts/info/doc/_cog.py | 13 +++++++++----
 1 file changed, 9 insertions(+), 4 deletions(-)
diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py
index 822f682bf..ecc648d89 100644
--- a/bot/exts/info/doc/_cog.py
+++ b/bot/exts/info/doc/_cog.py
@@ -218,10 +218,8 @@ class DocCog(commands.Cog):
             for symbol, relative_doc_url in items:
                 if "/" in symbol:
                     continue  # skip unreachable symbols with slashes
-                # Intern the group names since they're reused in all the DocItems
-                # to remove unnecessary memory consumption from them being unique objects
-                group_name = sys.intern(group.split(":")[1])
 
+                group_name = group.split(":")[1]
                 if (original_symbol := self.doc_symbols.get(symbol)) is not None:
                     if group_name in FORCE_PREFIX_GROUPS:
                         symbol = f"{group_name}.{symbol}"
@@ -240,7 +238,14 @@ class DocCog(commands.Cog):
                         self.renamed_symbols.add(symbol)
 
                 relative_url_path, _, symbol_id = relative_doc_url.partition("#")
-                symbol_item = DocItem(api_package_name, group_name, base_url, relative_url_path, symbol_id)
+                # Intern fields that have shared content so we're not storing unique strings for every object
+                symbol_item = DocItem(
+                    api_package_name,
+                    sys.intern(group_name),
+                    base_url,
+                    sys.intern(relative_url_path),
+                    symbol_id
+                )
                 self.doc_symbols[symbol] = symbol_item
                 self.item_fetcher.add_item(symbol_item)
 
-- 
cgit v1.2.3
From aeac77a08cdafadcc180a400c32ce21732d7d20d Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Sat, 14 Nov 2020 02:39:07 +0100
Subject: Limit newlines in doc descriptions
---
 bot/exts/info/doc/_parsing.py | 48 ++++++++++++++++++++++++++++---------------
 1 file changed, 32 insertions(+), 16 deletions(-)
diff --git a/bot/exts/info/doc/_parsing.py b/bot/exts/info/doc/_parsing.py
index 72e81982a..418405ca9 100644
--- a/bot/exts/info/doc/_parsing.py
+++ b/bot/exts/info/doc/_parsing.py
@@ -10,6 +10,7 @@ from typing import Callable, Collection, Container, Iterable, List, Optional, TY
 from bs4 import BeautifulSoup
 from bs4.element import NavigableString, PageElement, Tag
 
+from bot.utils.helpers import find_nth_occurrence
 from ._html import Strainer
 from ._markdown import DocMarkdownConverter
 if TYPE_CHECKING:
@@ -219,21 +220,23 @@ def _get_truncated_description(
         elements: Iterable[Union[Tag, NavigableString]],
         markdown_converter: DocMarkdownConverter,
         max_length: int,
+        max_lines: int,
 ) -> str:
     """
-    Truncate markdown from `elements` to be at most `max_length` characters when rendered.
+    Truncate markdown from `elements` to be at most `max_length` characters when rendered or `max_lines` newlines.
 
     `max_length` limits the length of the rendered characters in the string,
     with the real string length limited to `_MAX_DESCRIPTION_LENGTH` to accommodate discord length limits
     """
+    result = ""
+    markdown_element_ends = []
     rendered_length = 0
-    real_length = 0
-    result = []
-    shortened = False
 
+    tag_end_index = 0
     for element in elements:
         is_tag = isinstance(element, Tag)
         element_length = len(element.text) if is_tag else len(element)
+
         if rendered_length + element_length < max_length:
             if is_tag:
                 element_markdown = markdown_converter.process_tag(element)
@@ -241,21 +244,29 @@ def _get_truncated_description(
                 element_markdown = markdown_converter.process_text(element)
 
             element_markdown_length = len(element_markdown)
-            if real_length + element_markdown_length < _MAX_DESCRIPTION_LENGTH:
-                result.append(element_markdown)
-            else:
-                shortened = True
-                break
-            real_length += element_markdown_length
             rendered_length += element_length
+            tag_end_index += element_markdown_length
+
+            if not element_markdown.isspace():
+                markdown_element_ends.append(tag_end_index)
+            result += element_markdown
         else:
-            shortened = True
             break
 
-    markdown_string = "".join(result)
-    if shortened:
-        markdown_string = markdown_string.rstrip(_TRUNCATE_STRIP_CHARACTERS) + "..."
-    return markdown_string
+    if not markdown_element_ends:
+        return ""
+
+    newline_truncate_index = find_nth_occurrence(result, "\n", max_lines)
+    if newline_truncate_index is not None and newline_truncate_index < _MAX_DESCRIPTION_LENGTH:
+        truncate_index = newline_truncate_index
+    else:
+        truncate_index = _MAX_DESCRIPTION_LENGTH
+
+    if truncate_index >= markdown_element_ends[-1]:
+        return result
+
+    markdown_truncate_index = max(cut for cut in markdown_element_ends if cut < truncate_index)
+    return result[:markdown_truncate_index].strip(_TRUNCATE_STRIP_CHARACTERS) + "..."
 
 
 def _create_markdown(signatures: Optional[List[str]], description: Iterable[Tag], url: str) -> str:
@@ -265,7 +276,12 @@ def _create_markdown(signatures: Optional[List[str]], description: Iterable[Tag]
     The signatures are wrapped in python codeblocks, separated from the description by a newline.
     The result markdown string is max 750 rendered characters for the description with signatures at the start.
     """
-    description = _get_truncated_description(description, DocMarkdownConverter(bullets="•", page_url=url), 750)
+    description = _get_truncated_description(
+        description,
+        markdown_converter=DocMarkdownConverter(bullets="•", page_url=url),
+        max_length=750,
+        max_lines=13
+    )
     description = _WHITESPACE_AFTER_NEWLINES_RE.sub('', description)
     if signatures is not None:
         formatted_markdown = "".join(f"```py\n{signature}```" for signature in _truncate_signatures(signatures))
-- 
cgit v1.2.3
From b118f4cf38bdf99cf66e822c5b2280aff879123d Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Sat, 14 Nov 2020 22:59:50 +0100
Subject: Rework the doc redis cache to work with hashes
This rework requires us to delete packages caches easily with
deleting the package hash instead of having to pattern match all
keys and delete those.
The interface was also updated to accept DocItems instead of requiring
callers to construct the keys
---
 bot/exts/info/doc/_cog.py         | 11 +++-----
 bot/exts/info/doc/_redis_cache.py | 57 +++++++++++++++++++++++++++++++++++----
 2 files changed, 56 insertions(+), 12 deletions(-)
diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py
index ecc648d89..67a21ed72 100644
--- a/bot/exts/info/doc/_cog.py
+++ b/bot/exts/info/doc/_cog.py
@@ -4,7 +4,6 @@ import asyncio
 import logging
 import re
 import sys
-import urllib.parse
 from collections import defaultdict
 from contextlib import suppress
 from typing import Dict, List, NamedTuple, Optional, Union
@@ -175,6 +174,7 @@ class DocCog(commands.Cog):
         self.scheduled_inventories = set()
 
         self.bot.loop.create_task(self.init_refresh_inventory())
+        self.bot.loop.create_task(self.doc_cache.delete_expired())
 
     async def init_refresh_inventory(self) -> None:
         """Refresh documentation inventory on cog initialization."""
@@ -292,21 +292,18 @@ class DocCog(commands.Cog):
             return None
         self.bot.stats.incr(f"doc_fetches.{symbol_info.package.lower()}")
 
-        item_url = f"{symbol_info.url}#{symbol_info.symbol_id}"
-        redis_key = "".join(urllib.parse.urlparse(item_url)[1:])  # url without scheme
-
-        markdown = await self.doc_cache.get(redis_key)
+        markdown = await self.doc_cache.get(symbol_info)
         if markdown is None:
             log.debug(f"Redis cache miss for symbol `{symbol}`.")
             markdown = await self.item_fetcher.get_markdown(self.bot.http_session, symbol_info)
             if markdown is not None:
-                await self.doc_cache.set(redis_key, markdown)
+                await self.doc_cache.set(symbol_info, markdown)
             else:
                 markdown = "Unable to parse the requested symbol."
 
         embed = discord.Embed(
             title=discord.utils.escape_markdown(symbol),
-            url=item_url,
+            url=f"{symbol_info.url}#{symbol_info.symbol_id}",
             description=markdown
         )
         # Show all symbols with the same name that were renamed in the footer.
diff --git a/bot/exts/info/doc/_redis_cache.py b/bot/exts/info/doc/_redis_cache.py
index 147394ba6..c617eba49 100644
--- a/bot/exts/info/doc/_redis_cache.py
+++ b/bot/exts/info/doc/_redis_cache.py
@@ -1,23 +1,70 @@
-from typing import Optional
+from __future__ import annotations
+
+import datetime
+import pickle
+from typing import Optional, TYPE_CHECKING
 
 from async_rediscache.types.base import RedisObject, namespace_lock
+if TYPE_CHECKING:
+    from ._cog import DocItem
 
 
 class DocRedisCache(RedisObject):
     """Interface for redis functionality needed by the Doc cog."""
 
     @namespace_lock
-    async def set(self, key: str, value: str) -> None:
+    async def set(self, item: DocItem, value: str) -> None:
         """
         Set markdown `value` for `key`.
 
         Keys expire after a week to keep data up to date.
         """
+        expiry_timestamp = datetime.datetime.now().timestamp() + 7 * 24 * 60 * 60
         with await self._get_pool_connection() as connection:
-            await connection.setex(f"{self.namespace}:{key}", 7*24*60*60, value)
+            await connection.hset(
+                f"{self.namespace}:{item.package}",
+                self.get_item_key(item),
+                pickle.dumps((value, expiry_timestamp))
+            )
 
     @namespace_lock
-    async def get(self, key: str) -> Optional[str]:
+    async def get(self, item: DocItem) -> Optional[str]:
         """Get markdown contents for `key`."""
         with await self._get_pool_connection() as connection:
-            return await connection.get(f"{self.namespace}:{key}", encoding="utf8")
+            cached_value = await connection.hget(f"{self.namespace}:{item.package}", self.get_item_key(item))
+            if cached_value is None:
+                return None
+
+            value, expire = pickle.loads(cached_value)
+            if expire <= datetime.datetime.now().timestamp():
+                await connection.hdel(f"{self.namespace}:{item.package}", self.get_item_key(item))
+                return None
+
+            return value
+
+    @namespace_lock
+    async def delete(self, package: str) -> None:
+        """Remove all values for `package`."""
+        with await self._get_pool_connection() as connection:
+            await connection.delete(f"{self.namespace}:{package}")
+
+    @namespace_lock
+    async def delete_expired(self) -> None:
+        """Delete all expired keys."""
+        current_timestamp = datetime.datetime.now().timestamp()
+        with await self._get_pool_connection() as connection:
+            async for package_key in connection.iscan(match=f"{self.namespace}*"):
+                expired_fields = []
+
+                for field, cached_value in (await connection.hgetall(package_key)).items():
+                    _, expire = pickle.loads(cached_value)
+                    if expire <= current_timestamp:
+                        expired_fields.append(field)
+
+                if expired_fields:
+                    await connection.hdel(package_key, *expired_fields)
+
+    @staticmethod
+    def get_item_key(item: DocItem) -> str:
+        """Create redis key for `item`."""
+        return item.relative_url_path + item.symbol_id
-- 
cgit v1.2.3
From 07a5d5fc58a402f930505c7b29a7a275e743a84d Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Sat, 14 Nov 2020 23:07:13 +0100
Subject: Update existing redis values when parsing pages
If we're parsing a page for a symbol that's out of the cache
and encounter a symbol that was already cached we can update that symbol
to keep it up to date without additional requests
---
 bot/exts/info/doc/_cog.py         | 14 ++++++++------
 bot/exts/info/doc/_redis_cache.py | 17 +++++++++++++++++
 2 files changed, 25 insertions(+), 6 deletions(-)
diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py
index 67a21ed72..678134f3c 100644
--- a/bot/exts/info/doc/_cog.py
+++ b/bot/exts/info/doc/_cog.py
@@ -36,6 +36,8 @@ FORCE_PREFIX_GROUPS = (
 WHITESPACE_AFTER_NEWLINES_RE = re.compile(r"(?<=\n\n)(\s+)")
 NOT_FOUND_DELETE_DELAY = RedirectOutput.delete_delay
 
+doc_cache = DocRedisCache(namespace="Docs")
+
 
 class DocItem(NamedTuple):
     """Holds inventory symbol information."""
@@ -116,7 +118,9 @@ class CachedParser:
         while self._queue:
             item, soup = self._queue.pop()
             try:
-                self._results[item] = get_symbol_markdown(soup, item)
+                markdown = get_symbol_markdown(soup, item)
+                await doc_cache.set_if_exists(item, markdown)
+                self._results[item] = markdown
             except Exception:
                 log.exception(f"Unexpected error when handling {item}")
             else:
@@ -161,8 +165,6 @@ class CachedParser:
 class DocCog(commands.Cog):
     """A set of commands for querying & displaying documentation."""
 
-    doc_cache = DocRedisCache()
-
     def __init__(self, bot: Bot):
         self.base_urls = {}
         self.bot = bot
@@ -174,7 +176,7 @@ class DocCog(commands.Cog):
         self.scheduled_inventories = set()
 
         self.bot.loop.create_task(self.init_refresh_inventory())
-        self.bot.loop.create_task(self.doc_cache.delete_expired())
+        self.bot.loop.create_task(doc_cache.delete_expired())
 
     async def init_refresh_inventory(self) -> None:
         """Refresh documentation inventory on cog initialization."""
@@ -292,12 +294,12 @@ class DocCog(commands.Cog):
             return None
         self.bot.stats.incr(f"doc_fetches.{symbol_info.package.lower()}")
 
-        markdown = await self.doc_cache.get(symbol_info)
+        markdown = await doc_cache.get(symbol_info)
         if markdown is None:
             log.debug(f"Redis cache miss for symbol `{symbol}`.")
             markdown = await self.item_fetcher.get_markdown(self.bot.http_session, symbol_info)
             if markdown is not None:
-                await self.doc_cache.set(symbol_info, markdown)
+                await doc_cache.set(symbol_info, markdown)
             else:
                 markdown = "Unable to parse the requested symbol."
 
diff --git a/bot/exts/info/doc/_redis_cache.py b/bot/exts/info/doc/_redis_cache.py
index c617eba49..2230884c9 100644
--- a/bot/exts/info/doc/_redis_cache.py
+++ b/bot/exts/info/doc/_redis_cache.py
@@ -27,6 +27,23 @@ class DocRedisCache(RedisObject):
                 pickle.dumps((value, expiry_timestamp))
             )
 
+    @namespace_lock
+    async def set_if_exists(self, item: DocItem, value: str) -> None:
+        """
+        Set markdown `value` for `key` if `key` exists.
+
+        Keys expire after a week to keep data up to date.
+        """
+        expiry_timestamp = datetime.datetime.now().timestamp() + 7 * 24 * 60 * 60
+
+        with await self._get_pool_connection() as connection:
+            if await connection.hexists(f"{self.namespace}:{item.package}", self.get_item_key(item)):
+                await connection.hset(
+                    f"{self.namespace}:{item.package}",
+                    self.get_item_key(item),
+                    pickle.dumps((value, expiry_timestamp))
+                )
+
     @namespace_lock
     async def get(self, item: DocItem) -> Optional[str]:
         """Get markdown contents for `key`."""
-- 
cgit v1.2.3
From 15e73b7d4148ff16d2d408eaf201ebd5a6fd1251 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Sat, 14 Nov 2020 23:34:39 +0100
Subject: Add command for clearing the cache of packages
We also clear the cache when removing a package
---
 bot/exts/info/doc/_cog.py | 8 ++++++++
 1 file changed, 8 insertions(+)
diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py
index 678134f3c..b2d015b89 100644
--- a/bot/exts/info/doc/_cog.py
+++ b/bot/exts/info/doc/_cog.py
@@ -428,6 +428,7 @@ class DocCog(commands.Cog):
             # Rebuild the inventory to ensure that everything
             # that was from this package is properly deleted.
             await self.refresh_inventory()
+            await doc_cache.delete(package_name)
         await ctx.send(f"Successfully deleted `{package_name}` and refreshed inventory.")
 
     @docs_group.command(name="refreshdoc", aliases=("rfsh", "r"))
@@ -450,3 +451,10 @@ class DocCog(commands.Cog):
             description=f"```diff\n{added}\n{removed}```" if added or removed else ""
         )
         await ctx.send(embed=embed)
+
+    @docs_group.command(name="cleardoccache")
+    @commands.has_any_role(*MODERATION_ROLES)
+    async def clear_cache_command(self, ctx: commands.Context, package_name: PackageName) -> None:
+        """Clear persistent redis cache for `package`."""
+        await doc_cache.delete(package_name)
+        await ctx.send(f"Succesfully cleared cache for {package_name}")
-- 
cgit v1.2.3
From 531ee4aad5432860afa784d0c067019662b3a0fe Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Sun, 15 Nov 2020 02:35:37 +0100
Subject: Ensure packages from PRIORITY_PACKAGES are directly accessible
Some packages (currently only python) should be prioritised to others,
the previous cleanup didn't account for other packages loading before it
which resulted in duplicate symbols getting the python prefix and the
original symbols linking to most probably undesired pages
---
 bot/exts/info/doc/_cog.py | 7 +++++++
 1 file changed, 7 insertions(+)
diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py
index b2d015b89..9e4bb54ea 100644
--- a/bot/exts/info/doc/_cog.py
+++ b/bot/exts/info/doc/_cog.py
@@ -33,6 +33,9 @@ FORCE_PREFIX_GROUPS = (
     "pdbcommand",
     "term",
 )
+PRIORITY_PACKAGES = (
+    "python",
+)
 WHITESPACE_AFTER_NEWLINES_RE = re.compile(r"(?<=\n\n)(\s+)")
 NOT_FOUND_DELETE_DELAY = RedirectOutput.delete_delay
 
@@ -235,6 +238,10 @@ class DocCog(commands.Cog):
                         self.doc_symbols[overridden_symbol] = original_symbol
                         self.renamed_symbols.add(overridden_symbol)
 
+                    elif api_package_name in PRIORITY_PACKAGES:
+                        self.doc_symbols[f"{original_symbol.package}.{symbol}"] = original_symbol
+                        self.renamed_symbols.add(symbol)
+
                     else:
                         symbol = f"{api_package_name}.{symbol}"
                         self.renamed_symbols.add(symbol)
-- 
cgit v1.2.3
From 0d3d2bd632e2ed2e14eaacb7db9b49de4cd4baa5 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Sun, 29 Nov 2020 04:12:04 +0100
Subject: Use timedelta instead of constructing duration manually
A newline was also added to set to keep it consistent with
set_if_exists
---
 bot/exts/info/doc/_redis_cache.py | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/bot/exts/info/doc/_redis_cache.py b/bot/exts/info/doc/_redis_cache.py
index 2230884c9..e8577aa64 100644
--- a/bot/exts/info/doc/_redis_cache.py
+++ b/bot/exts/info/doc/_redis_cache.py
@@ -19,7 +19,8 @@ class DocRedisCache(RedisObject):
 
         Keys expire after a week to keep data up to date.
         """
-        expiry_timestamp = datetime.datetime.now().timestamp() + 7 * 24 * 60 * 60
+        expiry_timestamp = (datetime.datetime.now() + datetime.timedelta(weeks=1)).timestamp()
+
         with await self._get_pool_connection() as connection:
             await connection.hset(
                 f"{self.namespace}:{item.package}",
@@ -34,7 +35,7 @@ class DocRedisCache(RedisObject):
 
         Keys expire after a week to keep data up to date.
         """
-        expiry_timestamp = datetime.datetime.now().timestamp() + 7 * 24 * 60 * 60
+        expiry_timestamp = (datetime.datetime.now() + datetime.timedelta(weeks=1)).timestamp()
 
         with await self._get_pool_connection() as connection:
             if await connection.hexists(f"{self.namespace}:{item.package}", self.get_item_key(item)):
-- 
cgit v1.2.3
From e22deb55de286c4186da2f0d2f2d562b9e333630 Mon Sep 17 00:00:00 2001
From: Numerlor <25886452+Numerlor@users.noreply.github.com>
Date: Sun, 29 Nov 2020 04:34:41 +0100
Subject: Use pop instead of getitem and del
Co-authored-by: MarkKoz