Source code for click_extra.sphinx.myst_docstrings

# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
"""Convert MyST-flavored docstrings to reST for `sphinx.ext.autodoc`.

Lightweight replacement for
[`sphinx-autodoc2`](https://github.com/sphinx-extensions2/sphinx-autodoc2),
which provided native MyST docstring parsing but is abandoned (last release
`0.5.0`, November 2023; incompatible with current Sphinx and docutils).

Hooks into `autodoc-process-docstring` to transparently convert MyST markdown
syntax in Python docstrings to reStructuredText before Sphinx processes them.
Preserves full compatibility with `sphinx_autodoc_typehints`,
`autodoc_default_options`, and every other extension that builds on
`sphinx.ext.autodoc`. See {doc}`/myst-docstrings` for setup, usage, and
limitations.

The conversion is idempotent: docstrings already in reST pass through
unchanged. This allows incremental migration one module at a time.

Supported conversions:

```{list-table}
:header-rows: 1

* - Construct
  - MyST input
  - reST output
* - Cross-references
  - ``{role}`target```
  - ``:role:`target```
* - Fenced directives
  - `` ```{note} ``
  - `.. note::`
* - Plain code fences
  - ```` ```python ````
  - `.. code-block:: python`
* - Markdown links
  - `[text](url)`
  - ```text <url>`_``
* - Footnote references
  - `[^label]`
  - `[#label]_`
* - Footnote definitions
  - `[^label]: text`
  - `.. [#label] text`
```

Inline code (single backtick) is converted to reST double backticks.
Field list markers (`:param:`, `:return:`) need no conversion; the content
inside field list entries is converted normally (inline code, cross-references,
links).

````{note}
Register this extension in your Sphinx `conf.py`, before
`sphinx_autodoc_typehints` if present:

```{code-block} python
extensions = [
    "sphinx.ext.autodoc",
    "click_extra.sphinx.myst_docstrings",
    "sphinx_autodoc_typehints",  # must come after
]
```

This requires `click-extra[sphinx]` in your docs dependency group.
````
"""

from __future__ import annotations

import logging
import re

from sphinx.errors import ExtensionError

from .. import __version__

logger = logging.getLogger(__name__)

# {role}`target` -> {role}`target`
# Negative lookbehind prevents matching inside double backticks (``{version}``).
_XREF_RE = re.compile(r"(?<!``)\{([\w-]+)\}`([^`]*?)`")

# Colon fences are recognized for legacy docstrings; new content uses
# backtick fences (see `_BACKTICK_FENCE_RE` below).
# :::{directive} optional-title
# body
# :::
_COLON_FENCE_RE = re.compile(
    r"^( *):::\{([\w-]+)\}[ ]*([^\n]*)\n(.*?)^\1:::\s*$",
    re.MULTILINE | re.DOTALL,
)
_BACKTICK_FENCE_RE = re.compile(
    r"^( *)```\{([\w-]+)\}[ ]*([^\n]*)\n(.*?)^\1```\s*$",
    re.MULTILINE | re.DOTALL,
)

# ```language           (plain code fence, no {directive})
# code
# ```
# Negative lookahead for `{` prevents matching directive fences that were not
# consumed by the directive regex (malformed body, etc.).
_PLAIN_CODE_FENCE_RE = re.compile(
    r"^( *)```(?!\{)([\w.+-][\w.+-]*)?\s*\n(.*?)^\1```\s*$",
    re.MULTILINE | re.DOTALL,
)

# [^label] -> [#label]_   (footnote reference, not followed by ":" definition)
_FOOTNOTE_REF_RE = re.compile(r"\[\^([\w-]+)\](?!:)")

# [^label]: text -> .. [#label] text   (footnote definition)
_FOOTNOTE_DEF_RE = re.compile(r"^\[\^([\w-]+)\]:\s?(.*)$", re.MULTILINE)

# [text](url) but not ![alt](url)
_LINK_RE = re.compile(r"(?<!!)\[([^\]]+)\]\(([^)]+)\)")


def _convert_link(match: re.Match[str]) -> str:
    """Convert a markdown link to reST, stripping backticks from the label."""
    label = match.group(1).replace("`", "")
    url = match.group(2)
    return f"`{label} <{url}>`_"


# Single-backtick inline code spans are doubled for reST (after protected
# spans are placeholdered out).
_INLINE_CODE_RE = re.compile(r"(?<!`)`([^`\n]+)`(?!`)")

