diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 7efb782..40007cd 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,10 @@ Changelog ========= +* Add support for on/off comments. + + Thanks to Timothée Mazzucotelli in `PR #287 `__. + * Fix Markdown ``pycon`` formatting to allow formatting the rest of the file. 1.17.0 (2024-06-29) diff --git a/README.rst b/README.rst index dc2f69f..55d3678 100644 --- a/README.rst +++ b/README.rst @@ -155,6 +155,16 @@ And “pycon” blocks: ``` +Prevent formatting within a block using ``blacken-docs:off`` and ``blacken-docs:on`` comments: + +.. code-block:: markdown + + + ```python + # whatever you want + ``` + + Within Python files, docstrings that contain Markdown code blocks may be reformatted: .. code-block:: python @@ -189,6 +199,18 @@ In “pycon” blocks: ... print("hello world") ... +Prevent formatting within a block using ``blacken-docs:off`` and ``blacken-docs:on`` comments: + +.. code-block:: rst + + .. blacken-docs:off + + .. code-block:: python + + # whatever you want + + .. blacken-docs:on + Use ``--rst-literal-blocks`` to also format `literal blocks `__: .. code-block:: rst @@ -244,3 +266,13 @@ In PythonTeX blocks: def hello(): print("hello world") \end{pycode} + +Prevent formatting within a block using ``blacken-docs:off`` and ``blacken-docs:on`` comments: + +.. code-block:: latex + + % blacken-docs:off + \begin{minted}{python} + # whatever you want + \end{minted} + % blacken-docs:on diff --git a/src/blacken_docs/__init__.py b/src/blacken_docs/__init__.py index ac93bdd..298fb99 100644 --- a/src/blacken_docs/__init__.py +++ b/src/blacken_docs/__init__.py @@ -4,6 +4,7 @@ import contextlib import re import textwrap +from bisect import bisect from typing import Generator from typing import Match from typing import Sequence @@ -87,6 +88,16 @@ ) INDENT_RE = re.compile("^ +(?=[^ ])", re.MULTILINE) TRAILING_NL_RE = re.compile(r"\n+\Z", re.MULTILINE) +ON_OFF = r"blacken-docs:(on|off)" +ON_OFF_COMMENT_RE = re.compile( + # Markdown + rf"(?:^\s*$)|" + # rST + rf"(?:^\s*\.\. +{ON_OFF}$)|" + # LaTeX + rf"(?:^\s*% {ON_OFF}$)", + re.MULTILINE, +) class CodeBlockError: @@ -103,6 +114,29 @@ def format_str( ) -> tuple[str, Sequence[CodeBlockError]]: errors: list[CodeBlockError] = [] + off_ranges = [] + off_start = None + for comment in re.finditer(ON_OFF_COMMENT_RE, src): + # Check for the "off" value across the multiple (on|off) groups. + if "off" in comment.groups(): + if off_start is None: + off_start = comment.start() + else: + if off_start is not None: + off_ranges.append((off_start, comment.end())) + off_start = None + if off_start is not None: + off_ranges.append((off_start, len(src))) + + def _within_off_range(code_range: tuple[int, int]) -> bool: + index = bisect(off_ranges, code_range) + try: + off_start, off_end = off_ranges[index - 1] + except IndexError: + return False + code_start, code_end = code_range + return code_start >= off_start and code_end <= off_end + @contextlib.contextmanager def _collect_error(match: Match[str]) -> Generator[None, None, None]: try: @@ -111,6 +145,8 @@ def _collect_error(match: Match[str]) -> Generator[None, None, None]: errors.append(CodeBlockError(match.start(), e)) def _md_match(match: Match[str]) -> str: + if _within_off_range(match.span()): + return match[0] code = textwrap.dedent(match["code"]) with _collect_error(match): code = black.format_str(code, mode=black_mode) @@ -118,6 +154,8 @@ def _md_match(match: Match[str]) -> str: return f'{match["before"]}{code}{match["after"]}' def _rst_match(match: Match[str]) -> str: + if _within_off_range(match.span()): + return match[0] lang = match["lang"] if lang is not None and lang not in PYGMENTS_PY_LANGS: return match[0] @@ -132,6 +170,8 @@ def _rst_match(match: Match[str]) -> str: return f'{match["before"]}{code.rstrip()}{trailing_ws}' def _rst_literal_blocks_match(match: Match[str]) -> str: + if _within_off_range(match.span()): + return match[0] if not match["code"].strip(): return match[0] min_indent = min(INDENT_RE.findall(match["code"])) @@ -190,17 +230,23 @@ def finish_fragment() -> None: return code def _md_pycon_match(match: Match[str]) -> str: + if _within_off_range(match.span()): + return match[0] code = _pycon_match(match) code = textwrap.indent(code, match["indent"]) return f'{match["before"]}{code}{match["after"]}' def _rst_pycon_match(match: Match[str]) -> str: + if _within_off_range(match.span()): + return match[0] code = _pycon_match(match) min_indent = min(INDENT_RE.findall(match["code"])) code = textwrap.indent(code, min_indent) return f'{match["before"]}{code}' def _latex_match(match: Match[str]) -> str: + if _within_off_range(match.span()): + return match[0] code = textwrap.dedent(match["code"]) with _collect_error(match): code = black.format_str(code, mode=black_mode) @@ -208,6 +254,8 @@ def _latex_match(match: Match[str]) -> str: return f'{match["before"]}{code}{match["after"]}' def _latex_pycon_match(match: Match[str]) -> str: + if _within_off_range(match.span()): + return match[0] code = _pycon_match(match) code = textwrap.indent(code, match["indent"]) return f'{match["before"]}{code}{match["after"]}' diff --git a/tests/test_blacken_docs.py b/tests/test_blacken_docs.py index 86e606e..8d19a17 100644 --- a/tests/test_blacken_docs.py +++ b/tests/test_blacken_docs.py @@ -221,6 +221,151 @@ def test_format_src_markdown_pycon_twice(): ) +def test_format_src_markdown_comments_disable(): + before = ( + "\n" + "```python\n" + "'single quotes rock'\n" + "```\n" + "\n" + ) + after, _ = blacken_docs.format_str(before, BLACK_MODE) + assert after == before + + +def test_format_src_markdown_comments_disabled_enabled(): + before = ( + "\n" + "```python\n" + "'single quotes rock'\n" + "```\n" + "\n" + "```python\n" + "'double quotes rock'\n" + "```\n" + ) + after, _ = blacken_docs.format_str(before, BLACK_MODE) + assert after == ( + "\n" + "```python\n" + "'single quotes rock'\n" + "```\n" + "\n" + "```python\n" + '"double quotes rock"\n' + "```\n" + ) + + +def test_format_src_markdown_comments_before(): + before = ( + "\n" + "\n" + "```python\n" + "'double quotes rock'\n" + "```\n" + ) + after, _ = blacken_docs.format_str(before, BLACK_MODE) + assert after == ( + "\n" + "\n" + "```python\n" + '"double quotes rock"\n' + "```\n" + ) + + +def test_format_src_markdown_comments_after(): + before = ( + "```python\n" + "'double quotes rock'\n" + "```\n" + "\n" + "\n" + ) + after, _ = blacken_docs.format_str(before, BLACK_MODE) + assert after == ( + "```python\n" + '"double quotes rock"\n' + "```\n" + "\n" + "\n" + ) + + +def test_format_src_markdown_comments_only_on(): + # fmt: off + before = ( + "\n" + "```python\n" + "'double quotes rock'\n" + "```\n" + ) + after, _ = blacken_docs.format_str(before, BLACK_MODE) + assert after == ( + "\n" + "```python\n" + '"double quotes rock"\n' + "```\n" + ) + # fmt: on + + +def test_format_src_markdown_comments_only_off(): + # fmt: off + before = ( + "\n" + "```python\n" + "'single quotes rock'\n" + "```\n" + ) + # fmt: on + after, _ = blacken_docs.format_str(before, BLACK_MODE) + assert after == before + + +def test_format_src_markdown_comments_multiple(): + before = ( + "\n" # ignored + "\n" + "\n" + "\n" # ignored + "\n" + "\n" # ignored + "```python\n" + "'single quotes rock'\n" + "```\n" # no on comment, off until the end + ) + after, _ = blacken_docs.format_str(before, BLACK_MODE) + assert after == before + + +def test_on_off_comments_in_code_blocks(): + before = ( + "````md\n" + "\n" + "```python\n" + "f(1,2,3)\n" + "```\n" + "\n" + "````\n" + ) + after, _ = blacken_docs.format_str(before, BLACK_MODE) + assert after == before + + +def test_format_src_markdown_comments_disable_pycon(): + before = ( + "\n" + "```pycon\n" + ">>> 'single quotes rock'\n" + "```\n" + "\n" + ) + after, _ = blacken_docs.format_str(before, BLACK_MODE) + assert after == before + + def test_format_src_latex_minted(): before = ( "hello\n" "\\begin{minted}{python}\n" "f(1,2,3)\n" "\\end{minted}\n" "world!" @@ -319,12 +464,60 @@ def test_format_src_latex_minted_pycon_indented(): ) -def test_src_pythontex(): - before = "hello\n" "\\begin{pyblock}\n" "f(1,2,3)\n" "\\end{pyblock}\n" "world!" +def test_format_src_latex_minted_comments_off(): + before = ( + "% blacken-docs:off\n" + "\\begin{minted}{python}\n" + "'single quotes rock'\n" + "\\end{minted}\n" + "% blacken-docs:on\n" + ) + after, _ = blacken_docs.format_str(before, BLACK_MODE) + assert after == before + + +def test_format_src_latex_minted_comments_off_pycon(): + before = ( + "% blacken-docs:off\n" + "\\begin{minted}{pycon}\n" + ">>> 'single quotes rock'\n" + "\\end{minted}\n" + "% blacken-docs:on\n" + ) + after, _ = blacken_docs.format_str(before, BLACK_MODE) + assert after == before + + +def test_format_src_pythontex(): + # fmt: off + before = ( + "hello\n" + "\\begin{pyblock}\n" + "f(1,2,3)\n" + "\\end{pyblock}\n" + "world!" + ) after, _ = blacken_docs.format_str(before, BLACK_MODE) assert after == ( - "hello\n" "\\begin{pyblock}\n" "f(1, 2, 3)\n" "\\end{pyblock}\n" "world!" + "hello\n" + "\\begin{pyblock}\n" + "f(1, 2, 3)\n" + "\\end{pyblock}\n" + "world!" ) + # fmt: on + + +def test_format_src_pythontex_comments_off(): + before = ( + "% blacken-docs:off\n" + "\\begin{pyblock}\n" + "f(1,2,3)\n" + "\\end{pyblock}\n" + "% blacken-docs:on\n" + ) + after, _ = blacken_docs.format_str(before, BLACK_MODE) + assert after == before def test_format_src_rst(): @@ -385,6 +578,19 @@ def test_format_src_rst_literal_blocks_empty(): assert errors == [] +def test_format_src_rst_literal_blocks_comments(): + before = ( + ".. blacken-docs:off\n" + "Example::\n" + "\n" + " 'single quotes rock'\n" + "\n" + ".. blacken-docs:on\n" + ) + after, _ = blacken_docs.format_str(before, BLACK_MODE, rst_literal_blocks=True) + assert after == before + + def test_format_src_rst_sphinx_doctest(): before = ( ".. testsetup:: group1\n" @@ -511,6 +717,19 @@ def test_format_src_rst_python_inside_non_python_code_block(): assert after == before +def test_format_src_rst_python_comments(): + before = ( + ".. blacken-docs:off\n" + ".. code-block:: python\n" + "\n" + " 'single quotes rock'\n" + "\n" + ".. blacken-docs:on\n" + ) + after, _ = blacken_docs.format_str(before, BLACK_MODE) + assert after == before + + def test_integration_ok(tmp_path, capsys): f = tmp_path / "f.md" f.write_text( @@ -1038,3 +1257,16 @@ def test_format_src_rst_pycon_comment_before_promopt(): " # Comment about next line\n" " >>> pass\n" ) + + +def test_format_src_rst_pycon_comments(): + before = ( + ".. blacken-docs:off\n" + ".. code-block:: pycon\n" + "\n" + " >>> 'single quotes rock'\n" + "\n" + ".. blacken-docs:on\n" + ) + after, _ = blacken_docs.format_str(before, BLACK_MODE) + assert after == before