Source code for tests.sphinx.test_sphinx

# 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.
"""Fixtures and utilities for Sphinx testing."""

from __future__ import annotations

import ast
import subprocess
import sys
from pathlib import Path
from textwrap import dedent

import pytest

# Import click_extra.sphinx with myst_parser blocked, to check the module does not
# hard-depend on it. Runs in a subprocess: a meta-path blocker installed in-process
# would leak into every later test of the session.
NO_MYST_PARSER_SCRIPT = dedent("""
    import sys
    from importlib.abc import MetaPathFinder

    class MystParserBlocker(MetaPathFinder):
        def find_spec(self, fullname, path=None, target=None):
            if fullname.partition(".")[0] == "myst_parser":
                raise ImportError("myst-parser is not installed")
            return None

    sys.meta_path.insert(0, MystParserBlocker())

    import click_extra.sphinx

    assert click_extra.sphinx.myst_parser is None
""")


[docs] @pytest.mark.once def test_import_without_myst_parser(): """The extension imports for a reST-only project, which installs no myst-parser. The `sphinx` extra does not declare `myst-parser` and Sphinx does not depend on it, so a hard import of it in `click_extra.sphinx` breaks such a project outright. """ result = subprocess.run( (sys.executable, "-c", NO_MYST_PARSER_SCRIPT), capture_output=True, text=True, encoding="utf-8", check=False, ) assert result.returncode == 0, result.stderr
[docs] def imports_sphinx_package(node: ast.Import | ast.ImportFrom) -> bool: """Whether an import statement reaches `click_extra.sphinx` or a module of it.""" if isinstance(node, ast.ImportFrom): names = [f"{node.module}.{alias.name}" for alias in node.names] else: names = [alias.name for alias in node.names] return any(name.split(".")[:2] == ["click_extra", "sphinx"] for name in names)
[docs] def is_sphinx_guard(call: ast.Call) -> bool: """Whether the call is `pytest.importorskip("sphinx", ...)`.""" return ( isinstance(call.func, ast.Attribute) and call.func.attr == "importorskip" and bool(call.args) and isinstance(call.args[0], ast.Constant) and call.args[0].value == "sphinx" )
[docs] def unguarded_sphinx_imports(tree: ast.Module) -> list[int]: """Lines importing `click_extra.sphinx` that no `importorskip("sphinx")` precedes. A guard covers the rest of the function it is called in, or the whole module when called at its top level. """ guards: dict[ast.AST | None, int] = {} imports: list[tuple[ast.AST | None, int]] = [] def visit(node: ast.AST, scope: ast.AST | None) -> None: for child in ast.iter_child_nodes(node): if isinstance(child, ast.Call) and is_sphinx_guard(child): guards.setdefault(scope, child.lineno) elif isinstance(child, (ast.Import, ast.ImportFrom)) and ( imports_sphinx_package(child) ): imports.append((scope, child.lineno)) function = isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)) visit(child, child if function else scope) visit(tree, None) return [ line for scope, line in imports if not any(guards.get(owner, line) < line for owner in (None, scope)) ]
[docs] @pytest.mark.once def test_imports_outside_this_tree_skip_without_sphinx(): """Outside `tests/sphinx/`, a test skips itself before importing the extension. The `collect_ignore_glob` of this tree skips it on a builder shipping no Sphinx, and covers no test outside it. Such a test has to call `pytest.importorskip("sphinx")` before it imports `click_extra.sphinx`, or it fails on that builder instead of skipping. """ tests_root = Path(__file__).parent.parent offenders = [ f"{path.relative_to(tests_root)}:{line}" for path in sorted(tests_root.rglob("*.py")) if tests_root / "sphinx" not in path.parents for line in unguarded_sphinx_imports( ast.parse(path.read_text(encoding="utf-8")) ) ] assert not offenders, f"unguarded click_extra.sphinx imports: {offenders}"
[docs] def test_sphinx_extension_setup(sphinx_app): """Test that the Sphinx extension is properly loaded.""" # Check that the domain is registered. assert "click" in sphinx_app.registry.domains assert "click" in sphinx_app.env.domains # Check that our directives are registered. assert "source" in sphinx_app.env.get_domain("click").directives assert "run" in sphinx_app.env.get_domain("click").directives assert "tree" in sphinx_app.env.get_domain("click").directives
[docs] def test_resolve_any_xref(sphinx_app): """Test that ``resolve_any_xref`` is implemented and returns an empty list. .. seealso:: https://github.com/kdeldycke/click-extra/issues/1502 """ domain = sphinx_app.env.get_domain("click") result = domain.resolve_any_xref( env=sphinx_app.env, fromdocname="index", builder=sphinx_app.builder, target="anything", node=None, contnode=None, ) assert result == []