# 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.
"""Tests for the MyST-to-reST docstring conversion hook."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from docutils import nodes
from docutils.core import publish_doctree
from sphinx.errors import ExtensionError
from click_extra.sphinx.myst_docstrings import _TYPEHINTS_EXT, myst_to_rst, setup
def _convert(text: str) -> str:
"""Helper: run myst_to_rst on a string and return the result."""
lines = text.split("\n")
myst_to_rst(lines)
return "\n".join(lines)
# ---- Cross-references: {role}`target` -> :role:`target` --------------------
[docs]
@pytest.mark.parametrize(
("myst", "expected"),
[
("{func}`foo`", ":func:`foo`"),
("{data}`~extra_platforms.MACOS`", ":data:`~extra_platforms.MACOS`"),
("{deco}`~pytest.skip_linux`", ":deco:`~pytest.skip_linux`"),
(
"{func}`is_a`, {func}`is_b` and {data}`C`",
":func:`is_a`, :func:`is_b` and :data:`C`",
),
("a {class}`frozenset` of items", "a :class:`frozenset` of items"),
# Adjacent xrefs with no whitespace.
("{func}`a`{func}`b`", ":func:`a`:func:`b`"),
# Hyphenated role name.
("{pull-request}`#42`", ":pull-request:`#42`"),
# Empty target (Sphinx will warn later).
("{func}``", ":func:``"),
],
ids=[
"simple",
"tilde",
"custom-role",
"multiple",
"mid-sentence",
"adjacent-no-space",
"hyphenated-role",
"empty-target",
],
)
def test_xref_conversion(myst, expected):
assert _convert(myst) == expected
# ---- Colon-fenced directives: :::{directive} -> .. directive:: -------------
[docs]
@pytest.mark.parametrize(
("myst", "expected_fragments"),
[
(
":::{note}\nSome content.\n:::",
[".. note::", " Some content."],
),
(
":::{hint}\nLine one.\nLine two.\n:::",
[".. hint::", " Line one.", " Line two."],
),
(
":::{warning} Be careful\nDon't do this.\n:::",
[".. warning:: Be careful", " Don't do this."],
),
(
":::{note}\nOuter.\n\n - Item one.\n - Item two.\n:::",
[".. note::", " - Item one."],
),
(
":::{seealso}\nSource: <https://example.com>\n:::",
[".. seealso::", " Source: <https://example.com>"],
),
# Empty body.
(":::{note}\n:::", [".. note::"]),
# Indented fence (nested context).
(
" :::{warning}\n Watch out.\n :::",
[" .. warning::", " Watch out."],
),
# Directive with option lines.
(
":::{list-table}\n:header-rows: 1\n\n* - A\n - B\n:::",
[".. list-table::", " :header-rows: 1"],
),
],
ids=[
"note",
"multiline",
"with-title",
"indented-body",
"seealso",
"empty-body",
"indented-fence",
"directive-options",
],
)
def test_colon_fence_conversion(myst, expected_fragments):
result = _convert(myst)
for fragment in expected_fragments:
assert fragment in result
[docs]
def test_colon_fence_preserves_blank_lines():
result = _convert(":::{note}\nFirst.\n\nSecond.\n:::")
assert "" in result.split("\n")
[docs]
def test_colon_fence_blank_line_before_body():
"""Converted directive inserts blank line between header and body."""
result = _convert(":::{code-block} python\nprint(1)\n:::")
lines = result.split("\n")
header_idx = next(i for i, l in enumerate(lines) if ".. code-block::" in l)
assert lines[header_idx + 1] == "", "blank line required after directive header"
# ---- Backtick-fenced directives: ```{directive} -> .. directive:: ----------
[docs]
@pytest.mark.parametrize(
("myst", "expected_fragments"),
[
(
"```{note}\nSome content.\n```",
[".. note::", " Some content."],
),
(
"```{warning} Be careful\nDon't do this.\n```",
[".. warning:: Be careful", " Don't do this."],
),
(
"```{code-block} python\nprint(1)\n```",
[".. code-block:: python", " print(1)"],
),
# Empty body.
("```{tip}\n```", [".. tip::"]),
# Inline code inside body must not close the fence.
(
"```{note}\nUse `foo` here.\n```",
[".. note::", " Use ``foo`` here."],
),
],
ids=["note", "with-title", "code-block", "empty-body", "inline-code-in-body"],
)
def test_backtick_fence_conversion(myst, expected_fragments):
result = _convert(myst)
for fragment in expected_fragments:
assert fragment in result
[docs]
def test_mixed_colon_outer_backtick_inner():
"""Colon fence wrapping a backtick-fenced code block."""
myst = ":::{note}\nExample:\n\n```{code-block} python\nx = 1\n```\n:::"
result = _convert(myst)
assert ".. note::" in result
assert ".. code-block:: python" in result
# ---- Plain code fences: ```lang -> .. code-block:: lang ---------------------
[docs]
@pytest.mark.parametrize(
("myst", "expected_fragments"),
[
# With language identifier.
(
"```python\nprint(1)\n```",
[".. code-block:: python", " print(1)"],
),
# No language identifier.
(
"```\nsome code\n```",
[".. code-block::", " some code"],
),
# Language with hyphen.
(
"```shell-session\n$ ls\n```",
[".. code-block:: shell-session", " $ ls"],
),
# Multi-line body.
(
"```python\nx = 1\ny = 2\n```",
[".. code-block:: python", " x = 1", " y = 2"],
),
# Indented fence.
(
" ```python\n x = 1\n ```",
[" .. code-block:: python", " x = 1"],
),
# Language with dots (like c++, c#).
(
"```c++\nint x = 0;\n```",
[".. code-block:: c++", " int x = 0;"],
),
],
ids=[
"with-language",
"no-language",
"hyphenated-language",
"multiline-body",
"indented-fence",
"language-with-special-chars",
],
)
def test_plain_code_fence_conversion(myst, expected_fragments):
result = _convert(myst)
for fragment in expected_fragments:
assert fragment in result
[docs]
def test_plain_code_fence_blank_line_before_body():
"""Converted plain code fence inserts blank line between header and body."""
result = _convert("```python\nprint(1)\n```")
lines = result.split("\n")
header_idx = next(i for i, l in enumerate(lines) if ".. code-block::" in l)
assert lines[header_idx + 1] == "", "blank line required after directive header"
[docs]
def test_plain_code_fence_not_directive():
"""Plain code fences must not be confused with directive fences."""
# Directive fence: already handled by the directive regex.
directive = "```{note}\nSome content.\n```"
result = _convert(directive)
assert ".. note::" in result
assert ".. code-block::" not in result
[docs]
def test_plain_code_fence_idempotent():
"""Already-converted code-block directive passes through unchanged."""
rst = ".. code-block:: python\n\n print(1)"
assert _convert(rst) == rst
# ---- Directive options: right under the reST header ---------------------------
[docs]
def test_role_opening_a_body_is_not_an_option():
"""A body opening on a role keeps it as content: an option needs a space
after its colon-wrapped name."""
myst = "```{note}\n{mod}`sqlite3` is imported here.\n```"
assert _convert(myst) == ".. note::\n\n :mod:`sqlite3` is imported here.\n\n"
# ---- Code blocks keep their content verbatim ---------------------------------
[docs]
@pytest.mark.parametrize(
("myst", "expected"),
[
(
'```python\nbasket = "`kiwi`" # {func}`peel`, [docs](https://e.org)\n```',
(
".. code-block:: python\n"
"\n"
' basket = "`kiwi`" # {func}`peel`, [docs](https://e.org)\n'
"\n"
),
),
(
"```{code-block} markdown\n:caption: Recipe\n\nSee [^1] and `x`.\n```",
(
".. code-block:: markdown\n"
" :caption: Recipe\n"
"\n"
" See [^1] and `x`.\n"
"\n"
),
),
(
":::{tip}\nRun `peel`:\n\n```shell-session\n$ peel `basket`\n```\n:::",
(
".. tip::\n"
"\n"
" Run ``peel``:\n"
"\n"
" .. code-block:: shell-session\n"
"\n"
" $ peel `basket`\n"
"\n"
),
),
],
ids=["plain-fence", "code-block-directive", "nested-in-colon-directive"],
)
def test_code_block_content_is_verbatim(myst, expected):
"""Code is not prose: no backtick, role, link or footnote in it converts."""
assert _convert(myst) == expected
[docs]
def test_closing_fence_does_not_open_a_code_block():
"""The bare line closing a directive fence pairs with that fence.
Read as the opener of a plain code fence instead, it would hold everything
up to the next bare fence as code, the directive after it included.
"""
myst = (
"```{list-table}\n* - Fig\n```\n"
"\n"
"```{code-block} python\n:emphasize-lines: 1\n\nfig = 1\n```"
)
result = _convert(myst)
assert ".. list-table::" in result
assert ".. code-block:: python\n :emphasize-lines: 1\n\n fig = 1\n" in result
# ---- Footnotes: [^label] -> [#label]_ / [^label]: -> .. [#label] -----------
# ---- Links: [text](url) -> `text <url>`_ ----------------------------------
[docs]
@pytest.mark.parametrize(
("myst", "expected"),
[
(
"[click here](https://example.com)",
"`click here <https://example.com>`_",
),
(
"See [the docs](https://docs.example.com) for details.",
"See `the docs <https://docs.example.com>`_ for details.",
),
(
"[Wikipedia](https://en.wikipedia.org/wiki/Foo)",
"`Wikipedia <https://en.wikipedia.org/wiki/Foo>`_",
),
# Adjacent to punctuation.
(
"See [docs](https://example.com).",
"See `docs <https://example.com>`_.",
),
# Balanced parentheses in a bare destination.
(
"[`train(5)` timetable](https://example.com/man?train(5))",
"`train(5) timetable <https://example.com/man?train(5)>`_",
),
# Angle-bracketed destination, the CommonMark escape for parentheses.
(
"[`bus(7)` timetable](<https://example.com/man?bus(7)>)",
"`bus(7) timetable <https://example.com/man?bus(7)>`_",
),
],
ids=[
"simple",
"mid-sentence",
"path-url",
"adjacent-to-punctuation",
"balanced-parens-in-url",
"angle-bracketed-url",
],
)
def test_link_conversion(myst, expected):
assert _convert(myst) == expected
[docs]
@pytest.mark.parametrize(
("myst", "expected"),
[
# Image links must not be converted.
("", None),
],
ids=["image-link"],
)
def test_link_not_converted(myst, expected):
assert _convert(myst) == (expected if expected is not None else myst)
[docs]
def test_multiple_links_on_same_line():
myst = "[A](https://example.com/a) and [B](https://example.com/b)"
result = _convert(myst)
assert "`A <https://example.com/a>`_" in result
assert "`B <https://example.com/b>`_" in result
[docs]
@pytest.mark.parametrize(
("myst", "expected"),
[
(
"[`sys.platform`](https://docs.python.org/3)",
"`sys.platform <https://docs.python.org/3>`_",
),
(
"the [`/proc`](https://example.com) filesystem",
"the `/proc <https://example.com>`_ filesystem",
),
(
"[from `foo` to `bar`](https://example.com)",
"`from foo to bar <https://example.com>`_",
),
],
ids=["code-label", "code-label-mid-sentence", "multiple-code-spans"],
)
def test_link_strips_backticks_from_label(myst, expected):
"""reST has no nested markup, so backticks are stripped from link labels."""
assert _convert(myst) == expected
# ---- Inline code: `text` -> ``text`` ---------------------------------------
[docs]
@pytest.mark.parametrize(
("myst", "expected"),
[
("`True`", "``True``"),
("Returns `True` if detected.", "Returns ``True`` if detected."),
("`foo` and `bar`", "``foo`` and ``bar``"),
("`platform.machine()`", "``platform.machine()``"),
# Single character.
("`x`", "``x``"),
# Regex metacharacters.
("`foo*bar`", "``foo*bar``"),
("`a[0]`", "``a[0]``"),
# Adjacent to punctuation.
("`code`.", "``code``."),
("(`code`)", "(``code``)"),
# At line boundaries.
("`start`", "``start``"),
("end `here`", "end ``here``"),
# Content whitespace against the delimiters is stripped: reST inline
# literals cannot hold it (a `> ` PowerShell prompt, a padded span).
("use `> ` prompt", "use ``>`` prompt"),
("a ` x ` here", "a ``x`` here"),
],
ids=[
"simple",
"in-sentence",
"multiple",
"dotted-call",
"single-char",
"regex-star",
"regex-bracket",
"before-period",
"in-parens",
"start-of-line",
"end-of-line",
"trailing-space-stripped",
"surrounding-space-stripped",
],
)
def test_inline_code_conversion(myst, expected):
assert _convert(myst) == expected
[docs]
@pytest.mark.parametrize(
"text",
[
# Already-doubled backticks must not be quadrupled.
"Returns ``True`` if detected.",
# Double backticks with braces (converter output) must pass through.
"``{version}``",
# Triple backticks must not be mangled.
"```not inline```",
# Backtick span across newlines must not match.
"`start\nend`",
# An all-whitespace span has no reST literal to become: left as-is.
"a ` ` span",
],
ids=[
"double-backtick-passthrough",
"braces-in-double-backticks",
"triple-backticks",
"across-newlines",
"all-whitespace-content",
],
)
def test_inline_code_not_converted(text):
assert _convert(text) == text
[docs]
@pytest.mark.parametrize(
("myst", "expected"),
[
# The padding CommonMark lets a span start or end on a backtick goes.
("like `` - `apple` pie ``.", "like ``- `apple` pie``."),
("`` `pear` `` and `plum`", "```pear``` and ``plum``"),
# A reST literal holding backticks passes through.
("a ``fig `jam` here`` b", "a ``fig `jam` here`` b"),
],
ids=["padded", "on-backticks", "unpadded"],
)
def test_double_backtick_span_keeps_its_backticks(myst, expected):
"""A double-backtick span keeps its single backticks single.
Doubled, they would end the reST literal early: the conversion must parse
into whole literals, with no warning.
"""
rst = _convert(myst)
assert rst == expected
tree = publish_doctree(
rst,
settings_overrides={
"report_level": 1,
"halt_level": 5,
"warning_stream": False,
},
)
assert list(tree.findall(nodes.system_message)) == []
[docs]
@pytest.mark.parametrize(
("myst", "expected"),
[
("{func}`foo` returns `True`", ":func:`foo` returns ``True``"),
("a {data}`~X` or `None`", "a :data:`~X` or ``None``"),
("`some_var` is a {class}`str`", "``some_var`` is a :class:`str`"),
],
ids=["xref-then-code", "xref-or-code", "code-then-xref"],
)
def test_inline_code_no_cross_contamination_with_xref(myst, expected):
"""Closing backtick of a cross-ref must not pair with inline code."""
assert _convert(myst) == expected
[docs]
@pytest.mark.parametrize(
("myst", "expected"),
[
("a `Foo` and `_bar` end", "a ``Foo`` and ``_bar`` end"),
("a `Foo` then {func}`_bar` end", "a ``Foo`` then :func:`_bar` end"),
("a `_bar` then `Foo` end", "a ``_bar`` then ``Foo`` end"),
("see `apple`, `_pear`, `cherry`", "see ``apple``, ``_pear``, ``cherry``"),
# The first span's content ends in a non-word character, so its
# *closing* backtick clears the opening-boundary lookbehind too. Only
# the trailing-marker check keeps `_render()`/`_g` from being read as
# the reference marker of a link spanning the gap.
(
"captures `<stdout>` via `_render()` here",
"captures ``<stdout>`` via ``_render()`` here",
),
("call `f()` then `_g` now", "call ``f()`` then ``_g`` now"),
],
ids=[
"code-then-underscore-code",
"code-then-underscore-xref",
"underscore-code-first",
"underscore-code-middle",
"angle-bracket-close-then-underscore",
"paren-close-then-underscore",
],
)
def test_leading_underscore_span_not_a_hyperlink_ref(myst, expected):
"""A backtick span starting with `_` must not be read as a hyperlink ref.
The reST hyperlink-reference pattern ``` `label`_ ``` would otherwise let
the protection regex span the gap between two inline-code spans (consuming
the first span's closing backtick and the second span's leading underscore),
corrupting both. A hyperlink ref's opening backtick is anchored at a
non-word boundary, but that alone is not enough: when the first span's
content ends in a non-word character (`` `<stdout>` ``, `` `f()` ``), its
closing backtick also clears that lookbehind. The ref's trailing marker
(`_`/`__`) not being glued to an identifier is what rejects the false
match while still matching real ``` `label`_ ``` references.
"""
assert _convert(myst) == expected
[docs]
@pytest.mark.parametrize(
"text",
[
# A braced word abutting the literal's closing backticks must not be
# misread as a {role} with an empty target.
"``{levelname}:{message}`` and ``%(levelname)s``",
# Same shape inside a reST table row.
"``format`` ``{levelname}:{message}`` the default",
# A literal ending in a braced word, followed by more prose.
"set to ``{version}`` before the build",
],
ids=["brace-abuts-close", "table-row", "trailing-prose"],
)
def test_rst_literals_are_opaque(text):
"""Double-backtick reST literals pass through every step verbatim."""
assert _convert(text) == text
[docs]
@pytest.mark.parametrize(
"text",
["`see ``basket`` here`_", ":func:`peel ``kiwi`` now`"],
ids=["literal-in-hyperlink-reference", "literal-in-role"],
)
def test_nested_protected_spans_are_restored(text):
"""A reST literal inside a span protected later comes back whole.
Both are stashed behind placeholders: restored in the wrong order, the
inner one would leave its NUL-delimited placeholder in the output.
"""
assert _convert(text) == text
[docs]
@pytest.mark.parametrize(
("myst", "expected"),
[
# Role-shaped text inside a code span is an option name, not a role:
# it must not swallow the cross-reference that follows.
(
"`:tag-pattern:` defaults to {data}`DEFAULT_TAG_PATTERN`.",
"``:tag-pattern:`` defaults to :data:`DEFAULT_TAG_PATTERN`.",
),
("the `:manpage:` role", "the ``:manpage:`` role"),
# A real role right after prose still converts.
("see {data}`X` and `:path:`", "see :data:`X` and ``:path:``"),
# A span ending in role-shaped text: its closing backtick must not open
# a role target that runs on to the next span, a line below.
(
"`basket :keep-stems:` holds the fruit.\n`pear_count` grows.",
"``basket :keep-stems:`` holds the fruit.\n``pear_count`` grows.",
),
(
"the `basket :keep-stems:` of {func}`ripen`",
"the ``basket :keep-stems:`` of :func:`ripen`",
),
],
ids=[
"span-then-xref",
"span-alone",
"xref-then-span",
"span-ends-in-role-shape-then-span",
"span-ends-in-role-shape-then-xref",
],
)
def test_role_shaped_code_span_not_protected_as_role(myst, expected):
"""Role-shaped text inside a code span is code, wherever the span holds it."""
assert _convert(myst) == expected
# ---- Idempotent pass-through for reST docstrings ---------------------------
[docs]
@pytest.mark.parametrize(
"text",
[
":func:`~extra_platforms.is_linux`",
".. note::\n Some content.",
".. code-block:: python\n\n print(1)",
"`click here <https://example.com>`_",
"`click here <https://example.com>`__",
"Returns ``True`` if detected.",
":param name: The name.\n:return: A value.",
],
ids=[
"xref",
"admonition",
"code-block",
"named-hyperlink",
"anonymous-hyperlink",
"double-backtick",
"field-list",
],
)
def test_idempotent_rst_passthrough(text):
assert _convert(text) == text
[docs]
def test_idempotent_already_converted():
"""Running the converter twice produces the same result."""
myst = (
"{func}`foo` returns `True`.\n"
"\n"
":::{note}\nSee [docs](https://example.com).\n:::"
)
first = _convert(myst)
second = _convert(first)
assert first == second
[docs]
def test_idempotent_full_rst_docstring():
"""A complete reST docstring passes through unchanged."""
text = (
"Return :data:`True` if current architecture is"
" :data:`~extra_platforms.ARM`.\n"
"\n"
".. hint::\n"
" This is a fallback detection for generic ARM architecture."
" It will return\n"
" ``True`` for any ARM architecture not specifically covered"
" by the more precise\n"
" variants: :func:`~extra_platforms.is_aarch64`,"
" :func:`~extra_platforms.is_armv5tel`.\n"
)
assert _convert(text) == text
# ---- Mixed MyST + reST (generated docstrings) ------------------------------
# ---- Realistic docstring patterns ------------------------------------------
[docs]
def test_real_detection_function():
myst = (
"Return {data}`True` if current platform is"
" {data}`~extra_platforms.ANDROID`.\n"
"\n"
":::{seealso}\n"
"Source:\n"
"[kivy/utils.py](https://github.com/kivy/kivy/blob/master/kivy/utils.py)\n"
":::"
)
result = _convert(myst)
assert ":data:`True`" in result
assert ":data:`~extra_platforms.ANDROID`" in result
assert ".. seealso::" in result
assert (
" `kivy/utils.py <https://github.com/kivy/kivy/blob/master/kivy/utils.py>`_"
) in result
[docs]
def test_real_group_with_wikipedia_link():
myst = (
"All BSD platforms.\n"
"\n"
":::{note}\n"
"Are considered of this family ([according Wikipedia]"
"(https://en.wikipedia.org/wiki/Template:Unix)):\n"
"\n"
"- `386BSD` (`FreeBSD`, `NetBSD`)\n"
"- `Darwin` (`macOS`, `iOS`)\n"
":::"
)
result = _convert(myst)
assert ".. note::" in result
assert (
" Are considered of this family"
" (`according Wikipedia <https://en.wikipedia.org/wiki/Template:Unix>`_):"
) in result
assert " - ``386BSD`` (``FreeBSD``, ``NetBSD``)" in result
[docs]
def test_all_conversions_combined():
"""A docstring exercising every conversion step in one pass."""
myst = (
"Return {data}`True` if `path` exists.\n"
"\n"
"See [os.path](https://docs.python.org/3/library/os.path.html)\n"
"for details.\n"
"\n"
"```{warning}\n"
"Symlinks are not resolved.\n"
"```\n"
"\n"
":param path: The filesystem path.\n"
":return: `True` or `False`.\n"
)
result = _convert(myst)
# Xref converted.
assert ":data:`True`" in result
# Inline code doubled.
assert "``path``" in result
# Link converted.
assert "`os.path <https://docs.python.org/3/library/os.path.html>`_" in result
# Backtick fence converted.
assert ".. warning::" in result
assert " Symlinks are not resolved." in result
# Field list unchanged.
assert ":param path:" in result
# Inline code in field list doubled.
assert "``True`` or ``False``" in result
[docs]
def test_real_caution_with_code_block():
myst = (
"Return {data}`True` if architecture is"
" {data}`~extra_platforms.AARCH64`.\n"
"\n"
":::{caution}\n"
"`platform.machine()` returns different values depending on the OS:\n"
"\n"
"- Linux: `aarch64`\n"
"- macOS: `arm64`\n"
"- Windows: `ARM64`\n"
":::"
)
result = _convert(myst)
assert ":data:`True`" in result
assert ".. caution::" in result
assert (
" ``platform.machine()`` returns different values depending on the OS:"
) in result
assert " - Linux: ``aarch64``" in result
# ---- MyST inside field lists (:param:, :return:, :raises:) -------------------
[docs]
@pytest.mark.parametrize(
("myst", "expected"),
[
# Inline code inside :param:.
(
":param path: The `path` to the file.",
":param path: The ``path`` to the file.",
),
# Cross-reference inside :param:.
(
":param config: A {class}`Config` instance.",
":param config: A :class:`Config` instance.",
),
# Cross-reference with tilde inside :return:.
(
":return: A {class}`~orchard.config.Config` object.",
":return: A :class:`~orchard.config.Config` object.",
),
# Markdown link inside :param:.
(
":param url: See [the docs](https://example.com) for format.",
":param url: See `the docs <https://example.com>`_ for format.",
),
# Multiple inline codes inside :return:.
(
":return: `True` if valid, `False` otherwise.",
":return: ``True`` if valid, ``False`` otherwise.",
),
# Mixed xref and inline code inside :raises:.
(
":raises ValueError: If `name` is not a {class}`str`.",
":raises ValueError: If ``name`` is not a :class:`str`.",
),
# Inline code in multi-line :param: continuation.
(
(
":param items: A list of `str` values.\n"
" Each must be a valid {class}`Path`."
),
(
":param items: A list of ``str`` values.\n"
" Each must be a valid :class:`Path`."
),
),
],
ids=[
"inline-code-in-param",
"xref-in-param",
"tilde-xref-in-return",
"link-in-param",
"multiple-codes-in-return",
"mixed-in-raises",
"multiline-param-continuation",
],
)
def test_myst_inside_field_lists(myst, expected):
"""MyST inline constructs inside field list entries are converted."""
assert _convert(myst) == expected
[docs]
def test_full_docstring_with_myst_field_lists():
"""Realistic docstring with MyST in both prose and field list entries."""
myst = (
"Process a configuration file.\n"
"\n"
":::{note}\n"
"Only `TOML` files are supported.\n"
":::\n"
"\n"
":param path: Filesystem `path` to process.\n"
":param config: A {class}`~orchard.config.Config` instance.\n"
":return: `True` if the file was processed.\n"
":raises FileNotFoundError: If `path` does not exist.\n"
)
result = _convert(myst)
# Prose converted.
assert ".. note::" in result
assert " Only ``TOML`` files are supported." in result
# Field list markers unchanged.
assert ":param path:" in result
assert ":param config:" in result
assert ":return:" in result
assert ":raises FileNotFoundError:" in result
# Content inside field lists converted.
assert "Filesystem ``path`` to process." in result
assert ":class:`~orchard.config.Config`" in result
assert "``True`` if the file was processed." in result
assert "If ``path`` does not exist." in result
# ---- Unsupported constructs and known limitations ----------------------------
[docs]
@pytest.mark.parametrize(
("myst", "description"),
[
# MyST substitution references are not converted.
("The version is {{version}}.", "substitution-reference"),
# Definition list syntax is not converted.
("Term\n: Definition of the term.", "definition-list"),
# Heading syntax is not converted (and should not appear in docstrings).
("## Subheading", "heading-syntax"),
# Strikethrough is not converted.
("This is ~~deleted~~ text.", "strikethrough"),
# Task list checkboxes are not converted.
("- [x] Done\n- [ ] Not done", "task-list"),
],
ids=[
"substitution-reference",
"definition-list",
"heading-syntax",
"strikethrough",
"task-list",
],
)
def test_unsupported_constructs_pass_through(myst, description):
"""Unsupported MyST constructs pass through unchanged."""
assert _convert(myst) == myst, f"Expected {description} to pass through unchanged"
[docs]
def test_curly_braces_in_inline_code():
"""Inline code containing `{` and `}` characters.
A standalone `{word}` in single backticks is safe: it gets doubled to
``{word}`` because the xref regex needs the `{word}`target`` pattern
(two backtick-delimited spans). Double-backtick input also passes through.
The dangerous case is `{word}`text`` where the braces plus adjacent
backtick-delimited span mimics the xref pattern. The `convert-to-myst`
command intentionally keeps these as double backticks to avoid this.
"""
# Double backticks with braces: safe, passes through.
assert _convert("``{version}``") == "``{version}``"
# Single backtick with braces but no adjacent backtick span: safe.
assert _convert("`{version}`") == "``{version}``"
# Pattern that mimics a cross-reference: {word}`target`.
# This IS misinterpreted as a xref.
result = _convert("`{foo}`bar`")
assert result != "``{foo}`bar``", (
"Known limitation: `{foo}`bar` is misinterpreted as a cross-reference"
)
[docs]
def test_plain_code_fence_empty_body():
"""Plain code fence with an empty body."""
myst = "```python\n```"
result = _convert(myst)
assert ".. code-block:: python" in result
[docs]
@pytest.mark.parametrize(
("myst", "expected"),
[
# A longer directive fence holds a code fence of its own character.
(
"````{note}\nRun it:\n\n```python\nfruit = 1\n```\n````",
(
".. note::\n"
"\n"
" Run it:\n"
"\n"
" .. code-block:: python\n"
"\n"
" fruit = 1\n"
"\n"
),
),
# A longer code fence shows a directive fence as an example, verbatim.
(
"````markdown\n```{tip}\nPeel `kiwi`.\n```\n````",
".. code-block:: markdown\n\n ```{tip}\n Peel `kiwi`.\n ```\n\n",
),
],
ids=["directive-holds-code", "code-holds-directive"],
)
def test_longer_fence_holds_a_shorter_one(myst, expected):
"""A fence closes on a run of its own length, as in any MyST document."""
assert _convert(myst) == expected
[docs]
def test_blank_line_opening_a_body_goes_with_the_header():
"""MyST drops the blank line opening a directive body, options or not."""
assert _convert("```{note}\n\nRipe.\n```") == ".. note::\n\n Ripe.\n\n"
[docs]
def test_nested_same_type_fences_not_supported():
"""Nested fences of the same length are not reliably converted.
A colon fence inside a colon fence, or a backtick fence inside a backtick
fence, may not parse correctly: a fence closes on the first bare run of its
character at least as long as its opener.
"""
myst = ":::{note}\nOuter content.\n\n:::{warning}\nInner content.\n:::\n\n:::"
result = _convert(myst)
# The outer note should be converted.
assert ".. note::" in result
# The inner warning may or may not convert correctly depending on regex
# greediness. We just verify no crash.
assert "Inner content." in result
[docs]
def test_directive_inside_field_list_not_supported():
"""Fenced directives inside field list entries do not render correctly.
Field list entries in Sphinx are paragraph-level constructs.
Block-level MyST content (admonitions, code blocks) inside them is
converted by the regex but won't render correctly in Sphinx.
"""
myst = ":param path: The path.\n\n :::{note}\n Extra info.\n :::\n"
result = _convert(myst)
# The directive syntax is converted (the regex does not know about context).
assert ".. note::" in result
# But this won't render correctly as a field list continuation in Sphinx.
assert ":param path:" in result
[docs]
def test_link_with_parentheses_in_url():
"""A URL holding a balanced pair of parentheses is kept whole."""
myst = "[wiki](https://en.wikipedia.org/wiki/Foo_(bar))"
assert _convert(myst) == "`wiki <https://en.wikipedia.org/wiki/Foo_(bar)>`_"
[docs]
def test_consecutive_field_list_entries_all_converted():
"""Multiple consecutive field list entries each get their content converted."""
myst = (
":param a: First `param`.\n"
":param b: Second {class}`Config`.\n"
":param c: Third [link](https://example.com).\n"
":return: A `bool`.\n"
)
result = _convert(myst)
assert "First ``param``." in result
assert "Second :class:`Config`." in result
assert "Third `link <https://example.com>`_." in result
assert "A ``bool``." in result
[docs]
def test_empty_field_list_content():
"""Field list entries with empty or whitespace-only content."""
assert _convert(":param x:") == ":param x:"
assert _convert(":return:") == ":return:"
assert _convert(":param x: ") == ":param x: "
[docs]
def test_xref_adjacent_to_field_list_colon():
"""Cross-reference immediately after the field list colon."""
myst = ":param x: {class}`Foo` instance."
expected = ":param x: :class:`Foo` instance."
assert _convert(myst) == expected
[docs]
def test_multiple_xrefs_in_single_field_entry():
"""Multiple cross-references in one field list entry."""
myst = ":return: A {class}`dict` mapping {class}`str` to {class}`int`."
expected = ":return: A :class:`dict` mapping :class:`str` to :class:`int`."
assert _convert(myst) == expected
[docs]
def test_link_with_backtick_label_in_field_list():
"""Markdown link with backtick-wrapped label inside a field list entry."""
myst = ":param url: See [`requests`](https://example.com) docs."
expected = ":param url: See `requests <https://example.com>`_ docs."
assert _convert(myst) == expected
# ---- Extension setup ---------------------------------------------------------
[docs]
def test_setup_rejects_wrong_extension_order():
"""Extension must error if sphinx_autodoc_typehints loaded first."""
app = MagicMock()
# Simulate sphinx_autodoc_typehints already registered.
app.extensions = {_TYPEHINTS_EXT: SimpleNamespace()}
with pytest.raises(ExtensionError, match="must be listed before"):
setup(app)
[docs]
def test_setup_accepts_correct_order():
"""Extension loads normally when sphinx_autodoc_typehints is absent."""
app = MagicMock()
app.extensions = {}
result = setup(app)
assert result["parallel_read_safe"] is True
app.connect.assert_called_once()
assert app.connect.call_args[0][0] == "autodoc-process-docstring"