Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ NOTE: please use them in this order.

- Update configs for dev tooling ([#96](https://github.com/rstcheck/rstcheck-core/pull/96))
- Bump default python version to 3.12 ([#96](https://github.com/rstcheck/rstcheck-core/pull/96))
- Try to generate a line number for code blocks that don't have one

### Bugfixes

- Fix attribute errors with code-blocks and include when using sphinx ([#3](https://github.com/rstcheck/rstcheck-core/issues/3))

## [v1.2.1 (2024-03-23)](https://github.com/rstcheck/rstcheck-core/releases/v1.2.1)

Expand Down
45 changes: 22 additions & 23 deletions src/rstcheck_core/_sphinx.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,9 @@

from __future__ import annotations

import contextlib
import logging
import pathlib
import tempfile
import typing as t

from . import _docutils, _extras

Expand All @@ -22,38 +20,19 @@

logger = logging.getLogger(__name__)


def create_dummy_sphinx_app() -> sphinx.application.Sphinx:
"""Create a dummy sphinx instance with temp dirs."""
if _extras.SPHINX_INSTALLED:
logger.debug("Create dummy sphinx application.")
with tempfile.TemporaryDirectory() as temp_dir:
outdir = pathlib.Path(temp_dir) / "_build"
return sphinx.application.Sphinx(
sphinx_app = sphinx.application.Sphinx(
srcdir=temp_dir,
confdir=None,
outdir=str(outdir),
doctreedir=str(outdir),
buildername="dummy",
# NOTE: https://github.com/sphinx-doc/sphinx/issues/10483
status=None,
)


@contextlib.contextmanager
def load_sphinx_if_available() -> t.Generator[sphinx.application.Sphinx | None, None, None]:
"""Contextmanager to register Sphinx directives and roles if sphinx is available."""
if _extras.SPHINX_INSTALLED:
create_dummy_sphinx_app()
# NOTE: Hack to prevent sphinx warnings for overwriting registered nodes; see #113
sphinx.application.builtin_extensions = [
e
for e in sphinx.application.builtin_extensions
if e != "sphinx.addnodes" # type: ignore[assignment]
]

yield None


def get_sphinx_directives_and_roles() -> tuple[list[str], list[str]]:
"""Return Sphinx directives and roles loaded from sphinx.

Expand Down Expand Up @@ -86,6 +65,26 @@ def get_sphinx_directives_and_roles() -> tuple[list[str], list[str]]:
sphinx.util.docutils.roles._roles # type: ignore[attr-defined] # noqa: SLF001
)

# load the internal docroles for definitions like "file"
sphinx_roles += list(sphinx.roles.specific_docroles) + list(sphinx.roles.generic_docroles)

# manually load the "other" directives since they don't have a nice dictionary we can read
sphinx_directives += [
"toctree",
"sectionauthor",
"moduleauthor",
"codeauthor",
"seealso",
"tabularcolumns",
"centered",
"acks",
"hlist",
"only",
"include",
"cssclass",
"rst-class",
]

return (sphinx_directives, sphinx_roles)


Expand Down
122 changes: 74 additions & 48 deletions src/rstcheck_core/checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,16 +70,15 @@ def check_file(

_docutils.clean_docutils_directives_and_roles_cache()

with _sphinx.load_sphinx_if_available():
return list(
check_source(
source,
source_file=source_file,
ignores=ignore_dict,
report_level=run_config.report_level or config.DEFAULT_REPORT_LEVEL,
warn_unknown_settings=run_config.warn_unknown_settings or False,
)
return list(
check_source(
source,
source_file=source_file,
ignores=ignore_dict,
report_level=run_config.report_level or config.DEFAULT_REPORT_LEVEL,
warn_unknown_settings=run_config.warn_unknown_settings or False,
)
)


def _load_run_config(
Expand Down Expand Up @@ -231,30 +230,16 @@ def check_source(
# "self.state.document.settings.env". Ignore this for now until we
# figure out a better approach.
# https://github.com/rstcheck/rstcheck-core/issues/3
try:
docutils.core.publish_string(
source,
writer=writer,
source_path=str(source_origin),
settings_overrides={
"halt_level": 5,
"report_level": report_level.value,
"warning_stream": string_io,
},
)
except AttributeError:
if not _extras.SPHINX_INSTALLED:
raise
logger.warning(
"An `AttributeError` error occured. This is most probably due to a code block "
"directive (code/code-block/sourcecode) without a specified language. "
"This may result in a false negative for source: '%s'. "
"The reason can also be another directive. "
"For more information see the FAQ (https://rstcheck-core.rtfd.io/en/latest/faq) "
"or the corresponding github issue: "
"https://github.com/rstcheck/rstcheck-core/issues/3.",
source_origin,
)
docutils.core.publish_string(
source,
writer=writer,
source_path=str(source_origin),
settings_overrides={
"halt_level": 5,
"report_level": report_level.value,
"warning_stream": string_io,
},
)

yield from _run_code_checker_and_filter_errors(writer.checkers, ignores["messages"])

Expand Down Expand Up @@ -308,6 +293,20 @@ def _parse_and_filter_rst_errors(
)


def _get_source_path(node: docutils.nodes.Element) -> str | None:
"""Iterate through all parent nodes until we get a valid source path.

:param node: A docutils node
:return: String with rst source path
"""
current_node = node
source = current_node.source
while current_node and not source:
current_node = current_node.parent
source = current_node.source
return source


class _CheckWriter(docutils.writers.Writer): # type: ignore[misc]
"""Runs CheckTranslator on code blocks."""

Expand Down Expand Up @@ -411,6 +410,34 @@ def visit_doctest_block(self, node: docutils.nodes.Element) -> None:
is_code_node=False,
)

def _get_code_block_directive_line(self, node: docutils.nodes.Element) -> int | None:
"""Find line of code block directive.

:param node: The code block node
:return: Line of code block directive or :py:obj:`None`
"""
line_number = node.line
if line_number is None:
try:
line_number = _generate_directive_line(node)
except IndexError:
return None

if line_number is None:
return None

node_source = _get_source_path(node)
if node_source and node_source != str(self.source_origin):
lines = _get_source(pathlib.Path(node_source)).splitlines()
else:
lines = self.source.splitlines()

for line_no in range(line_number, 1, -1):
if CODE_BLOCK_RE.match(lines[line_no - 2].strip()) is not None:
return line_no - 1

return None

def visit_literal_block(self, node: docutils.nodes.Element) -> None:
"""Add check for syntax of code block.

Expand All @@ -428,7 +455,7 @@ def visit_literal_block(self, node: docutils.nodes.Element) -> None:
return
language = classes[-1]

directive_line = _get_code_block_directive_line(node, self.source)
directive_line = self._get_code_block_directive_line(node)
if directive_line is None:
logger.warning(
"Could not find line for literal block directive. "
Expand Down Expand Up @@ -564,24 +591,23 @@ def _beginning_of_code_block(
CODE_BLOCK_RE = re.compile(r"\.\. code::|\.\. code-block::|\.\. sourcecode::")


def _get_code_block_directive_line(node: docutils.nodes.Element, full_contents: str) -> int | None:
"""Find line of code block directive.
def _generate_directive_line(node: docutils.nodes.Element) -> int | None:
"""Generate a line number based on the parent rawsource.

:param node: The code block node
:param full_contents: The node's contents
:return: Line of code block directive or :py:obj:`None`
"""
line_number = node.line
if line_number is None:
return None

if _extras.SPHINX_INSTALLED:
return line_number

lines = full_contents.splitlines()
for line_no in range(line_number, 1, -1):
if CODE_BLOCK_RE.match(lines[line_no - 2].strip()) is not None:
return line_no - 1
parent = node.parent
if parent:
child_index = parent.index(node)
child_grouped_lines = parent.rawsource.split(parent.child_text_separator)
preceeding_rawsoruce = parent.child_text_separator.join(
child_grouped_lines[: child_index + 2]
)
parent_line = parent.line if parent.line else _generate_directive_line(parent)
if parent_line:
node.line = len(preceeding_rawsoruce.splitlines()) + parent_line
return node.line

return None

Expand Down
13 changes: 6 additions & 7 deletions src/rstcheck_core/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import sys
import typing as t

from . import _sphinx, checker, config, types
from . import checker, config, types

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -183,11 +183,10 @@ def _run_checks_sync(self) -> list[list[types.LintError]]:
:return: List of lists of errors found per file
"""
logger.debug("Runnning checks synchronically.")
with _sphinx.load_sphinx_if_available():
return [
checker.check_file(file, self.config, self.overwrite_config)
for file in self._files_to_check
]
return [
checker.check_file(file, self.config, self.overwrite_config)
for file in self._files_to_check
]

def _run_checks_parallel(self) -> list[list[types.LintError]]:
"""Check all files from the file list in parallel and return the errors.
Expand All @@ -198,7 +197,7 @@ def _run_checks_parallel(self) -> list[list[types.LintError]]:
"Runnning checks in parallel with pool size of %s.",
self._pool_size,
)
with _sphinx.load_sphinx_if_available(), multiprocessing.Pool(self._pool_size) as pool:
with multiprocessing.Pool(self._pool_size) as pool:
return pool.starmap(
checker.check_file,
[(file, self.config, self.overwrite_config) for file in self._files_to_check],
Expand Down
6 changes: 6 additions & 0 deletions testing/examples/good/_include.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@

1. A list to indent.

.. code-block:: python

print()
2 changes: 2 additions & 0 deletions testing/examples/good/code_blocks.rst
Original file line number Diff line number Diff line change
Expand Up @@ -159,3 +159,5 @@ Run more tests for checking performance.

# ¬∆˚ß∂ƒß∂ƒ˚¬∆
print(1)

.. include:: _include.rst
38 changes: 0 additions & 38 deletions tests/_sphinx_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,48 +4,10 @@

import typing as t

import docutils.parsers.rst.directives as docutils_directives
import docutils.parsers.rst.roles as docutils_roles
import pytest

from rstcheck_core import _extras, _sphinx

if _extras.SPHINX_INSTALLED:
import sphinx.application


@pytest.mark.skipif(not _extras.SPHINX_INSTALLED, reason="Depends on sphinx extra.")
def test_dummy_app_creator() -> None:
"""Test creation of dummy sphinx app."""
result = _sphinx.create_dummy_sphinx_app()

assert isinstance(result, sphinx.application.Sphinx)


class TestContextManager:
"""Test ``load_sphinx_if_available`` context manager."""

@staticmethod
@pytest.mark.skipif(_extras.SPHINX_INSTALLED, reason="Test without sphinx extra.")
@pytest.mark.usefixtures("patch_docutils_directives_and_roles_dict")
def test_yield_nothing_with_sphinx_missing() -> None:
"""Test for ``None`` yield and no action when sphinx is missing."""
with _sphinx.load_sphinx_if_available() as ctx_manager:
assert ctx_manager is None
assert not docutils_directives._directives # type: ignore[attr-defined]
assert not docutils_roles._roles # type: ignore[attr-defined]

@staticmethod
@pytest.mark.skipif(not _extras.SPHINX_INSTALLED, reason="Depends on sphinx extra.")
@pytest.mark.usefixtures("patch_docutils_directives_and_roles_dict")
def test_yield_nothing_with_sphinx_installed() -> None:
"""Test for ``None`` yield but action when sphinx is installed."""
with _sphinx.load_sphinx_if_available() as ctx_manager:
assert ctx_manager is None
assert docutils_directives._directives # type: ignore[attr-defined]
assert docutils_roles._roles # type: ignore[attr-defined]
assert "sphinx.addnodes" not in sphinx.application.builtin_extensions


class TestSphinxDirectiveAndRoleGetter:
"""Test ``get_sphinx_directives_and_roles`` function."""
Expand Down
Loading