Source code for tests.test_myst

# 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 reST-to-MyST converters and the `convert-to-myst` command."""

from __future__ import annotations

from textwrap import dedent

import pytest
from click.testing import CliRunner

from click_extra.cli import convert_to_myst_cmd
from click_extra.markdown.myst import (
    convert_apidoc_rst_to_myst,
    convert_comment_blocks,
    convert_directives,
    convert_directory,
    convert_file,
    convert_inline_code,
    convert_links,
    convert_rst_files_in_directory,
    convert_source,
    convert_xrefs,
)

# ---- Cross-references: :role:`target` -> {role}`target` --------------------


[docs] @pytest.mark.parametrize( ("rst", "expected"), [ (":func:`foo`", "{func}`foo`"), (":data:`~extra_platforms.MACOS`", "{data}`~extra_platforms.MACOS`"), ( "see :class:`str` and :meth:`str.split`", "see {class}`str` and {meth}`str.split`", ), # F-string interpolation targets are excluded. ('f":func:`~{self.id}`"', 'f":func:`~{self.id}`"'), # A backtick right before the role means the role-shaped text sits # inside a literal span, not a cross-reference. ("(`:param:`, `:return:`)", "(`:param:`, `:return:`)"), # Domain-qualified roles stay reST: converting the inner role alone # would leave a mangled hybrid. (":py:class:`Basket`", ":py:class:`Basket`"), # Already-MyST input is untouched (idempotence). ("{func}`foo`", "{func}`foo`"), ], ids=[ "simple", "tilde", "multiple", "fstring-excluded", "backtick-preceded-excluded", "domain-qualified-excluded", "idempotent", ], ) def test_convert_xrefs(rst, expected): assert convert_xrefs(rst) == expected
# ---- Named links: `text <url>`_ -> [text](url) ----------------------------- # ---- Inline code: ``code`` -> `code` ---------------------------------------
[docs] @pytest.mark.parametrize( ("rst", "expected"), [ ("``True``", "`True`"), ("mix of ``a`` and ``b``", "mix of `a` and `b`"), # Braces are excluded so f-string interpolations survive. ("``{self.id}``", "``{self.id}``"), # A brace-bearing literal is consumed whole: its closing backticks # must not pair with the next literal across the gap. ( "``size`` ``{width}: {height}`` ``%(width)s``", "`size` ``{width}: {height}`` `%(width)s`", ), ("``{`` ``%``", "``{`` `%`"), # Already-MyST input is untouched (idempotence). ("`True`", "`True`"), ], ids=[ "simple", "multiple", "brace-excluded", "brace-gap-not-paired", "bare-brace-gap", "idempotent", ], ) def test_convert_inline_code(rst, expected): assert convert_inline_code(rst) == expected
# ---- Directives: .. name:: -> ```{name} fences -----------------------------
[docs] @pytest.mark.parametrize( ("rst", "expected_fragments"), [ ( ".. note::\n\n Cloudy today.", ["```{note}", "Cloudy today.", "```"], ), ( ".. warning:: Storm ahead\n\n Stay inside.", ["```{warning} Storm ahead", "Stay inside."], ), # Indented directive keeps its indentation on the fences. ( " .. hint::\n\n Try oranges.", [" ```{hint}", " Try oranges.", " ```"], ), ], ids=["bare", "with-argument", "indented"], ) def test_convert_directives(rst, expected_fragments): result = convert_directives(rst) for fragment in expected_fragments: assert fragment in result
[docs] def test_convert_directives_keeps_fenced_body(): """A directive body already holding a fence cannot nest: it stays reST.""" rst = dedent("""\ .. code-block:: markdown ```{python:render} print("kiwi") ``` """) assert convert_directives(rst) == rst
[docs] def test_convert_directives_skips_fence_interiors(): """A reST directive inside a fence is prior nested-conversion output: keep it.""" myst = dedent("""\ ```{tip} Ripen at room temperature. .. note:: Bananas brown faster in the fridge. ``` """) assert convert_directives(myst) == myst
[docs] def test_convert_source_nested_directives_idempotent(): """Nested directives convert the outer level only, and re-runs are no-ops.""" source = dedent('''\ def store(fruit): """Store a fruit. .. tip:: Check the skin first. .. note:: Thick skins keep longer. """ ''') converted = convert_source(source) assert "```{tip}" in converted # The inner directive stays reST inside the fence, and stays stable. assert ".. note::" in converted assert "```{note}" not in converted assert convert_source(converted) == converted
[docs] def test_convert_source_fenced_samples_untouched(): """Fence bodies are sample code: reST markup inside them is data.""" source = dedent('''\ def brew(tea): """Brew a cup. ```markdown See :func:`steep` and ``leaves``. ``` """ ''') assert convert_source(source) == source
[docs] def test_convert_comment_blocks(): """`#:` blocks are unwrapped, converted, and re-wrapped.""" rst = "#: .. note::\n#:\n#: Ripe bananas only." result = convert_comment_blocks(rst) assert "#: ```{note}" in result assert "#: Ripe bananas only." in result assert "#: ```" in result
# ---- Scoping: only docstrings and comments are converted --------------------
[docs] def test_convert_source_scopes_to_docstrings_and_comments(): source = dedent('''\ """Sort ``apples`` before :func:`peel`.""" # Ripeness is checked by :func:`peel` on ``green`` fruits. RIPE_RE = re.compile(r"``(\\w+)``") MESSAGE = "Use ``ripe`` fruit, see :func:`peel`." count = 3 # Twice the ``basket`` size. ''') converted = convert_source(source) assert '"""Sort `apples` before {func}`peel`."""' in converted assert "# Ripeness is checked by {func}`peel` on `green` fruits." in converted assert "# Twice the `basket` size." in converted # String literals and regex patterns are runtime code: byte-for-byte. assert 'RIPE_RE = re.compile(r"``(\\w+)``")' in converted assert 'MESSAGE = "Use ``ripe`` fruit, see :func:`peel`."' in converted
[docs] def test_convert_source_attribute_docstrings(): source = dedent('''\ FRUIT = "banana" """Default fruit, in its ``ripe`` state, see :data:`FRUIT`.""" ''') converted = convert_source(source) assert 'FRUIT = "banana"' in converted assert '"""Default fruit, in its `ripe` state, see {data}`FRUIT`."""' in converted
[docs] def test_convert_source_sharp_colon_comment_blocks(): source = dedent("""\ #: .. note:: #: #: Ripe ``bananas`` only. BASKET = ["banana"] """) converted = convert_source(source) assert "#: ```{note}" in converted assert "#: Ripe `bananas` only." in converted assert '\nBASKET = ["banana"]' in converted
# ---- File and directory pipeline ------------------------------------------- SAMPLE_RST_MODULE = dedent('''\ """Pick the ripest fruit. See :func:`ripeness` and the `market guide <https://example.com/fruits>`_. .. note:: Prefer ``fresh`` produce. """ ''')
[docs] def test_convert_file_roundtrip_and_idempotence(tmp_path): module = tmp_path / "basket.py" module.write_text(SAMPLE_RST_MODULE, encoding="UTF-8") assert convert_file(module) is True converted = module.read_text(encoding="UTF-8") assert "{func}`ripeness`" in converted assert "[market guide](https://example.com/fruits)" in converted assert "```{note}" in converted assert "`fresh`" in converted # A second pass finds nothing to change. assert convert_file(module) is False assert module.read_text(encoding="UTF-8") == converted
[docs] def test_convert_directory_reports_only_changed(tmp_path): (tmp_path / "rest.py").write_text(SAMPLE_RST_MODULE, encoding="UTF-8") (tmp_path / "myst.py").write_text('"""Already `MyST`."""\n', encoding="UTF-8") changed = convert_directory(tmp_path) assert [path.name for path in changed] == ["rest.py"]
# ---- CLI command ------------------------------------------------------------ def _write_pyproject(tmp_path, scripts: dict[str, str]) -> None: lines = ["[project]", 'name = "orchard"', 'version = "1.0.0"'] if scripts: lines.append("[project.scripts]") lines.extend(f'{name} = "{target}"' for name, target in scripts.items()) (tmp_path / "pyproject.toml").write_text("\n".join(lines) + "\n", encoding="UTF-8")
[docs] def test_convert_to_myst_cli_explicit_directory(tmp_path): (tmp_path / "basket.py").write_text(SAMPLE_RST_MODULE, encoding="UTF-8") result = CliRunner().invoke(convert_to_myst_cmd, [str(tmp_path)]) assert result.exit_code == 0 assert "Converted:" in result.output assert "1 file(s) converted." in result.output
[docs] def test_convert_to_myst_cli_autodetect(tmp_path, monkeypatch): _write_pyproject(tmp_path, {"orchard": "orchard.cli:main"}) package = tmp_path / "orchard" package.mkdir() (package / "basket.py").write_text(SAMPLE_RST_MODULE, encoding="UTF-8") monkeypatch.chdir(tmp_path) result = CliRunner().invoke(convert_to_myst_cmd, []) assert result.exit_code == 0 assert "1 file(s) converted." in result.output
[docs] def test_convert_to_myst_cli_autodetect_failure(tmp_path, monkeypatch): _write_pyproject(tmp_path, {}) monkeypatch.chdir(tmp_path) result = CliRunner().invoke(convert_to_myst_cmd, []) assert result.exit_code != 0 assert "Cannot auto-detect" in result.output
[docs] def test_convert_to_myst_cli_not_a_directory(tmp_path): target = tmp_path / "missing" result = CliRunner().invoke(convert_to_myst_cmd, [str(target)]) assert result.exit_code != 0 assert "Not a directory" in result.output
# ---- sphinx-apidoc pages --------------------------------------------------- APIDOC_STUB = dedent("""\ orchard package =============== Submodules ---------- orchard.basket module --------------------- .. automodule:: orchard.basket :members: :undoc-members: :show-inheritance: """)
[docs] @pytest.mark.parametrize( ("rst", "expected"), [ # Underline characters map to heading levels. ("Title\n=====", "# Title"), ("Section\n-------", "## Section"), ("Sub\n~~~", "### Sub"), # Backslash escapes are stripped and identifiers wrapped in backticks. ( "fruit\\_stand.apples module\n--------------------------", "## `fruit_stand.apples` module", ), ], ids=["h1", "h2", "h3", "escaped-identifier"], ) def test_heading_conversion(rst, expected): assert expected in convert_apidoc_rst_to_myst(rst)
[docs] def test_automodule_block_wrapped_in_eval_rst(): result = convert_apidoc_rst_to_myst(APIDOC_STUB) assert "# `orchard` package" in result assert "## Submodules" in result assert "## `orchard.basket` module" in result # The directive block is preserved verbatim inside an eval-rst fence. assert "```{eval-rst}" in result assert ".. automodule:: orchard.basket" in result assert " :members:" in result assert result.rstrip().endswith("```")
[docs] def test_convert_rst_files_creates_md_and_deletes_rst(tmp_path): rst_path = tmp_path / "orchard.rst" rst_path.write_text(APIDOC_STUB, encoding="UTF-8") converted = convert_rst_files_in_directory(tmp_path) assert converted == [tmp_path / "orchard.md"] assert not rst_path.exists() assert "```{eval-rst}" in (tmp_path / "orchard.md").read_text(encoding="UTF-8")
[docs] def test_convert_rst_files_skips_non_apidoc(tmp_path): rst_path = tmp_path / "prose.rst" rst_path.write_text("Just prose\n==========\n", encoding="UTF-8") assert convert_rst_files_in_directory(tmp_path) == [] # Non-apidoc RST files are left in place. assert rst_path.exists()
[docs] def test_convert_rst_files_existing_md_wins(tmp_path): rst_path = tmp_path / "orchard.rst" rst_path.write_text(APIDOC_STUB, encoding="UTF-8") md_path = tmp_path / "orchard.md" md_path.write_text("# Hand-written page\n", encoding="UTF-8") assert convert_rst_files_in_directory(tmp_path) == [] # The stale stub is deleted; the hand-written markdown is untouched. assert not rst_path.exists() assert md_path.read_text(encoding="UTF-8") == "# Hand-written page\n"