8000 Fix attribute errors, address code-blocks by StaticRocket · Pull Request #107 · rstcheck/rstcheck-core · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

Fix attribute errors, address code-blocks #107

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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 @@ -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)

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[type-arg]
"""Runs CheckTranslator on code blocks."""

Expand Down Expand Up @@ -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.

Expand All @@ -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. "
Expand Down Expand Up @@ -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

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
0