Source code for snippet_fmt

#!/usr/bin/env python3
#
#  __init__.py
"""
Format and validate code snippets in reStructuredText files.
"""
#
#  Copyright © 2021 Dominic Davis-Foster <dominic@davis-foster.co.uk>
#
#  Permission is hereby granted, free of charge, to any person obtaining a copy
#  of this software and associated documentation files (the "Software"), to deal
#  in the Software without restriction, including without limitation the rights
#  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
#  copies of the Software, and to permit persons to whom the Software is
#  furnished to do so, subject to the following conditions:
#
#  The above copyright notice and this permission notice shall be included in all
#  copies or substantial portions of the Software.
#
#  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
#  EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
#  MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
#  IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
#  DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
#  OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
#  OR OTHER DEALINGS IN THE SOFTWARE.
#
#  Parts based on https://github.com/asottile/blacken-docs
#  Copyright (c) 2018 Anthony Sottile
#  MIT Licensed
#

# stdlib
import contextlib
import re
import textwrap
from typing import Dict, Iterator, List, Match, NamedTuple, Optional

# 3rd party
import click
import entrypoints  # type: ignore[import-untyped]
from consolekit.terminal_colours import ColourTrilean, resolve_color_default
from consolekit.utils import coloured_diff
from domdf_python_tools.paths import PathPlus
from domdf_python_tools.stringlist import StringList
from domdf_python_tools.typing import PathLike
from formate.utils import syntaxerror_for_file

# this package
from snippet_fmt.config import SnippetFmtConfigDict
from snippet_fmt.formatters import Formatter, format_ini, format_json, format_python, format_toml, noformat

__author__: str = "Dominic Davis-Foster"
__copyright__: str = "2021 Dominic Davis-Foster"
__license__: str = "MIT License"
__version__: str = "0.1.5"
__email__: str = "dominic@davis-foster.co.uk"

__all__ = ("CodeBlockError", "RSTReformatter", "reformat_file")

TRAILING_NL_RE = re.compile(r'\n+\Z', re.MULTILINE)


[docs]class CodeBlockError(NamedTuple): """ Represents an exception raised when parsing and reformatting a code block. """ #: The character offset where the exception was raised. offset: int #: The exception itself. exc: Exception
# TODO: reformatter for docstrings
[docs]class RSTReformatter: """ Reformat code snippets in a reStructuredText file. :param filename: The filename to reformat. :param config: The ``snippet_fmt`` configuration, parsed from a TOML file (or similar). .. autosummary-widths:: 35/100 .. latex:clearpage:: """ #: The filename being reformatted. filename: str #: The filename being reformatted, as a POSIX-style path. file_to_format: PathPlus #: The ``formate`` configuration, parsed from a TOML file (or similar). config: SnippetFmtConfigDict errors: List[CodeBlockError] def __init__(self, filename: PathLike, config: SnippetFmtConfigDict): self.file_to_format = PathPlus(filename) self.filename = self.file_to_format.as_posix() self.config = config self._unformatted_source = self.file_to_format.read_text() self._reformatted_source: Optional[str] = None self.errors = [] self._formatters: Dict[str, Formatter] = { "bash": noformat, "python": format_python, "python3": format_python, "toml": format_toml, "ini": format_ini, "json": format_json, } self.load_extra_formatters()
[docs] def run(self) -> bool: """ Run the reformatter. :return: Whether the file was changed. """ content = StringList(self._unformatted_source) content.blankline(ensure_single=True) directives = '|'.join(self.config["directives"]) pattern = re.compile( rf'(?P<before>' rf'^(?P<indent>[ \t]*)\.\.[ \t]*(' rf'({directives})::\s*(?P<lang>[A-Za-z0-9-_]+)?)\n' rf'((?P=indent)[ \t]+:.*\n)*' # Limitation: should be `(?P=body_indent)` rather than `[ \t]+` rf'\n*' rf')' rf'(?P<code>^((?P=indent)(?P<body_indent>[ \t]+).*)?\n(^((?P=indent)(?P=body_indent).*)?\n)*)', re.MULTILINE, ) self._reformatted_source = pattern.sub(self.process_match, str(content)) for error in self.errors: lineno = self._unformatted_source[:error.offset].count('\n') + 1 click.echo(f"{self.filename}:{lineno}: {error.exc.__class__.__name__}: {error.exc}", err=True) return self._reformatted_source != self._unformatted_source
[docs] def process_match(self, match: Match[str]) -> str: """ Process a :meth:`re.Match <re.Match.expand>` for a single code block. :param match: """ lang = match.group("lang") if lang in self.config["languages"]: lang_config = self.config["languages"][lang] # TODO: show warning if not found and in "strict" mode formatter = self._formatters.get(lang.lower(), noformat) else: lang_config = {} formatter = noformat trailing_ws_match = TRAILING_NL_RE.search(match["code"]) assert trailing_ws_match trailing_ws = trailing_ws_match.group() code = textwrap.dedent(match["code"]) with self._collect_error(match): with syntaxerror_for_file(self.filename): code = formatter(code, **lang_config) code = textwrap.indent(code, match["indent"] + match["body_indent"]) return f'{match["before"]}{code.rstrip()}{trailing_ws}'
[docs] def get_diff(self) -> str: """ Returns the diff between the original and reformatted file content. """ # Based on yapf # Apache 2.0 License after = self.to_string().split('\n') before = self._unformatted_source.split('\n') return coloured_diff( before, after, self.filename, self.filename, "(original)", "(reformatted)", lineterm='', )
[docs] def to_string(self) -> str: """ Return the reformatted file as a string. """ if self._reformatted_source is None: raise ValueError("'Reformatter.run()' must be called first!") return self._reformatted_source
[docs] def to_file(self) -> None: """ Write the reformatted source to the original file. """ self.file_to_format.write_text(self.to_string())
@contextlib.contextmanager def _collect_error(self, match: Match[str]) -> Iterator[None]: try: yield except Exception as e: self.errors.append(CodeBlockError(match.start(), e))
[docs] def load_extra_formatters(self) -> None: """ Load custom formatters defined via entry points. """ group = "snippet_fmt.formatters" for distro_config, _ in entrypoints.iter_files_distros(): if group in distro_config: for name, epstr in distro_config[group].items(): with contextlib.suppress(entrypoints.BadEntryPoint, ImportError): # pylint: disable=W8205 # TODO: show warning for bad entry point if verbose, or "strict"? ep = entrypoints.EntryPoint.from_string(epstr, name) self._formatters[name] = ep.load()
[docs]def reformat_file( filename: PathLike, config: SnippetFmtConfigDict, colour: ColourTrilean = None, ) -> int: """ Reformat the given reStructuredText file, and show the diff if changes were made. :param filename: The filename to reformat. :param config: The ``snippet-fmt`` configuration, parsed from a TOML file (or similar). :param colour: Whether to force coloured output on (:py:obj:`True`) or off (:py:obj:`False`). """ r = RSTReformatter(filename, config) ret = r.run() if ret: click.echo(r.get_diff(), color=resolve_color_default(colour)) r.to_file() return ret