# A reST double-backtick literal on a single line.  The lookarounds pin the
# match to exactly two backticks on each side, so triple-backtick fence lines
# and longer backtick runs are never mistaken for literal delimiters.
_RST_LITERAL_RE = re.compile(r"(?<!`)``(?!`)[^`\n]+``(?!`)")

# Backtick spans that must NOT be treated as plain inline code, lexed in one
# left-to-right pass so each alternative consumes its span before a later
# alternative can restart inside it.  In priority order:
#   {role}`target`   β€” reST cross-references (from step 1), kept verbatim
#   `text`_          β€” reST hyperlink references (idempotent pass-through)
#   `:role-shaped:`  β€” a code span holding a literal role or option name
#                      (like `:tag-pattern:`), doubled for reST on the spot
# The single pass is what makes adjacency safe: when two converted roles
# abut with no separator, the first role consumes the backtick that the
# role-shaped-span alternative would otherwise claim as its opening
# delimiter.  Without the last alternative, the role pattern would read a
# role-shaped span's closing backtick as a role's target delimiter and
# swallow everything up to the next backtick, corrupting the span and any
# cross-reference after it.
# The hyperlink alternative anchors its opening backtick at a non-word,
# non-backtick boundary AND requires its trailing reference marker (`_` or
# `__`) not to be glued to an identifier. The boundary alone is not enough:
# when a preceding code span's content ends in a non-word character (the `>`
# of `` `<stdout>` ``, the `)` of `` `func()` ``), that span's *closing*
# backtick also clears the lookbehind, so the alternative would start there,
# read the gap as link text, swallow the next span's *opening* backtick, and
# match a following `_word` as a bogus reference marker: the two real spans
# collapse into one and their backticks mispair. A genuine reST reference
# marker is never followed by a word character, so the trailing `(?![\w])`
# rejects that false match while still matching real `` `text`_ `` links.
_PROTECTED_RE = re.compile(
    r":[\w-]+:`[^`]*`"
    r"|(?<![\w`])`[^`]+`_{1,2}(?![\w])"
    r"|(?<!`)`(:[\w-]+:[^`\n]*)`(?!`)"
)


def _convert_plain_code_fence(match: re.Match[str]) -> str:
    """Convert a plain triple-backtick code fence to a reST `code-block`."""
    indent = match.group(1)
    lang = match.group(2) or ""
    body = match.group(3)

    header = f"{indent}.. code-block::"
    if lang:
        header += f" {lang}"
    header += "\n"

    body_indent = indent + "    "
    converted_lines: list[str] = []
    for line in body.split("\n"):
        if line.strip():
            stripped = line.lstrip()
            extra_spaces = len(line) - len(stripped) - len(indent)
            converted_lines.append(body_indent + " " * max(0, extra_spaces) + stripped)
        else:
            converted_lines.append("")

    # reST directives need a blank line between the header and the body.
    return header + "\n" + "\n".join(converted_lines) + "\n"


def _convert_fence(match: re.Match[str]) -> str:
    """Convert a single colon-fenced directive to reST."""
    indent = match.group(1)
    directive = match.group(2)
    title = match.group(3).strip()
    body = match.group(4)

    header = f"{indent}.. {directive}::"
    if title:
        header += f" {title}"
    header += "\n"

    body_indent = indent + "    "
    converted_lines: list[str] = []
    for line in body.split("\n"):
        if line.strip():
            # Preserve relative indentation within the body.
            stripped = line.lstrip()
            extra_spaces = len(line) - len(stripped) - len(indent)
            converted_lines.append(body_indent + " " * max(0, extra_spaces) + stripped)
        else:
            converted_lines.append("")

    # reST directives need a blank line between the header and the body.
    return header + "\n" + "\n".join(converted_lines) + "\n"


def _double_inline_code(m: re.Match[str]) -> str:
    """Double a MyST inline-code span's backticks for reST.

    reST inline literals cannot carry whitespace adjacent to their delimiters,
    so a span whose content has leading or trailing spaces (a `` `> ` ``
    prompt) is stripped to its core before doubling. An all-whitespace span has
    no reST literal to become, so it is left untouched.
    """
    content = m.group(1).strip()
    if not content:
        return m.group(0)
    return f"``{content}``"


