GitHubMarkdown.py
Summary
- Lines:
213 of 226 covered (94.2%)
- Excluded lines:
2
- Branches:
88 of 96 taken (91.7%), 8 lines partially
- Coverage:
93.5%
- Directory:
- Missed lines:
207-209, 216, 383-385, 418, 420-422, 475, 513
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
103EMOJI_FILE = "GitHubEmoji.txt"
104"""
105Name of the emoji table in :mod:`pyTooling.Sphinx.Resources`: a line per emoji, its code points and its shortcodes,
106generated from github/gemoji (MIT license, Copyright (c) 2019 GitHub, Inc.).
107"""
108
109OWNER_PATTERN = re_compile(r"[A-Za-z0-9](?:-?[A-Za-z0-9])*")
110"""A GitHub owner - a user or an organization: ASCII letters, digits and single hyphens, not starting with a hyphen."""
111
112REPOSITORY_NAME_PATTERN = re_compile(r"[A-Za-z0-9._-]+")
113"""A GitHub repository's name: ASCII letters, digits, ``.``, ``_`` and ``-``."""
114
115REPOSITORY_PATTERN = re_compile(rf"{OWNER_PATTERN.pattern}/{REPOSITORY_NAME_PATTERN.pattern}")
116"""A GitHub repository with its owner, as a reference writes it: ``<owner>/<name>``."""
117
118_BEFORE = r"(?<![\w/@#.-])"
119"""What can't precede a reference: a word character, or a character continuing a path, a reference or a URL."""
120
121REFERENCE_PATTERN = re_compile(
122 r":(?P<shortcode>[a-z0-9_+-]+):"
123 rf"|{_BEFORE}(?:(?P<issueRepository>{REPOSITORY_PATTERN.pattern})#|GH-|#)(?P<issue>[0-9]+)(?![\w-])"
124 rf"|{_BEFORE}(?:(?P<commitRepository>{REPOSITORY_PATTERN.pattern})@)?(?P<sha>[0-9a-f]{{7,40}})(?![\w-])"
125 rf"|{_BEFORE}@(?P<user>{OWNER_PATTERN.pattern})(?![\w/-])"
126)
127"""An emoji's shortcode, or one of GitHub's references: an issue or pull request, a commit, or a user."""
128
129
130@export
131class GitHubMarkdown(metaclass=ExtendedType, slots=True):
132 """
133 A converter of GitHub-flavoured Markdown into docutils nodes, which links GitHub's references in a repository's
134 context.
135
136 The owner and the repository are the ones ``#123`` refers to, and the server URL the one every link starts with.
137 :meth:`Convert` converts a Markdown document into docutils nodes, :meth:`Parse` parses a text without Markdown for
138 GitHub's references and emoji.
139
140 GitHub writes a line break of an issue, a comment or a release's notes as a line break, not as a space:
141 :attr:`LineBreaks` selects which.
142 """
143
144 _emojis: ClassVar[Nullable[dict[str, str]]] = None #: The emoji's characters by shortcode, once read.
145 _shortcodes: ClassVar[Nullable[dict[str, str]]] = None #: A shortcode per emoji, by its characters without U+FE0F.
146 _emojiPattern: ClassVar[Nullable[Pattern[str]]] = None #: The pattern finding emoji written as characters.
147
148 _owner: str #: The owner of the repository references refer to, a user or an organization.
149 _repository: str #: The name of the repository references refer to.
150 _serverURL: str #: The URL of the GitHub server, without a trailing ``/``.
151 _lineBreaks: bool #: Whether a line break in a paragraph is written as a line break.
152 _parser: MarkdownIt #: The Markdown parser.
153
154 def __init__(
155 self,
156 owner: str,
157 repository: str,
158 serverURL: str = "https://github.com",
159 lineBreaks: bool = True
160 ) -> None:
161 """
162 Initializes a converter for the Markdown of a repository.
163
164 :param owner: The owner of the repository references refer to, a user or an organization.
165 :param repository: The name of the repository references refer to.
166 :param serverURL: Optional, URL of the GitHub server. Default: ``"https://github.com"``.
167 :param lineBreaks: Optional, whether a line break in a paragraph is written as a line break, as
168 GitHub does in a release's notes. Default: ``True``.
169 :raises ValueError: If parameter ``owner`` is ``None``.
170 :raises TypeError: If parameter ``owner`` isn't of type :class:`str`.
171 :raises ValueError: If parameter ``owner`` isn't a GitHub user's or organization's name.
172 :raises ValueError: If parameter ``repository`` is ``None``.
173 :raises TypeError: If parameter ``repository`` isn't of type :class:`str`.
174 :raises ValueError: If parameter ``repository`` isn't a GitHub repository's name.
175 :raises ValueError: If parameter ``serverURL`` is ``None``.
176 :raises TypeError: If parameter ``serverURL`` isn't of type :class:`str`.
177 :raises ValueError: If parameter ``serverURL`` isn't an HTTP or HTTPS URL.
178 :raises ValueError: If parameter ``lineBreaks`` is ``None``.
179 :raises TypeError: If parameter ``lineBreaks`` isn't of type :class:`bool`.
180 :raises MissingDependencyError: If the extra ``markdown`` isn't installed.
181 """
182 if owner is None:
183 raise ValueError("Parameter 'owner' is None.")
184 elif not isinstance(owner, str):
185 ex = TypeError("Parameter 'owner' is not of type 'str'.")
186 ex.add_note(f"Got type '{getFullyQualifiedName(owner)}'.")
187 raise ex
188 elif OWNER_PATTERN.fullmatch(owner) is None:
189 ex = ValueError("Parameter 'owner' is not a GitHub user's or organization's name.")
190 ex.add_note(f"Got value '{owner}'.")
191 raise ex
192
193 if repository is None:
194 raise ValueError("Parameter 'repository' is None.")
195 elif not isinstance(repository, str):
196 ex = TypeError("Parameter 'repository' is not of type 'str'.")
197 ex.add_note(f"Got type '{getFullyQualifiedName(repository)}'.")
198 raise ex
199 elif REPOSITORY_NAME_PATTERN.fullmatch(repository) is None:
200 ex = ValueError("Parameter 'repository' is not a GitHub repository's name.")
201 ex.add_note(f"Got value '{repository}'.")
202 raise ex
203
204 if serverURL is None:
205 raise ValueError("Parameter 'serverURL' is None.")
206 elif not isinstance(serverURL, str):
207 ex = TypeError("Parameter 'serverURL' is not of type 'str'.")
208 ex.add_note(f"Got type '{getFullyQualifiedName(serverURL)}'.")
209 raise ex
210 elif urlsplit(serverURL).scheme not in ("https", "http") or urlsplit(serverURL).netloc == "":
211 ex = ValueError("Parameter 'serverURL' is not an HTTP or HTTPS URL.")
212 ex.add_note(f"Got value '{serverURL}'.")
213 raise ex
214
215 if lineBreaks is None:
216 raise ValueError("Parameter 'lineBreaks' is None.")
217 elif not isinstance(lineBreaks, bool):
218 ex = TypeError("Parameter 'lineBreaks' is not of type 'bool'.")
219 ex.add_note(f"Got type '{getFullyQualifiedName(lineBreaks)}'.")
220 raise ex
221
222 try:
223 from markdown_it import MarkdownIt
224 except ImportError as ex:
225 raise MissingDependencyError(dependency="markdown-it-py", extra="markdown") from ex
226
227 try:
228 from mdit_py_plugins.gfm import gfm_plugin
229 except ImportError as ex:
230 raise MissingDependencyError(dependency="mdit-py-plugins", extra="markdown") from ex
231
232 self._owner = owner
233 self._repository = repository
234 self._serverURL = serverURL.rstrip("/")
235 self._lineBreaks = lineBreaks
236 # 'text_join' would merge an escaped character into the text around it, e.g. '\#12' into a reference
237 self._parser = MarkdownIt("commonmark", {"html": True}).use(gfm_plugin).disable("text_join")
238
239 if GitHubMarkdown._emojis is None:
240 emojis: dict[str, str] = {}
241 shortcodes: dict[str, str] = {}
242 for line in readResourceFile(SphinxResources, EMOJI_FILE).splitlines():
243 if line.startswith("#") or line.strip() == "":
244 continue
245
246 codePoints, *names = line.split()
247 characters = "".join(chr(int(codePoint, 16)) for codePoint in codePoints.split("-"))
248 shortcodes[characters.replace("\ufe0f", "")] = names[0]
249 for name in names:
250 emojis[name] = characters
251
252 GitHubMarkdown._emojiPattern = self._EmojiPattern(shortcodes)
253 GitHubMarkdown._shortcodes = shortcodes
254 GitHubMarkdown._emojis = emojis
255
256 @staticmethod
257 def _EmojiPattern(shortcodes: dict[str, str]) -> Pattern[str]:
258 """
259 Build the pattern finding the emoji written as characters in a text: a trie of their characters, each optionally
260 followed by variation selector 16 (U+FE0F).
261
262 A single character below U+2190 - e.g. the copyright sign - is an emoji only if followed by U+FE0F; else it is
263 a character of the text font.
264
265 :param shortcodes: The emoji's characters without U+FE0F.
266 :returns: The pattern.
267 """
268 trie: dict[str, Any] = {}
269 for characters in shortcodes:
270 level = trie
271 for character in characters:
272 level = level.setdefault(character, {})
273 level[""] = {}
274
275 def alternatives(level: dict[str, Any], depth: int) -> str:
276 """
277 Nested function for recursion.
278
279 :param level: A level of the trie, by character; key ``""`` marks the end of an emoji.
280 :param depth: The level's depth, ``0`` for the first character.
281 :returns: The level's pattern.
282 """
283 branches = []
284 for character, nextLevel in sorted(level.items()):
285 if character == "":
286 continue
287
288 selector = "\ufe0f" if depth == 0 and ord(character) < 0x2190 and list(nextLevel) == [""] else "\ufe0f?"
289 branches.append(f"{re_escape(character)}{selector}{alternatives(nextLevel, depth + 1)}")
290
291 if len(branches) == 0:
292 return ""
293
294 optional = "?" if "" in level else ""
295 return f"(?:{'|'.join(branches)}){optional}"
296
297 return re_compile(alternatives(trie, 0))
298
299 @readonly
300 def Owner(self) -> str:
301 """
302 Read-only property to access the owner of the repository references refer to (:attr:`_owner`).
303
304 :returns: The owner, a user or an organization.
305 """
306 return self._owner
307
308 @readonly
309 def Repository(self) -> str:
310 """
311 Read-only property to access the name of the repository references refer to (:attr:`_repository`).
312
313 :returns: The repository's name.
314 """
315 return self._repository
316
317 @readonly
318 def ServerURL(self) -> str:
319 """
320 Read-only property to access the URL of the GitHub server (:attr:`_serverURL`).
321
322 :returns: The URL, without a trailing ``/``.
323 """
324 return self._serverURL
325
326 @readonly
327 def LineBreaks(self) -> bool:
328 """
329 Read-only property to access whether a line break in a paragraph is written as a line break (:attr:`_lineBreaks`).
330
331 :returns: ``True``, if a line break is written as a line break; ``False``, if as a space.
332 """
333 return self._lineBreaks
334
335 def Convert(
336 self,
337 markdown: str,
338 document: nodes.document,
339 repeatedTitle: str = "",
340 location: Nullable[str] = None
341 ) -> list[nodes.Node]:
342 """
343 Convert a Markdown document into docutils nodes.
344
345 The nodes are body elements a directive returns; headings become topics and rubrics (see the module's
346 description). A footnote's ID is unique in the document.
347
348 The first heading of the Markdown can repeat the title, which the nodes are put under, e.g. ``# GHDL 5.0.1`` in
349 the notes of a release titled so. Given as ``repeatedTitle``, such a heading is skipped, so the headings after it
350 become the topics.
351
352 :param markdown: The Markdown.
353 :param document: The document the nodes are put into.
354 :param repeatedTitle: Optional, the title the first heading is skipped for; compared without surrounding and
355 repeated whitespace. Default: ``""``, no heading is skipped.
356 :param location: Optional, location the warnings are reported at, e.g. ``index.rst:12``. Default: ``None``.
357 :returns: The nodes.
358 :raises ValueError: If parameter ``markdown`` is ``None``.
359 :raises TypeError: If parameter ``markdown`` isn't of type :class:`str`.
360 :raises ValueError: If parameter ``document`` is ``None``.
361 :raises TypeError: If parameter ``document`` isn't of type :class:`~docutils.nodes.document`.
362 :raises ValueError: If parameter ``repeatedTitle`` is ``None``.
363 :raises TypeError: If parameter ``repeatedTitle`` isn't of type :class:`str`.
364 :raises TypeError: If parameter ``location`` isn't of type :class:`str`.
365 """
366 if markdown is None:
367 raise ValueError("Parameter 'markdown' is None.")
368 elif not isinstance(markdown, str):
369 ex = TypeError("Parameter 'markdown' is not of type 'str'.")
370 ex.add_note(f"Got type '{getFullyQualifiedName(markdown)}'.")
371 raise ex
372
373 if document is None:
374 raise ValueError("Parameter 'document' is None.")
375 elif not isinstance(document, nodes.document):
376 ex = TypeError("Parameter 'document' is not of type 'docutils.nodes.document'.")
377 ex.add_note(f"Got type '{getFullyQualifiedName(document)}'.")
378 raise ex
379
380 if repeatedTitle is None:
381 raise ValueError("Parameter 'repeatedTitle' is None.")
382 elif not isinstance(repeatedTitle, str):
383 ex = TypeError("Parameter 'repeatedTitle' is not of type 'str'.")
384 ex.add_note(f"Got type '{getFullyQualifiedName(repeatedTitle)}'.")
385 raise ex
386
387 if location is not None and not isinstance(location, str):
388 ex = TypeError("Parameter 'location' is not of type 'str'.")
389 ex.add_note(f"Got type '{getFullyQualifiedName(location)}'.")
390 raise ex
391
392 tokens = self._parser.parse(markdown)
393 if repeatedTitle != "":
394 for index, token in enumerate(tokens):
395 if token.type == "heading_open" and token.level == 0:
396 if " ".join(tokens[index + 1].content.split()) == " ".join(repeatedTitle.split()):
397 del tokens[index:index + 3]
398 break
399
400 return MarkdownTranslator(self, document, location).Translate(tokens)
401
402 def Parse(self, text: str, references: bool = True) -> list[nodes.Node]:
403 """
404 Parse a text for GitHub's references and emoji into docutils nodes, linking the references and writing emoji as
405 :class:`~pyTooling.Sphinx.Node.Emoji` nodes.
406
407 An emoji is written as its shortcode, e.g. ``:tada:``, or as its characters. A shortcode GitHub doesn't know
408 stays text, as does a custom GitHub emoji, which has no characters, e.g. ``:octocat:``.
409
410 :param text: The text, without Markdown.
411 :param references: Optional, whether GitHub's references are linked; ``False`` e.g. for a link's text. Default:
412 ``True``.
413 :returns: The text, references and emoji.
414 :raises ValueError: If parameter ``text`` is ``None``.
415 :raises TypeError: If parameter ``text`` isn't of type :class:`str`.
416 """
417 if text is None:
418 raise ValueError("Parameter 'text' is None.")
419 elif not isinstance(text, str):
420 ex = TypeError("Parameter 'text' is not of type 'str'.")
421 ex.add_note(f"Got type '{getFullyQualifiedName(text)}'.")
422 raise ex
423
424 result: list[nodes.Node] = []
425 ownRepository = f"{self._owner}/{self._repository}"
426 position = 0
427 for match in REFERENCE_PATTERN.finditer(text):
428 written = match[0]
429 if (shortcode := match["shortcode"]) is not None:
430 if (characters := self._emojis.get(shortcode)) is None:
431 continue
432
433 node: nodes.Node = Emoji(characters, characters, shortcode=shortcode)
434 elif not references:
435 continue
436 elif (issue := match["issue"]) is not None:
437 repository = ownRepository if match["issueRepository"] is None else match["issueRepository"]
438 node = nodes.reference(
439 written, written, refuri=f"{self._serverURL}/{repository}/issues/{issue}", classes=["github-issue"]
440 )
441 elif (sha := match["sha"]) is not None:
442 if sha.isdigit() or not any(character.isdigit() for character in sha):
443 continue
444
445 repository = ownRepository if match["commitRepository"] is None else match["commitRepository"]
446 node = nodes.reference(
447 written, "", refuri=f"{self._serverURL}/{repository}/commit/{sha}", classes=["github-commit"]
448 )
449 if match["commitRepository"] is not None:
450 node += nodes.Text(f"{repository}@")
451 node += nodes.literal(sha[:7], sha[:7])
452 else:
453 node = nodes.reference(
454 written, written, refuri=f"{self._serverURL}/{match['user']}", classes=["github-user"]
455 )
456
457 result.extend(self._ConvertEmojiCharacters(text[position:match.start()]))
458 result.append(node)
459 position = match.end()
460
461 result.extend(self._ConvertEmojiCharacters(text[position:]))
462 return result
463
464 def _ConvertEmojiCharacters(self, text: str) -> list[nodes.Node]:
465 """
466 Convert a text into text and :class:`~pyTooling.Sphinx.Node.Emoji` nodes for the emoji written as characters.
467
468 :param text: The text.
469 :returns: The text and the emoji; nothing for an empty text.
470 """
471 result: list[nodes.Node] = []
472 position = 0
473 for match in self._emojiPattern.finditer(text):
474 if match.end() == match.start():
475 continue
476
477 if match.start() > position:
478 result.append(nodes.Text(text[position:match.start()]))
479
480 characters = match[0]
481 result.append(Emoji(characters, characters, shortcode=self._shortcodes[characters.replace("\ufe0f", "")]))
482 position = match.end()
483
484 if position < len(text):
485 result.append(nodes.Text(text[position:]))
486
487 return result
488
489
490@export
491class RasterizeShieldsBadges(SphinxPostTransform):
492 """
493 A post-transform fetching the shields.io badges of converted Markdown as PNG in LaTeX, which can't include SVG.
494
495 A badge's URI is moved to :data:`~pyTooling.Sphinx.Shields.SHIELDS_SERVICE_PNG`, without a suffix ``.svg``. Every
496 other SVG image is left to :mod:`sphinx.ext.imgconverter`, which pyTooling.Sphinx loads and which converts it with
497 ImageMagick.
498 """
499
500 default_priority = 50 #: Priority among the post-transforms: before Sphinx downloads remote images.
501 formats = ("latex", ) #: Output formats the transform runs for.
502
503 def run(self, **kwargs: Any) -> None:
504 """
505 Move the shields.io badges of converted Markdown to the service rasterizing them.
506
507 The images of converted Markdown have class :data:`~pyTooling.Sphinx.Markdown.IMAGE_CLASS`.
508
509 :param kwargs: Keyword arguments Sphinx passes to a post-transform; none is used.
510 """
511 for image in self.document.findall(nodes.image):
512 if IMAGE_CLASS not in image["classes"]:
513 continue
514
515 uri = urlsplit(image["uri"])
516 if f"{uri.scheme}://{uri.netloc}" == SHIELDS_SERVICE_SVG:
517 raster = urlsplit(SHIELDS_SERVICE_PNG)._replace(path=uri.path.removesuffix(".svg"), query=uri.query)
518 image["uri"] = raster.geturl()
519 image["candidates"] = {"?": image["uri"]}