diff --git a/CHANGELOG.md b/CHANGELOG.md index 64221086..f5ccb6b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,11 @@ NOTE: please use them in this order. - Bump default python version to 3.12 ([#96](https://github.com/rstcheck/rstcheck-core/pull/96)) - Dropped support for python 3.8 - Added python 3.13 to tox config as preparation for adding the version to the test pool. ([#109](https://github.com/rstcheck/rstcheck/issues/109)) +- 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) diff --git a/src/rstcheck_core/_sphinx.py b/src/rstcheck_core/_sphinx.py index db69962e..e7519fb3 100644 --- a/src/rstcheck_core/_sphinx.py +++ b/src/rstcheck_core/_sphinx.py @@ -2,11 +2,9 @@ from __future__ import annotations -import contextlib import logging import pathlib import tempfile -import typing as t from . import _docutils, _extras @@ -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. @@ -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) diff --git a/src/rstcheck_core/checker.py b/src/rstcheck_core/checker.py index 7e520de0..c16a6db6 100644 --- a/src/rstcheck_core/checker.py +++ b/src/rstcheck_core/checker.py @@ -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( @@ -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"]) @@ -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[type-arg] """Runs CheckTranslator on code blocks.""" @@ -415,6 +414,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. @@ -432,7 +459,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. " @@ -568,24 +595,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 diff --git a/src/rstcheck_core/runner.py b/src/rstcheck_core/runner.py index 32645391..f8ed9c17 100644 --- a/src/rstcheck_core/runner.py +++ b/src/rstcheck_core/runner.py @@ -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__) @@ -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. @@ -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], diff --git a/testing/examples/good/_include.rst b/testing/examples/good/_include.rst new file mode 100644 index 00000000..f57b2edc --- /dev/null +++ b/testing/examples/good/_include.rst @@ -0,0 +1,6 @@ + +1. A list to indent. + + .. code-block:: python + + print() diff --git a/testing/examples/good/code_blocks.rst b/testing/examples/good/code_blocks.rst index 88bfce1d..8bd48b83 100644 --- a/testing/examples/good/code_blocks.rst +++ b/testing/examples/good/code_blocks.rst @@ -159,3 +159,5 @@ Run more tests for checking performance. # ¬∆˚ß∂ƒß∂ƒ˚¬∆ print(1) + +.. include:: _include.rst diff --git a/tests/_sphinx_test.py b/tests/_sphinx_test.py index 1620ba5c..ae9379ee 100644 --- a/tests/_sphinx_test.py +++ b/tests/_sphinx_test.py @@ -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.""" diff --git a/tests/checker_test.py b/tests/checker_test.py index c6b62e76..04c1fddd 100644 --- a/tests/checker_test.py +++ b/tests/checker_test.py @@ -15,7 +15,7 @@ import docutils.utils import pytest -from rstcheck_core import _extras, _sphinx, checker, config, types +from rstcheck_core import _extras, checker, config, types if t.TYPE_CHECKING: import pytest_mock @@ -378,9 +378,6 @@ def test_code_block_without_language_works_without_sphinx( @staticmethod @pytest.mark.skipif(not _extras.SPHINX_INSTALLED, reason="Depends on sphinx extra.") @pytest.mark.skipif(sys.version_info[0:2] > (3, 9), reason="Requires python3.9 or lower") - @pytest.mark.xfail( - reason="Sphinx support fails for language-less code blocks. See #3", strict=True - ) @pytest.mark.parametrize("code_block_directive", ["code", "code-block", "sourcecode"]) def test_code_block_without_language_is_works_with_sphinx_pre310( code_block_directive: str, @@ -397,21 +394,16 @@ def test_code_block_without_language_is_works_with_sphinx_pre310( """ ignores = types.construct_ignore_dict() # fmt: off - with _sphinx.load_sphinx_if_available(): - - result = list(checker.check_source(source, ignores=ignores)) + result = list(checker.check_source(source, ignores=ignores)) # fmt: on assert len(result) == 1 - assert result[0]["line_number"] == 9 + assert result[0]["line_number"] == 8 assert "unexpected EOF while parsing" in result[0]["message"] @staticmethod @pytest.mark.skipif(not _extras.SPHINX_INSTALLED, reason="Depends on sphinx extra.") @pytest.mark.skipif(sys.version_info < (3, 10), reason="Requires python3.10 or higher") - @pytest.mark.xfail( - reason="Sphinx support fails for language-less code blocks. See #3", strict=True - ) @pytest.mark.parametrize("code_block_directive", ["code", "code-block", "sourcecode"]) def test_code_block_without_language_is_works_with_sphinx( code_block_directive: str, @@ -428,13 +420,11 @@ def test_code_block_without_language_is_works_with_sphinx( """ ignores = types.construct_ignore_dict() # fmt: off - with _sphinx.load_sphinx_if_available(): - - result = list(checker.check_source(source, ignores=ignores)) + result = list(checker.check_source(source, ignores=ignores)) # fmt: on assert len(result) == 1 - assert result[0]["line_number"] == 9 + assert result[0]["line_number"] == 8 assert "'(' was never closed" in result[0]["message"] @staticmethod @@ -459,9 +449,7 @@ def test_code_block_without_language_logs_nothing_without_sphinx( """ ignores = types.construct_ignore_dict() # fmt: off - with _sphinx.load_sphinx_if_available(): - - result = list(checker.check_source(source, ignores=ignores)) + result = list(checker.check_source(source, ignores=ignores)) # fmt: on assert result @@ -470,37 +458,6 @@ def test_code_block_without_language_logs_nothing_without_sphinx( "directive (code/code-block/sourcecode) without a specified language" not in caplog.text ) - @staticmethod - @pytest.mark.skipif(not _extras.SPHINX_INSTALLED, reason="Depends on sphinx extra.") - @pytest.mark.parametrize("code_block_directive", ["code", "code-block", "sourcecode"]) - def test_code_block_without_language_logs_critcal_with_sphinx( - code_block_directive: str, - caplog: pytest.LogCaptureFixture, - ) -> None: - """Test code blocks without a language log critical and do not error with sphinx. - - Counterpart to the XFAIL tests ``test_code_block_without_language_is_works_with_sphinx``. - """ - source = f""" -.. {code_block_directive}:: - - print( - -.. {code_block_directive}:: python - - print( -""" - ignores = types.construct_ignore_dict() - # fmt: off - with _sphinx.load_sphinx_if_available(): - - result = list(checker.check_source(source, ignores=ignores)) - - # fmt: on - assert not result - assert "An `AttributeError` error occured" in caplog.text - assert "directive (code/code-block/sourcecode) without a specified language" in caplog.text - class TestCodeCheckRunner: """Test ``_run_code_checker_and_filter_errors`` function."""