[docs] def myst_to_rst(lines: list[str]) -> None: """Convert MyST syntax to reST, modifying *lines* in place. The conversion is idempotent: reST-only docstrings pass through unchanged because none of the patterns match reST syntax. """ text = "\n".join(lines) placeholders: dict[str, str] = {} counter = 0 def _stash(value: str) -> str: nonlocal counter key = f"\x00P{counter}\x00" counter += 1 placeholders[key] = value return key def _save(m: re.Match[str]) -> str: return _stash(m.group(0)) def _save_protected(m: re.Match[str]) -> str: # A role-shaped code span (group 1) is doubled for reST on its way # into the placeholder; other protected spans are kept verbatim. if m.group(1): return _stash(f"``{m.group(1)}``") return _stash(m.group(0)) # 0. Protect reST double-backtick literals from every following step. # Their content is verbatim by definition, and a brace-bearing literal # abutting its closing backticks (like ``{levelname}:{message}``) would # otherwise be misread by the cross-reference pattern as a {role} with an # empty target, mangling the literal. text = _RST_LITERAL_RE.sub(_save, text) # 1. Cross-references: {role}`target` -> {role}`target`. text = _XREF_RE.sub(r":\1:`\2`", text) # 2. Fenced directives -> reST directives. # Handles both colon fences (:::) and backtick fences (```). # Loop handles nesting (rare in docstrings, but correct). prev = None while prev != text: prev = text text = _COLON_FENCE_RE.sub(_convert_fence, text) text = _BACKTICK_FENCE_RE.sub(_convert_fence, text) # 3. Plain code fences -> reST code-block directives. # Runs after directive fences are consumed so only plain fences remain. text = _PLAIN_CODE_FENCE_RE.sub(_convert_plain_code_fence, text) # 4. Footnote definitions: [^label]: text -> .. [#label] text. # Runs before inline code so the reST output is not mangled. text = _FOOTNOTE_DEF_RE.sub(r".. [#\1] \2", text) # 5. Single-backtick inline code -> double-backtick. # Protect reST backtick spans (cross-references from step 1, reST # hyperlink references in idempotent pass-through, and role-shaped code # spans, which are doubled as they are stashed) with placeholders so # their backticks are not mistaken for inline code boundaries. text = _PROTECTED_RE.sub(_save_protected, text) text = _INLINE_CODE_RE.sub(_double_inline_code, text) # 6. Footnote references: [^label] -> [#label]_. # Runs after inline code (no backticks involved) and before links so that # [^label] is not mistaken for a markdown link with text "^label". text = _FOOTNOTE_REF_RE.sub(r"[#\1]_", text) # 7. Markdown links -> reST links. # Runs after inline code so that the reST links it produces (which use # single backticks: `text <url>`_) are not doubled by step 5. # Backticks in link labels are stripped because reST does not support # nested markup (inline code inside hyperlinks). This lets authors write # idiomatic MyST like [`sys.platform`](url) and get a clean reST link. text = _LINK_RE.sub(_convert_link, text) # Restore the spans protected in steps 0 and 5, byte-for-byte. for key, value in placeholders.items(): text = text.replace(key, value) lines[:] = text.split("\n")
def _on_process_docstring(app, what, name, obj, options, lines): """`autodoc-process-docstring` event handler.""" myst_to_rst(lines) _TYPEHINTS_EXT = "sphinx_autodoc_typehints" """Extension whose `autodoc-process-docstring` hook must run after ours. `sphinx_autodoc_typehints` hooks at default priority 500. We register at 400 so MyST-to-reST conversion always runs first, regardless of `conf.py` ordering. The `setup()` check is a belt-and-suspenders safety net: it catches misconfiguration early with a clear message rather than letting it surface as garbled backticks in the rendered docs. """ _PRIORITY = 400 """Event priority for `autodoc-process-docstring`. Sphinx invokes callbacks in ascending priority order. The default is 500. We use 400 to guarantee MyST-to-reST conversion runs before any extension that injects reST markup into docstrings (like `sphinx_autodoc_typehints`). """
[docs] def setup(app): """Sphinx extension entry point. :raises ExtensionError: If `sphinx_autodoc_typehints` is already loaded. """ if _TYPEHINTS_EXT in app.extensions: msg = ( f"click_extra.sphinx.myst_docstrings must be listed before" f" {_TYPEHINTS_EXT} in conf.py extensions." f" Both hook autodoc-process-docstring; while priority" f" ordering (400 vs 500) handles execution order," f" listing myst_docstrings first makes the intent explicit." ) raise ExtensionError(msg) app.connect("autodoc-process-docstring", _on_process_docstring, _PRIORITY) return { "version": __version__, "parallel_read_safe": True, "parallel_write_safe": True, }