Source code for formate.utils

#!/usr/bin/env python3
#
#  utils.py
"""
Utility functions.
"""
#
#  Copyright © 2020-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.
#

# stdlib
import ast
import os
import pathlib
import re
import sys
from contextlib import contextmanager
from itertools import starmap
from operator import itemgetter
from typing import TYPE_CHECKING, Dict, Iterator, List, Tuple, TypeVar

# 3rd party
import asttokens
import click
from consolekit import terminal_colours
from consolekit.tracebacks import TracebackHandler
from domdf_python_tools.import_tools import discover_entry_points_by_name
from domdf_python_tools.typing import PathLike

# this package
from formate.classes import EntryPoint, Hook
from formate.exceptions import HookNotFoundError

if TYPE_CHECKING:
	# stdlib
	from typing import NoReturn

__all__ = ("import_entry_points", "normalize", "syntaxerror_for_file", "Rewriter", "SyntaxTracebackHandler")

_normalize_pattern = re.compile(r"[-_.]+")


[docs]def normalize(name: str) -> str: """ Normalize the given name into lowercase, with underscores replaced by hyphens. :param name: The hook name. """ # From PEP 503 (public domain). return _normalize_pattern.sub('-', name).lower()
[docs]def import_entry_points(hooks: List[Hook]) -> Dict[str, EntryPoint]: """ Given a list of hooks, import the corresponding entry point and return a mapping of entry point names to :class:`~.EntryPoint` objects. :param hooks: :raises: :exc:`~.HookNotFoundError` if no entry point can be found for a hook. """ # noqa: D400 hook_names = [hook.name for hook in hooks] def name_match_func(name: str) -> bool: return normalize(name) in hook_names entry_points = { normalize(k): v for k, v in { **discover_entry_points_by_name("formate_hooks", name_match_func=name_match_func), **discover_entry_points_by_name("formate-hooks", name_match_func=name_match_func), }.items() } for hook in hooks: if hook.name not in entry_points: raise HookNotFoundError(hook) return {e.name: e for e in (starmap(EntryPoint, entry_points.items()))}
[docs]class Rewriter(ast.NodeVisitor): """ ABC for rewriting Python source files from an AST and a token stream. .. autosummary-widths:: 8/16 """ #: The original source. source: str #: The tokenized source. tokens: asttokens.ASTTokens replacements: List[Tuple[Tuple[int, int], str]] """ The parts of code to replace. Each element comprises a tuple of ``(start char, end char)`` in :attr:`~.source`, and the new text to insert between these positions. """ def __init__(self, source: str): self.source = source self.tokens = asttokens.ASTTokens(source, parse=True) self.replacements: List[Tuple[Tuple[int, int], str]] = [] assert self.tokens.tree is not None
[docs] def rewrite(self) -> str: """ Rewrite the source and return the new source. :returns: The reformatted source. """ tree = self.tokens.tree assert tree is not None self.visit(tree) reformatted_source = self.source # Work from the bottom up for (start, end), replacement in sorted(self.replacements, key=itemgetter(0), reverse=True): source_before = reformatted_source[:start] source_after = reformatted_source[end:] reformatted_source = ''.join([source_before, replacement, source_after]) return reformatted_source
[docs] def record_replacement(self, text_range: Tuple[int, int], new_source: str) -> None: """ Record a region of text to be replaced. :param text_range: The region of text to be replaced. :param new_source: The new text for that region. """ self.replacements.append((text_range, new_source))
[docs]class SyntaxTracebackHandler(TracebackHandler): """ Subclass of :class:`consolekit.tracebacks.TracebackHandler` to additionally handle :exc:`SyntaxError`. """ @staticmethod def handle_SyntaxError(e: SyntaxError) -> "NoReturn": # noqa: D102 click.echo(terminal_colours.Fore.RED(f"Fatal: {e.__class__.__name__}: {e}"), err=True) sys.exit(126) @staticmethod def handle_HookNotFoundError(e: HookNotFoundError) -> "NoReturn": # noqa: D102 click.echo(terminal_colours.Fore.RED(f"Fatal: Hook not found: {e}"), err=True) sys.exit(126)
[docs]@contextmanager def syntaxerror_for_file(filename: PathLike) -> Iterator: """ Context manager to catch :exc:`SyntaxError` and set its filename to ``filename`` if the current filename is ``<unknown>``. This is useful for syntax errors raised when parsing source into an AST. :param filename: .. clearpage:: """ # noqa: D400 try: yield except SyntaxError as e: if e.filename == "<unknown>": e.filename = os.fspath(filename) raise e
_P = TypeVar("_P", bound=pathlib.Path) def _find_from_parents(path: _P) -> _P: """ Try to find ``path`` in the current directory or its parents. If the file can't be found ``path`` is returned. """ if len(path.parts) == 1 and not path.exists(): for parent in path.cwd().parents: candidate = parent / path if candidate.exists(): return candidate return path