GitHubMarkdown.py
Summary
- Lines:
208 of 221 covered (94.1%)
- Excluded lines:
2
- Branches:
88 of 96 taken (91.7%), 8 lines partially
- Coverage:
93.4%
- Directory:
- Missed lines:
205-207, 214, 381-383, 416, 418-420, 473, 511
Source
pyTooling/Sphinx/GitHubMarkdown.pycoveredpartialuncoveredexcludednot executable
1# ==================================================================================================================== #
2# _____ _ _ ____ _ _ #
3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ / ___| _ __ | |__ (_)_ __ __ __ #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | \___ \| '_ \| '_ \| | '_ \\ \/ / #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_ ___) | |_) | | | | | | | |> < #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____/| .__/|_| |_|_|_| |_/_/\_\ #
7# |_| |___/ |___/ |_| #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2026-2026 Patrick Lehmann - Bötzingen, Germany #
15# #
16# Licensed under the Apache License, Version 2.0 (the "License"); #
17# you may not use this file except in compliance with the License. #
18# You may obtain a copy of the License at #
19# #
20# http://www.apache.org/licenses/LICENSE-2.0 #
21# #
22# Unless required by applicable law or agreed to in writing, software #
23# distributed under the License is distributed on an "AS IS" BASIS, #
24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
25# See the License for the specific language governing permissions and #
26# limitations under the License. #
27# #
28# SPDX-License-Identifier: Apache-2.0 #
29# ==================================================================================================================== #
30#
31"""
32A converter of GitHub-flavoured Markdown into docutils nodes.
33
34The Markdown is e.g. the notes of a GitHub release. :class:`GitHubMarkdown` parses it with markdown-it-py and the
35GFM plugin of mdit-py-plugins - CommonMark plus tables, strikethrough, task lists, alerts, autolinks and footnotes -,
36and :class:`~pyTooling.Sphinx.Markdown.MarkdownTranslator` translates the tokens into the nodes a directive
37returns:
38
39.. code-block:: Python
40
41 converter = GitHubMarkdown("pyTooling", "pyTooling")
42 return converter.Convert(markdown, self.state.document, location=self.get_location())
43
44.. rubric:: GitHub's references
45
46GitHub links what Markdown leaves as text: ``#123``, ``GH-123`` and ``owner/name#123`` to an issue or a pull request,
47``@user`` to a user, a commit's hash (7 to 40 hexadecimal digits) to the commit, and ``:tada:`` is an emoji. The
48converter does the same in text outside of code and links (see :meth:`GitHubMarkdown.Parse`). GitHub links a
49hash only if the commit exists; the converter can't ask, so it links a word of hexadecimal digits holding a digit and a
50letter.
51
52.. rubric:: Emoji
53
54The emoji, their characters and shortcodes, are GitHub's own list: the table :data:`EMOJI_FILE` is generated from
55github/gemoji's :file:`db/emoji.json`, which GitHub, Inc. distributes under the MIT license; the table's header names
56the version and holds the license text. A custom GitHub emoji, e.g. ``:octocat:``, is an image without characters, and
57isn't in the list.
58
59.. rubric:: Headings and raw HTML
60
61A heading becomes a topic or a rubric, never a section, and raw HTML is translated element by element, as
62:mod:`~pyTooling.Sphinx.Markdown` describes.
63
64.. rubric:: LaTeX
65
66An emoji is a node of its own (:class:`~pyTooling.Sphinx.Node.Emoji`), whose characters LaTeX writes as they are, for
67LuaLaTeX and an emoji font.
68
69LaTeX can't include an SVG image, e.g. a badge. :class:`RasterizeShieldsBadges` fetches a shields.io badge as PNG;
70every other SVG image is converted by Sphinx' ImageMagick converter, :mod:`sphinx.ext.imgconverter`, which
71pyTooling.Sphinx loads. Without ImageMagick, Sphinx warns, and LaTeX stops at the image with "Unknown graphics
72extension: .svg".
73
74.. attention::
75
76 Packages ``markdown_it`` and ``mdit_py_plugins`` are imported when a :class:`GitHubMarkdown` is created, not when
77 this module is imported. They are installed with the extra ``markdown``.
78"""
79from __future__ import annotations
80
81from re import compile as re_compile, escape as re_escape
82from typing import TYPE_CHECKING, Any, ClassVar, Optional as Nullable, Pattern
83from urllib.parse import urlsplit
84
85from docutils import nodes
86from sphinx.transforms.post_transforms import SphinxPostTransform
87
88from pyTooling.Common import getFullyQualifiedName, readResourceFile
89from pyTooling.Decorators import export, readonly
90from pyTooling.Exceptions import MissingDependencyError
91from pyTooling.MetaClasses import ExtendedType
92from pyTooling.Sphinx import Resources as SphinxResources
93from pyTooling.Sphinx.Markdown import IMAGE_CLASS, MarkdownTranslator
94from pyTooling.Sphinx.Node import Emoji
95from pyTooling.Sphinx.Shields import SHIELDS_SERVICE_PNG, SHIELDS_SERVICE_SVG
96
97if TYPE_CHECKING: # pragma: no cover
98 from markdown_it import MarkdownIt
99
100
101__all__ = ["EMOJI_FILE", "OWNER_PATTERN", "REPOSITORY_NAME_PATTERN", "REPOSITORY_PATTERN", "REFERENCE_PATTERN"]
102
103#: Name of the emoji table in :mod:`pyTooling.Sphinx.Resources`: a line per emoji, its code points and its shortcodes,
104#: generated from github/gemoji (MIT license, Copyright (c) 2019 GitHub, Inc.).
105EMOJI_FILE = "GitHubEmoji.txt"
106
107#: A GitHub owner - a user or an organization: ASCII letters, digits and single hyphens, not starting with a hyphen.
108OWNER_PATTERN = re_compile(r"[A-Za-z0-9](?:-?[A-Za-z0-9])*")
109
110#: A GitHub repository's name: ASCII letters, digits, ``.``, ``_`` and ``-``.
111REPOSITORY_NAME_PATTERN = re_compile(r"[A-Za-z0-9._-]+")
112
113#: A GitHub repository with its owner, as a reference writes it: ``<owner>/<name>``.
114REPOSITORY_PATTERN = re_compile(rf"{OWNER_PATTERN.pattern}/{REPOSITORY_NAME_PATTERN.pattern}")
115
116_BEFORE = r"(?<![\w/@#.-])"
117"""What can't precede a reference: a word character, or a character continuing a path, a reference or a URL."""
118
119#: An emoji's shortcode, or one of GitHub's references: an issue or pull request, a commit, or a user.
120REFERENCE_PATTERN = re_compile(
121 r":(?P<shortcode>[a-z0-9_+-]+):"
122 rf"|{_BEFORE}(?:(?P<issueRepository>{REPOSITORY_PATTERN.pattern})#|GH-|#)(?P<issue>[0-9]+)(?![\w-])"
123 rf"|{_BEFORE}(?:(?P<commitRepository>{REPOSITORY_PATTERN.pattern})@)?(?P<sha>[0-9a-f]{{7,40}})(?![\w-])"
124 rf"|{_BEFORE}@(?P<user>{OWNER_PATTERN.pattern})(?![\w/-])"
125)
126
127
128@export
129class GitHubMarkdown(metaclass=ExtendedType, slots=True):
130 """
131 A converter of GitHub-flavoured Markdown into docutils nodes, which links GitHub's references in a repository's
132 context.
133
134 The owner and the repository are the ones ``#123`` refers to, and the server URL the one every link starts with.
135 :meth:`Convert` converts a Markdown document into docutils nodes, :meth:`Parse` parses a text without Markdown for
136 GitHub's references and emoji.
137
138 GitHub writes a line break of an issue, a comment or a release's notes as a line break, not as a space:
139 :attr:`LineBreaks` selects which.
140 """
141
142 _emojis: ClassVar[Nullable[dict[str, str]]] = None #: The emoji's characters by shortcode, once read.
143 _shortcodes: ClassVar[Nullable[dict[str, str]]] = None #: A shortcode per emoji, by its characters without U+FE0F.
144 _emojiPattern: ClassVar[Nullable[Pattern[str]]] = None #: The pattern finding emoji written as characters.
145
146 _owner: str #: The owner of the repository references refer to, a user or an organization.
147 _repository: str #: The name of the repository references refer to.
148 _serverURL: str #: The URL of the GitHub server, without a trailing ``/``.
149 _lineBreaks: bool #: Whether a line break in a paragraph is written as a line break.
150 _parser: MarkdownIt #: The Markdown parser.
151
152 def __init__(
153 self,
154 owner: str,
155 repository: str,
156 serverURL: str = "https://github.com",
157 lineBreaks: bool = True
158 ) -> None:
159 """
160 Initializes a converter for the Markdown of a repository.
161
162 :param owner: The owner of the repository references refer to, a user or an organization.
163 :param repository: The name of the repository references refer to.
164 :param serverURL: Optional, URL of the GitHub server. Default: ``"https://github.com"``.
165 :param lineBreaks: Optional, whether a line break in a paragraph is written as a line break, as
166 GitHub does in a release's notes. Default: ``True``.
167 :raises ValueError: If parameter ``owner`` is ``None``.
168 :raises TypeError: If parameter ``owner`` isn't of type :class:`str`.
169 :raises ValueError: If parameter ``owner`` isn't a GitHub user's or organization's name.
170 :raises ValueError: If parameter ``repository`` is ``None``.
171 :raises TypeError: If parameter ``repository`` isn't of type :class:`str`.
172 :raises ValueError: If parameter ``repository`` isn't a GitHub repository's name.
173 :raises ValueError: If parameter ``serverURL`` is ``None``.
174 :raises TypeError: If parameter ``serverURL`` isn't of type :class:`str`.
175 :raises ValueError: If parameter ``serverURL`` isn't an HTTP or HTTPS URL.
176 :raises ValueError: If parameter ``lineBreaks`` is ``None``.
177 :raises TypeError: If parameter ``lineBreaks`` isn't of type :class:`bool`.
178 :raises MissingDependencyError: If the extra ``markdown`` isn't installed.
179 """
180 if owner is None:
181 raise ValueError("Parameter 'owner' is None.")
182 elif not isinstance(owner, str):
183 ex = TypeError("Parameter 'owner' is not of type 'str'.")
184 ex.add_note(f"Got type '{getFullyQualifiedName(owner)}'.")
185 raise ex
186 elif OWNER_PATTERN.fullmatch(owner) is None:
187 ex = ValueError("Parameter 'owner' is not a GitHub user's or organization's name.")
188 ex.add_note(f"Got value '{owner}'.")
189 raise ex
190
191 if repository is None:
192 raise ValueError("Parameter 'repository' is None.")
193 elif not isinstance(repository, str):
194 ex = TypeError("Parameter 'repository' is not of type 'str'.")
195 ex.add_note(f"Got type '{getFullyQualifiedName(repository)}'.")
196 raise ex
197 elif REPOSITORY_NAME_PATTERN.fullmatch(repository) is None:
198 ex = ValueError("Parameter 'repository' is not a GitHub repository's name.")
199 ex.add_note(f"Got value '{repository}'.")
200 raise ex
201
202 if serverURL is None:
203 raise ValueError("Parameter 'serverURL' is None.")
204 elif not isinstance(serverURL, str):
205 ex = TypeError("Parameter 'serverURL' is not of type 'str'.")
206 ex.add_note(f"Got type '{getFullyQualifiedName(serverURL)}'.")
207 raise ex
208 elif urlsplit(serverURL).scheme not in ("https", "http") or urlsplit(serverURL).netloc == "":
209 ex = ValueError("Parameter 'serverURL' is not an HTTP or HTTPS URL.")
210 ex.add_note(f"Got value '{serverURL}'.")
211 raise ex
212
213 if lineBreaks is None:
214 raise ValueError("Parameter 'lineBreaks' is None.")
215 elif not isinstance(lineBreaks, bool):
216 ex = TypeError("Parameter 'lineBreaks' is not of type 'bool'.")
217 ex.add_note(f"Got type '{getFullyQualifiedName(lineBreaks)}'.")
218 raise ex
219
220 try:
221 from markdown_it import MarkdownIt
222 except ImportError as ex:
223 raise MissingDependencyError(dependency="markdown-it-py", extra="markdown") from ex
224
225 try:
226 from mdit_py_plugins.gfm import gfm_plugin
227 except ImportError as ex:
228 raise MissingDependencyError(dependency="mdit-py-plugins", extra="markdown") from ex
229
230 self._owner = owner
231 self._repository = repository
232 self._serverURL = serverURL.rstrip("/")
233 self._lineBreaks = lineBreaks
234 # 'text_join' would merge an escaped character into the text around it, e.g. '\#12' into a reference
235 self._parser = MarkdownIt("commonmark", {"html": True}).use(gfm_plugin).disable("text_join")
236
237 if GitHubMarkdown._emojis is None:
238 emojis: dict[str, str] = {}
239 shortcodes: dict[str, str] = {}
240 for line in readResourceFile(SphinxResources, EMOJI_FILE).splitlines():
241 if line.startswith("#") or line.strip() == "":
242 continue
243
244 codePoints, *names = line.split()
245 characters = "".join(chr(int(codePoint, 16)) for codePoint in codePoints.split("-"))
246 shortcodes[characters.replace("\ufe0f", "")] = names[0]
247 for name in names:
248 emojis[name] = characters
249
250 GitHubMarkdown._emojiPattern = self._EmojiPattern(shortcodes)
251 GitHubMarkdown._shortcodes = shortcodes
252 GitHubMarkdown._emojis = emojis
253
254 @staticmethod
255 def _EmojiPattern(shortcodes: dict[str, str]) -> Pattern[str]:
256 """
257 Build the pattern finding the emoji written as characters in a text: a trie of their characters, each optionally
258 followed by variation selector 16 (U+FE0F).
259
260 A single character below U+2190 - e.g. the copyright sign - is an emoji only if followed by U+FE0F; else it is
261 a character of the text font.
262
263 :param shortcodes: The emoji's characters without U+FE0F.
264 :returns: The pattern.
265 """
266 trie: dict[str, Any] = {}
267 for characters in shortcodes:
268 level = trie
269 for character in characters:
270 level = level.setdefault(character, {})
271 level[""] = {}
272
273 def alternatives(level: dict[str, Any], depth: int) -> str:
274 """
275 Nested function for recursion.
276
277 :param level: A level of the trie, by character; key ``""`` marks the end of an emoji.
278 :param depth: The level's depth, ``0`` for the first character.
279 :returns: The level's pattern.
280 """
281 branches = []
282 for character, nextLevel in sorted(level.items()):
283 if character == "":
284 continue
285
286 selector = "\ufe0f" if depth == 0 and ord(character) < 0x2190 and list(nextLevel) == [""] else "\ufe0f?"
287 branches.append(f"{re_escape(character)}{selector}{alternatives(nextLevel, depth + 1)}")
288
289 if len(branches) == 0:
290 return ""
291
292 optional = "?" if "" in level else ""
293 return f"(?:{'|'.join(branches)}){optional}"
294
295 return re_compile(alternatives(trie, 0))
296
297 @readonly
298 def Owner(self) -> str:
299 """
300 Read-only property to access the owner of the repository references refer to (:attr:`_owner`).
301
302 :returns: The owner, a user or an organization.
303 """
304 return self._owner
305
306 @readonly
307 def Repository(self) -> str:
308 """
309 Read-only property to access the name of the repository references refer to (:attr:`_repository`).
310
311 :returns: The repository's name.
312 """
313 return self._repository
314
315 @readonly
316 def ServerURL(self) -> str:
317 """
318 Read-only property to access the URL of the GitHub server (:attr:`_serverURL`).
319
320 :returns: The URL, without a trailing ``/``.
321 """
322 return self._serverURL
323
324 @readonly
325 def LineBreaks(self) -> bool:
326 """
327 Read-only property to access whether a line break in a paragraph is written as a line break (:attr:`_lineBreaks`).
328
329 :returns: ``True``, if a line break is written as a line break; ``False``, if as a space.
330 """
331 return self._lineBreaks
332
333 def Convert(
334 self,
335 markdown: str,
336 document: nodes.document,
337 repeatedTitle: str = "",
338 location: Nullable[str] = None
339 ) -> list[nodes.Node]:
340 """
341 Convert a Markdown document into docutils nodes.
342
343 The nodes are body elements a directive returns; headings become topics and rubrics (see the module's
344 description). A footnote's ID is unique in the document.
345
346 The first heading of the Markdown can repeat the title, which the nodes are put under, e.g. ``# GHDL 5.0.1`` in
347 the notes of a release titled so. Given as ``repeatedTitle``, such a heading is skipped, so the headings after it
348 become the topics.
349
350 :param markdown: The Markdown.
351 :param document: The document the nodes are put into.
352 :param repeatedTitle: Optional, the title the first heading is skipped for; compared without surrounding and
353 repeated whitespace. Default: ``""``, no heading is skipped.
354 :param location: Optional, location the warnings are reported at, e.g. ``index.rst:12``. Default: ``None``.
355 :returns: The nodes.
356 :raises ValueError: If parameter ``markdown`` is ``None``.
357 :raises TypeError: If parameter ``markdown`` isn't of type :class:`str`.
358 :raises ValueError: If parameter ``document`` is ``None``.
359 :raises TypeError: If parameter ``document`` isn't of type :class:`~docutils.nodes.document`.
360 :raises ValueError: If parameter ``repeatedTitle`` is ``None``.
361 :raises TypeError: If parameter ``repeatedTitle`` isn't of type :class:`str`.
362 :raises TypeError: If parameter ``location`` isn't of type :class:`str`.
363 """
364 if markdown is None:
365 raise ValueError("Parameter 'markdown' is None.")
366 elif not isinstance(markdown, str):
367 ex = TypeError("Parameter 'markdown' is not of type 'str'.")
368 ex.add_note(f"Got type '{getFullyQualifiedName(markdown)}'.")
369 raise ex
370
371 if document is None:
372 raise ValueError("Parameter 'document' is None.")
373 elif not isinstance(document, nodes.document):
374 ex = TypeError("Parameter 'document' is not of type 'docutils.nodes.document'.")
375 ex.add_note(f"Got type '{getFullyQualifiedName(document)}'.")
376 raise ex
377
378 if repeatedTitle is None:
379 raise ValueError("Parameter 'repeatedTitle' is None.")
380 elif not isinstance(repeatedTitle, str):
381 ex = TypeError("Parameter 'repeatedTitle' is not of type 'str'.")
382 ex.add_note(f"Got type '{getFullyQualifiedName(repeatedTitle)}'.")
383 raise ex
384
385 if location is not None and not isinstance(location, str):
386 ex = TypeError("Parameter 'location' is not of type 'str'.")
387 ex.add_note(f"Got type '{getFullyQualifiedName(location)}'.")
388 raise ex
389
390 tokens = self._parser.parse(markdown)
391 if repeatedTitle != "":
392 for index, token in enumerate(tokens):
393 if token.type == "heading_open" and token.level == 0:
394 if " ".join(tokens[index + 1].content.split()) == " ".join(repeatedTitle.split()):
395 del tokens[index:index + 3]
396 break
397
398 return MarkdownTranslator(self, document, location).Translate(tokens)
399
400 def Parse(self, text: str, references: bool = True) -> list[nodes.Node]:
401 """
402 Parse a text for GitHub's references and emoji into docutils nodes, linking the references and writing emoji as
403 :class:`~pyTooling.Sphinx.Node.Emoji` nodes.
404
405 An emoji is written as its shortcode, e.g. ``:tada:``, or as its characters. A shortcode GitHub doesn't know
406 stays text, as does a custom GitHub emoji, which has no characters, e.g. ``:octocat:``.
407
408 :param text: The text, without Markdown.
409 :param references: Optional, whether GitHub's references are linked; ``False`` e.g. for a link's text. Default:
410 ``True``.
411 :returns: The text, references and emoji.
412 :raises ValueError: If parameter ``text`` is ``None``.
413 :raises TypeError: If parameter ``text`` isn't of type :class:`str`.
414 """
415 if text is None:
416 raise ValueError("Parameter 'text' is None.")
417 elif not isinstance(text, str):
418 ex = TypeError("Parameter 'text' is not of type 'str'.")
419 ex.add_note(f"Got type '{getFullyQualifiedName(text)}'.")
420 raise ex
421
422 result: list[nodes.Node] = []
423 ownRepository = f"{self._owner}/{self._repository}"
424 position = 0
425 for match in REFERENCE_PATTERN.finditer(text):
426 written = match[0]
427 if (shortcode := match["shortcode"]) is not None:
428 if (characters := self._emojis.get(shortcode)) is None:
429 continue
430
431 node: nodes.Node = Emoji(characters, characters, shortcode=shortcode)
432 elif not references:
433 continue
434 elif (issue := match["issue"]) is not None:
435 repository = ownRepository if match["issueRepository"] is None else match["issueRepository"]
436 node = nodes.reference(
437 written, written, refuri=f"{self._serverURL}/{repository}/issues/{issue}", classes=["github-issue"]
438 )
439 elif (sha := match["sha"]) is not None:
440 if sha.isdigit() or not any(character.isdigit() for character in sha):
441 continue
442
443 repository = ownRepository if match["commitRepository"] is None else match["commitRepository"]
444 node = nodes.reference(
445 written, "", refuri=f"{self._serverURL}/{repository}/commit/{sha}", classes=["github-commit"]
446 )
447 if match["commitRepository"] is not None:
448 node += nodes.Text(f"{repository}@")
449 node += nodes.literal(sha[:7], sha[:7])
450 else:
451 node = nodes.reference(
452 written, written, refuri=f"{self._serverURL}/{match['user']}", classes=["github-user"]
453 )
454
455 result.extend(self._ConvertEmojiCharacters(text[position:match.start()]))
456 result.append(node)
457 position = match.end()
458
459 result.extend(self._ConvertEmojiCharacters(text[position:]))
460 return result
461
462 def _ConvertEmojiCharacters(self, text: str) -> list[nodes.Node]:
463 """
464 Convert a text into text and :class:`~pyTooling.Sphinx.Node.Emoji` nodes for the emoji written as characters.
465
466 :param text: The text.
467 :returns: The text and the emoji; nothing for an empty text.
468 """
469 result: list[nodes.Node] = []
470 position = 0
471 for match in self._emojiPattern.finditer(text):
472 if match.end() == match.start():
473 continue
474
475 if match.start() > position:
476 result.append(nodes.Text(text[position:match.start()]))
477
478 characters = match[0]
479 result.append(Emoji(characters, characters, shortcode=self._shortcodes[characters.replace("\ufe0f", "")]))
480 position = match.end()
481
482 if position < len(text):
483 result.append(nodes.Text(text[position:]))
484
485 return result
486
487
488@export
489class RasterizeShieldsBadges(SphinxPostTransform):
490 """
491 A post-transform fetching the shields.io badges of converted Markdown as PNG in LaTeX, which can't include SVG.
492
493 A badge's URI is moved to :data:`~pyTooling.Sphinx.Shields.SHIELDS_SERVICE_PNG`, without a suffix ``.svg``. Every
494 other SVG image is left to :mod:`sphinx.ext.imgconverter`, which pyTooling.Sphinx loads and which converts it with
495 ImageMagick.
496 """
497
498 default_priority = 50 #: Priority among the post-transforms: before Sphinx downloads remote images.
499 formats = ("latex", ) #: Output formats the transform runs for.
500
501 def run(self, **kwargs: Any) -> None:
502 """
503 Move the shields.io badges of converted Markdown to the service rasterizing them.
504
505 The images of converted Markdown have class :data:`~pyTooling.Sphinx.Markdown.IMAGE_CLASS`.
506
507 :param kwargs: Keyword arguments Sphinx passes to a post-transform; none is used.
508 """
509 for image in self.document.findall(nodes.image):
510 if IMAGE_CLASS not in image["classes"]:
511 continue
512
513 uri = urlsplit(image["uri"])
514 if f"{uri.scheme}://{uri.netloc}" == SHIELDS_SERVICE_SVG:
515 raster = urlsplit(SHIELDS_SERVICE_PNG)._replace(path=uri.path.removesuffix(".svg"), query=uri.query)
516 image["uri"] = raster.geturl()
517 image["candidates"] = {"?": image["uri"]}