From a8eb862d45911ed7a84248167b41483002a42eb4 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Sun, 21 Dec 2025 16:26:09 +0000 Subject: [PATCH 1/6] deps: update mistune to v3.1.4 --- pyproject.toml | 2 +- uv.lock | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4d32ce3d..ce6e956f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ "httpx-aiohttp>=0.1.9", "lxml>=5.4.0,<7.0.0", "markdownify~=1.1.0", - "mistune<3.0.0,>=2.0.4", + "mistune<4.0.0,>=3.1.4", "msgpack<2.0.0,>=1.1.0", "orjson<4.0.0,>=3.10.18", "Pillow<12.0,>=11.2", diff --git a/uv.lock b/uv.lock index c051b72a..3c410f7e 100644 --- a/uv.lock +++ b/uv.lock @@ -1916,11 +1916,14 @@ wheels = [ [[package]] name = "mistune" -version = "2.0.5" +version = "3.1.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fb/6b/d8013058fcdb0088b4130164fc961e15c50d30302f60a349c16bdfda9770/mistune-2.0.5.tar.gz", hash = "sha256:0246113cb2492db875c6be56974a7c893333bf26cd92891c85f63151cee09d34", size = 75854, upload-time = "2023-02-07T05:42:06.739Z" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/02/a7fb8b21d4d55ac93cdcde9d3638da5dd0ebdd3a4fed76c7725e10b81cbe/mistune-3.1.4.tar.gz", hash = "sha256:b5a7f801d389f724ec702840c11d8fc48f2b33519102fc7ee739e8177b672164", size = 94588, upload-time = "2025-08-29T07:20:43.594Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/e5/780d22d19543f339aad583304f58002975b586757aa590cbe7bea5cc6f13/mistune-2.0.5-py2.py3-none-any.whl", hash = "sha256:bad7f5d431886fcbaf5f758118ecff70d31f75231b34024a1341120340a65ce8", size = 24549, upload-time = "2023-02-07T05:42:05.089Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f0/8282d9641415e9e33df173516226b404d367a0fc55e1a60424a152913abc/mistune-3.1.4-py3-none-any.whl", hash = "sha256:93691da911e5d9d2e23bc54472892aff676df27a75274962ff9edc210364266d", size = 53481, upload-time = "2025-08-29T07:20:42.218Z" }, ] [[package]] @@ -2142,7 +2145,7 @@ requires-dist = [ { name = "httpx-aiohttp", specifier = ">=0.1.9" }, { name = "lxml", specifier = ">=5.4.0,<7.0.0" }, { name = "markdownify", specifier = "~=1.1.0" }, - { name = "mistune", specifier = ">=2.0.4,<3.0.0" }, + { name = "mistune", specifier = ">=3.1.4,<4.0.0" }, { name = "msgpack", specifier = ">=1.1.0,<2.0.0" }, { name = "orjson", specifier = ">=3.10.18,<4.0.0" }, { name = "pillow", specifier = ">=11.2,<12.0" }, From c00ec09bda0fcb35e97f646d48f60f90804f1035 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Sun, 21 Dec 2025 16:48:19 +0000 Subject: [PATCH 2/6] refactor: update discord markdown renderer for mistune v3 --- monty/exts/info/github/_handlers.py | 5 +- monty/utils/markdown.py | 194 +++++++++++----------------- 2 files changed, 81 insertions(+), 118 deletions(-) diff --git a/monty/exts/info/github/_handlers.py b/monty/exts/info/github/_handlers.py index c1df413b..6ab02843 100644 --- a/monty/exts/info/github/_handlers.py +++ b/monty/exts/info/github/_handlers.py @@ -5,7 +5,7 @@ import enum import re from abc import abstractmethod -from typing import Generic, Literal, NamedTuple, TypeVar, overload +from typing import Generic, Literal, NamedTuple, TypeVar, cast, overload import disnake import disnake.utils @@ -171,7 +171,8 @@ def render_markdown(self, body: str, *, repo_url: str, limit: int = 2700) -> str "url", ], ) - body = markdown(body) or "" + # this will always be str, unless renderer above is set to None + body = cast("str", markdown(body)) body = body.strip() diff --git a/monty/utils/markdown.py b/monty/utils/markdown.py index 6a05a00d..140197e3 100644 --- a/monty/utils/markdown.py +++ b/monty/utils/markdown.py @@ -2,11 +2,11 @@ from typing import Any from urllib.parse import urljoin -import mistune.renderers +import mistune.renderers.markdown from bs4.element import PageElement, Tag from markdownify import MarkdownConverter - -from monty import constants +from mistune.core import BlockState +from typing_extensions import override __all__ = ( @@ -15,6 +15,8 @@ "remove_codeblocks", ) +RenderToken = dict[str, Any] + CODE_BLOCK_RE = re.compile( r"```(.+?)```|(?P`{1,2})([^\n]+?)(?P=delim)", @@ -100,155 +102,115 @@ def convert_hr(self, el: PageElement, text: str, parent_tags: set[str]) -> str: return "" -# TODO: this will be expanded over time as necessary -class DiscordRenderer(mistune.renderers.BaseRenderer): - """Custom renderer for markdown to discord compatiable markdown.""" +class DiscordRenderer(mistune.renderers.markdown.MarkdownRenderer): + """Custom renderer for markdown to discord compatible markdown.""" def __init__(self, repo: str | None = None) -> None: self._repo = (repo or "").rstrip("/") - def text(self, text: str) -> str: + @override + def text(self, token: RenderToken, state: BlockState) -> str: """Replace GitHub links with their expanded versions.""" + text: str = token["raw"] if self._repo: # TODO: expand this to all different varieties of automatic links + # FIXME: this shouldn't expand shorthands inside []() links # if a repository is provided we replace all snippets with the correct thing def replacement(match: re.Match[str]) -> str: - return self.link(self._repo + "/issues/" + match[1], text=match[0]) + full, num = match[0], match[1] + url = f"{self._repo}/issues/{num}" + # NOTE: until the above fixme is resolved, we can't use self.link here, + # since it would recurse indefinitely. + return f"[{full}]({url})" text = GH_ISSUE_RE.sub(replacement, text) return text - def link(self, link: str, text: str | None = None, title: str | None = None) -> str: - """Properly format a link.""" - if text or title: - if not text: - text = link - if title: - paran = f'({link} "{title}")' - else: - paran = f"({link})" - return f"[{text}]{paran}" - else: - return link - - def image(self, src: str, alt: str | None = None, title: str | None = None) -> str: - """Return a link to the provided image.""" - return "!" + self.link(src, text="image", title=alt) - - def emphasis(self, text: str) -> str: - """Return italiced text.""" - return f"*{text}*" - - def strong(self, text: str) -> str: - """Return bold text.""" - return f"**{text}**" - - def strikethrough(self, text: str) -> str: + # Discord renders links regardless of whether it's `link` or `` + @override + def link(self, token: RenderToken, state: BlockState) -> str: + """Format links, removing unnecessary angle brackets.""" + s = super().link(token, state) + if s.startswith("<") and s.endswith(">"): + s = s[1:-1] + return s + + # provided by plugin, so not part of base MarkdownRenderer + def strikethrough(self, token: RenderToken, state: BlockState) -> str: """Return crossed-out text.""" + text = self.render_children(token, state) return f"~~{text}~~" - def heading(self, text: str, level: int) -> str: + @override + def heading(self, token: RenderToken, state: BlockState) -> str: """Format the heading normally if it's large enough, or underline it.""" + level: int = token["attrs"]["level"] + text = self.render_children(token, state) if level in (1, 2, 3): return "#" * level + f" {text.strip()}\n" else: + # TODO: consider `-# __text__` for level 5 (smallest) headings? return f"__{text}__\n" - def newline(self) -> str: - """No op.""" + @override + def inline_html(self, token: RenderToken, state: BlockState) -> str: + """No op, Discord doesn't render HTML.""" return "" - # this is for forced breaks like `text \ntext`; Discord - def linebreak(self) -> str: - """Return a new line.""" - return "\n" - - def inline_html(self, html: str) -> str: - """No op.""" + @override + def thematic_break(self, token: RenderToken, state: BlockState) -> str: + """No op, Discord doesn't render breaks as horizontal rules.""" return "" - def thematic_break(self) -> str: - """No op.""" - return "" - - def block_text(self, text: str) -> str: - """Return text in lists as-is.""" - return text + "\n" + # Block code can be fenced by 3+ backticks or 3+ tildes, or be an indented block. + # Discord only renders code blocks with exactly 3 backticks, so we have to force this format. + @override + def block_code(self, token: RenderToken, state: BlockState) -> str: + """Put code in a codeblock with triple backticks.""" + code: str = token["raw"] + info: str | None = token.get("attrs", {}).get("info") - def block_code(self, code: str, info: str | None = None) -> str: - """Put the code in a codeblock.""" md = "```" - if info is not None: - info = info.strip() if info: - lang = info.split(None, 1)[0] - md += lang + lang = info.strip().split(None, 1)[0] + if lang: + md += lang md += "\n" - return md + code.replace("`" * 3, "`\u200b" * 3) + "\n```\n" - def block_quote(self, text: str) -> str: - """Quote the provided text.""" - if text: - return "> " + "> ".join(text.rstrip().splitlines(keepends=True)) + "\n\n" - return "" + return md + code.replace("`" * 3, "`\u200b" * 3) + "\n```\n" - def block_html(self, html: str) -> str: - """No op.""" + @override + def block_html(self, token: RenderToken, state: BlockState) -> str: + """No op, Discord doesn't render HTML.""" return "" - def block_error(self, html: str) -> str: + @override + def block_error(self, token: RenderToken, state: BlockState) -> str: """No op.""" return "" - def codespan(self, text: str) -> str: + # Codespans can be delimited with two backticks as well, which allows having + # single backticks in the contents. + # Additionally, the delimiters may include one space, e.g. "`` text ``", for text that starts/ends + # with a backtick. Mistune strips these spaces, but we need them to avoid breaking formatting. + # Discord renders these spaces (even though they shouldn't), but it's better than no formatting at all. + # TODO: instead of spaces, we could use \u200b? + @override + def codespan(self, token: RenderToken, state: BlockState) -> str: """Return the text in a codeblock.""" - char = "``" if "`" in text else "`" - return char + text + char - - def paragraph(self, text: str) -> str: - """Return a paragraph with a newline postceeding.""" - return f"{text}\n\n" - - def list(self, text: str, ordered: bool, level: int, start: Any = None) -> str: - """Return the unedited list.""" - # TODO: figure out how this should actually work - if level == 1: - return text.lstrip("\n") + "\n" - return text + text: str = token["raw"] + + delim = "``" if "`" in text else "`" + + if text.startswith("`") or text.endswith("`"): + text = f" {text} " + + return delim + text + delim - def list_item(self, text: str, level: int) -> str: - """Show the list, indented to its proper level.""" - lines = text.rstrip().splitlines() - - prefix = "- " - result: list[str] = [prefix + lines[0]] - - # just add one level of indentation; any outer lists will indent this again as needed - indent = " " * len(prefix) - in_codeblock = "```" in lines[0] - for line in lines[1:]: - if not line.strip(): - # whitespace-only lines can be rendered as empty - result.append("") - continue - - if in_codeblock: - # don't indent lines inside codeblocks - result.append(line) - else: - result.append(indent + line) - - # check this at the end, since the first codeblock line should generally be indented - if "```" in line: - in_codeblock = not in_codeblock - - return "\n".join(result) + "\n" - - def task_list_item(self, text: Any, level: int, checked: bool = False, **attrs) -> str: - """Convert task list options to emoji.""" - emoji = constants.Emojis.confirmation if checked else constants.Emojis.no_choice_light - return self.list_item(emoji + " " + text, level=level) - - def finalize(self, data: Any) -> str: - """Finalize the data.""" - return "".join(data) + # FIXME: restore this, plugin rendering changed significantly + # # def task_list_item(self, text: Any, level: int, checked: bool = False, **attrs) -> str: + # def task_list_item(self, token: RenderToken, state: BlockState) -> str: + # """Convert task list options to emoji.""" + # checked: bool = token["attrs"]["checked"] + # emoji = constants.Emojis.confirmation if checked else constants.Emojis.no_choice_light + # return self.list_item(emoji + " " + text, level=level) From cad879abf996fa1300d8eeaf9bc3f91d32c134cc Mon Sep 17 00:00:00 2001 From: shiftinv Date: Sun, 21 Dec 2025 18:27:43 +0000 Subject: [PATCH 3/6] chore: remove broken task_list_item handler --- monty/utils/markdown.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/monty/utils/markdown.py b/monty/utils/markdown.py index 140197e3..726c9a74 100644 --- a/monty/utils/markdown.py +++ b/monty/utils/markdown.py @@ -206,11 +206,3 @@ def codespan(self, token: RenderToken, state: BlockState) -> str: text = f" {text} " return delim + text + delim - - # FIXME: restore this, plugin rendering changed significantly - # # def task_list_item(self, text: Any, level: int, checked: bool = False, **attrs) -> str: - # def task_list_item(self, token: RenderToken, state: BlockState) -> str: - # """Convert task list options to emoji.""" - # checked: bool = token["attrs"]["checked"] - # emoji = constants.Emojis.confirmation if checked else constants.Emojis.no_choice_light - # return self.list_item(emoji + " " + text, level=level) From 1c015124030550b66267232bcc43aff2999ade16 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Sun, 21 Dec 2025 18:27:22 +0000 Subject: [PATCH 4/6] feat(markdown): re-implement task lists in discord renderer --- monty/exts/info/github/_handlers.py | 3 ++- monty/utils/markdown.py | 42 +++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/monty/exts/info/github/_handlers.py b/monty/exts/info/github/_handlers.py index 6ab02843..e0ef00af 100644 --- a/monty/exts/info/github/_handlers.py +++ b/monty/exts/info/github/_handlers.py @@ -167,10 +167,11 @@ def render_markdown(self, body: str, *, repo_url: str, limit: int = 2700) -> str renderer=DiscordRenderer(repo=repo_url), plugins=[ "strikethrough", - "task_lists", "url", ], ) + markdown.before_render_hooks.append(DiscordRenderer.hook_list_pre_render) + # this will always be str, unless renderer above is set to None body = cast("str", markdown(body)) diff --git a/monty/utils/markdown.py b/monty/utils/markdown.py index 726c9a74..5fc39df4 100644 --- a/monty/utils/markdown.py +++ b/monty/utils/markdown.py @@ -5,9 +5,12 @@ import mistune.renderers.markdown from bs4.element import PageElement, Tag from markdownify import MarkdownConverter +from mistune import Markdown from mistune.core import BlockState from typing_extensions import override +from monty import constants + __all__ = ( "DiscordRenderer", @@ -26,6 +29,8 @@ # references should be preceded by a non-word character (or element start) GH_ISSUE_RE = re.compile(r"(?:^|(?<=\W))(?:#|GH-)(\d+)\b", re.IGNORECASE) +TASK_LIST_ITEM_RE = re.compile(r"\[[ x]\]\s+", re.IGNORECASE) + def remove_codeblocks(content: str) -> str: """Remove any codeblock in a message.""" @@ -206,3 +211,40 @@ def codespan(self, token: RenderToken, state: BlockState) -> str: text = f" {text} " return delim + text + delim + + @staticmethod + def hook_list_pre_render(md: Markdown, state: BlockState) -> None: + """Mistune hook to render task list items (e.g. `- [x] stuff`) as emojis. + + This is essentially a smaller patched version of the builtin task_lists plugin, + which unfortunately does not work with `MarkdownRenderer`. + See https://github.com/lepture/mistune/issues/340. + + Should be registered using `md.before_render_hooks.append(f)`. + """ + + def rewrite_item(token: RenderToken) -> None: + # this runs before the inline tokenizer/renderer, so we still have plain `block_text` tokens + if ( + (children := token["children"]) + and (text := children[0].get("text")) + and (match := TASK_LIST_ITEM_RE.match(text)) + ): + # trim task list marker + text = text[match.end() :] + + # get corresponding emoji + checked = "x" in match[0].lower() + emoji = constants.Emojis.confirmation if checked else constants.Emojis.no_choice_light + + # update item text + children[0]["text"] = emoji + " " + text + + def recurse(tokens: list[RenderToken]) -> None: + for tok in tokens: + if tok["type"] == "list_item": + rewrite_item(tok) + if children := tok.get("children"): + recurse(children) + + recurse(state.tokens) From a997b393ae8fb6007b5d3850085f24331786d4f3 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Sun, 21 Dec 2025 19:50:34 +0000 Subject: [PATCH 5/6] feat: implement custom list renderer --- monty/exts/info/github/_handlers.py | 3 +- monty/utils/markdown.py | 93 ++++++++++++++++++----------- 2 files changed, 58 insertions(+), 38 deletions(-) diff --git a/monty/exts/info/github/_handlers.py b/monty/exts/info/github/_handlers.py index e0ef00af..6ab02843 100644 --- a/monty/exts/info/github/_handlers.py +++ b/monty/exts/info/github/_handlers.py @@ -167,11 +167,10 @@ def render_markdown(self, body: str, *, repo_url: str, limit: int = 2700) -> str renderer=DiscordRenderer(repo=repo_url), plugins=[ "strikethrough", + "task_lists", "url", ], ) - markdown.before_render_hooks.append(DiscordRenderer.hook_list_pre_render) - # this will always be str, unless renderer above is set to None body = cast("str", markdown(body)) diff --git a/monty/utils/markdown.py b/monty/utils/markdown.py index 5fc39df4..7dce624d 100644 --- a/monty/utils/markdown.py +++ b/monty/utils/markdown.py @@ -1,17 +1,22 @@ +import itertools import re -from typing import Any +from typing import TYPE_CHECKING, Any from urllib.parse import urljoin +import mistune.renderers._list import mistune.renderers.markdown from bs4.element import PageElement, Tag from markdownify import MarkdownConverter -from mistune import Markdown from mistune.core import BlockState from typing_extensions import override from monty import constants +if TYPE_CHECKING: + from collections.abc import Iterator + + __all__ = ( "DiscordRenderer", "DocMarkdownConverter", @@ -29,8 +34,6 @@ # references should be preceded by a non-word character (or element start) GH_ISSUE_RE = re.compile(r"(?:^|(?<=\W))(?:#|GH-)(\d+)\b", re.IGNORECASE) -TASK_LIST_ITEM_RE = re.compile(r"\[[ x]\]\s+", re.IGNORECASE) - def remove_codeblocks(content: str) -> str: """Remove any codeblock in a message.""" @@ -111,6 +114,7 @@ class DiscordRenderer(mistune.renderers.markdown.MarkdownRenderer): """Custom renderer for markdown to discord compatible markdown.""" def __init__(self, repo: str | None = None) -> None: + super().__init__() self._repo = (repo or "").rstrip("/") @override @@ -212,39 +216,56 @@ def codespan(self, token: RenderToken, state: BlockState) -> str: return delim + text + delim - @staticmethod - def hook_list_pre_render(md: Markdown, state: BlockState) -> None: - """Mistune hook to render task list items (e.g. `- [x] stuff`) as emojis. + @override + def list(self, token: RenderToken, state: BlockState) -> str: + """Render lists for Discord's (relatively limited subset of) markdown. + + This includes: + - For ordered lists, enforce 1. instead of 1) + - For unordered lists, enforce - instead of * or + + - Discord technically supports *, but might as well use - for all of them + - Always use "tight" list spacing, Discord does not render loose list items properly + + Moreover, this renders list items with the generic token renderer instead of directly + calling into list_item(), which allows custom list items (such as `task_list_item`) + to work (unlike the builtin list renderer :( ). + """ + prefix_gen: Iterator[str] + if token["attrs"]["ordered"]: + start = token["attrs"].get("start", 1) + prefix_gen = (f"{i}. " for i in itertools.count(start)) + else: + prefix_gen = itertools.repeat("- ") - This is essentially a smaller patched version of the builtin task_lists plugin, - which unfortunately does not work with `MarkdownRenderer`. - See https://github.com/lepture/mistune/issues/340. + text = "" + for child, prefix in zip(token["children"], prefix_gen, strict=False): + child = {**child, "parent": {"leading": prefix}} + text += self.render_token(child, state) - Should be registered using `md.before_render_hooks.append(f)`. + # if this is a nested list, strip trailing newlines + if token.get("parent"): + text = text.rstrip() + return text + "\n" + + def list_item(self, token: RenderToken, state: BlockState) -> str: + """Render a single list item. + + See `list()` above for details. """ + for child in token["children"]: + # force tight list + if child["type"] == "paragraph": + child["type"] = "block_text" + + return mistune.renderers._list._render_list_item(self, token["parent"], token, state) + + def task_list_item(self, token: RenderToken, state: BlockState) -> str: + """Render a task list item, e.g. `- [x] stuff`.""" + checked: bool = token["attrs"]["checked"] + emoji = constants.Emojis.confirmation if checked else constants.Emojis.no_choice_light + + prefix = {"type": "text", "raw": f"{emoji} "} + token["children"].insert(0, prefix) - def rewrite_item(token: RenderToken) -> None: - # this runs before the inline tokenizer/renderer, so we still have plain `block_text` tokens - if ( - (children := token["children"]) - and (text := children[0].get("text")) - and (match := TASK_LIST_ITEM_RE.match(text)) - ): - # trim task list marker - text = text[match.end() :] - - # get corresponding emoji - checked = "x" in match[0].lower() - emoji = constants.Emojis.confirmation if checked else constants.Emojis.no_choice_light - - # update item text - children[0]["text"] = emoji + " " + text - - def recurse(tokens: list[RenderToken]) -> None: - for tok in tokens: - if tok["type"] == "list_item": - rewrite_item(tok) - if children := tok.get("children"): - recurse(children) - - recurse(state.tokens) + # treat this like a normal list item now + return self.list_item(token, state) From 7477bbdb72536d3914d8538242718d3a60dc3a85 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Tue, 23 Dec 2025 11:51:20 +0000 Subject: [PATCH 6/6] deps: update mistune v3.1.4 -> v3.2.0 --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ce6e956f..df1e6a22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ "httpx-aiohttp>=0.1.9", "lxml>=5.4.0,<7.0.0", "markdownify~=1.1.0", - "mistune<4.0.0,>=3.1.4", + "mistune<4.0.0,>=3.2.0", "msgpack<2.0.0,>=1.1.0", "orjson<4.0.0,>=3.10.18", "Pillow<12.0,>=11.2", diff --git a/uv.lock b/uv.lock index 3c410f7e..96acb2d3 100644 --- a/uv.lock +++ b/uv.lock @@ -1916,14 +1916,14 @@ wheels = [ [[package]] name = "mistune" -version = "3.1.4" +version = "3.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d7/02/a7fb8b21d4d55ac93cdcde9d3638da5dd0ebdd3a4fed76c7725e10b81cbe/mistune-3.1.4.tar.gz", hash = "sha256:b5a7f801d389f724ec702840c11d8fc48f2b33519102fc7ee739e8177b672164", size = 94588, upload-time = "2025-08-29T07:20:43.594Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/55/d01f0c4b45ade6536c51170b9043db8b2ec6ddf4a35c7ea3f5f559ac935b/mistune-3.2.0.tar.gz", hash = "sha256:708487c8a8cdd99c9d90eb3ed4c3ed961246ff78ac82f03418f5183ab70e398a", size = 95467, upload-time = "2025-12-23T11:36:34.994Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/f0/8282d9641415e9e33df173516226b404d367a0fc55e1a60424a152913abc/mistune-3.1.4-py3-none-any.whl", hash = "sha256:93691da911e5d9d2e23bc54472892aff676df27a75274962ff9edc210364266d", size = 53481, upload-time = "2025-08-29T07:20:42.218Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f7/4a5e785ec9fbd65146a27b6b70b6cdc161a66f2024e4b04ac06a67f5578b/mistune-3.2.0-py3-none-any.whl", hash = "sha256:febdc629a3c78616b94393c6580551e0e34cc289987ec6c35ed3f4be42d0eee1", size = 53598, upload-time = "2025-12-23T11:36:33.211Z" }, ] [[package]] @@ -2145,7 +2145,7 @@ requires-dist = [ { name = "httpx-aiohttp", specifier = ">=0.1.9" }, { name = "lxml", specifier = ">=5.4.0,<7.0.0" }, { name = "markdownify", specifier = "~=1.1.0" }, - { name = "mistune", specifier = ">=3.1.4,<4.0.0" }, + { name = "mistune", specifier = ">=3.2.0,<4.0.0" }, { name = "msgpack", specifier = ">=1.1.0,<2.0.0" }, { name = "orjson", specifier = ">=3.10.18,<4.0.0" }, { name = "pillow", specifier = ">=11.2,<12.0" },