#!/usr/bin/env python3
#
# mini_hooks.py
"""
Small but mighty hooks.
"""
#
# 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 re
from typing import List
# 3rd party
from domdf_python_tools.paths import PathPlus
from domdf_python_tools.stringlist import StringList
from domdf_python_tools.typing import PathLike
# this package
from formate.config import wants_filename
__all__ = ("noqa_reformat", "check_ast", "squish_stubs")
[docs]def check_ast(source: str) -> str:
"""
Check the source can be parsed as a Python Abstract Syntax Tree.
:param source: The source to check.
:raises SyntaxError: If the source is not valid Python.
:return: The source unchanged.
"""
ast.parse(source)
return source
[docs]@wants_filename
def squish_stubs(source: str, formate_filename: PathLike) -> str:
"""
Squash type stubs by removing unnecessary blank lines.
.. versionadded:: 0.2.0
:param source: The source to check.
:param formate_filename: The name of the source file, to ensure this hook only runs on type stubs.
:return: The reformatted source.
"""
filename = PathPlus(formate_filename)
if filename.suffix != ".pyi":
return source
blocks = _breakup_source(source)
return str(_reformat_blocks(blocks))
class _Variables(list):
pass
class _Class(list):
pass
class _Function(list):
pass
class _DecoratedFunction(_Function):
pass
class _Decorator(list):
pass
class _MultilineFunction(_Function):
pass
def _breakup_source(source: str) -> List[List[str]]:
blocks: List[List[str]] = [[]]
split_az_re = re.compile("[A-Za-z]")
split_az_underscore_re = re.compile("[A-Za-z)*_]")
for line in source.split('\n'):
if not line.strip():
if isinstance(blocks[-1], _Variables):
blocks[-1].append(line)
elif line.lstrip().startswith('@'):
if isinstance(blocks[-1], _Decorator):
blocks[-1].append(line)
else:
blocks.append(_Decorator([line]))
elif line.lstrip().startswith("def "):
if isinstance(blocks[-1], _Decorator):
blocks[-1] = _DecoratedFunction([*blocks[-1], line])
else:
blocks.append(_Function([line]))
elif line.lstrip().startswith("class "):
# TODO: decorated classes?
blocks.append(_Class([line]))
elif line.rstrip().startswith(' ') or line.startswith('\t'):
if isinstance(blocks[-1], _Class):
blocks[-1].append(line)
elif isinstance(blocks[-1], _MultilineFunction):
if len(blocks[-1]) < 2:
blocks[-1].append(line)
elif split_az_underscore_re.split(line)[0] == split_az_underscore_re.split(blocks[-1][-1])[0]:
blocks[-1].append(line)
else:
blocks.append(_Variables([line]))
elif isinstance(blocks[-1], _Function):
if split_az_re.split(line)[0] == split_az_re.split(blocks[-1][-1])[0]:
blocks.append(_Variables([line]))
elif line.rstrip().endswith(','):
blocks[-1] = _MultilineFunction([*blocks[-1], line])
else:
blocks.append(_Variables([line]))
elif isinstance(blocks[-1], _Variables):
blocks[-1].append(line)
else:
blocks.append(_Variables([line]))
else:
if isinstance(blocks[-1], _Variables):
blocks[-1].append(line)
else:
blocks.append(_Variables([line]))
return blocks
def _reformat_blocks(blocks: List[List[str]]) -> StringList:
cursor = 1
tab_re = re.compile("^[ \t]+")
while cursor < len(blocks):
# pylint: disable=loop-invariant-statement
if isinstance(blocks[cursor - 1], (_MultilineFunction, _DecoratedFunction, _Class)):
# Add a blank line after _Variables, a multi-line function, or a decorated function
blocks.insert(cursor, [])
cursor += 1
if (
blocks[cursor] and blocks[cursor - 1] and tab_re.match(blocks[cursor - 1][-1])
and not tab_re.match(blocks[cursor][0])
):
# Add a blank line after a dedent
blocks.insert(cursor, [])
cursor += 1
if isinstance(blocks[cursor - 1], _Variables):
# Add a blank line before and after _Variables
blocks.insert(cursor - 1, [])
blocks.insert(cursor + 1, [])
cursor += 2
if isinstance(blocks[cursor], _Variables):
# Add a blank line before and after _Variables
blocks.insert(cursor, [])
blocks.insert(cursor + 2, [])
cursor += 2
if isinstance(blocks[cursor], (_DecoratedFunction, _MultilineFunction)):
# Add a blank line before a decorated function
blocks.insert(cursor, [])
cursor += 1
if isinstance(blocks[cursor], _Class):
if (
cursor + 1 < len(blocks) and isinstance(blocks[cursor + 1], _Function)
and not isinstance(blocks[cursor + 1], (_DecoratedFunction, _MultilineFunction))
and blocks[cursor][-1].lstrip().startswith("class") and blocks[cursor + 1][0][0].isspace()
):
# pylint: enable=loop-invariant-statement
blocks.insert(cursor, [])
cursor += 2
else:
blocks.insert(cursor, [])
blocks.insert(cursor + 2, [])
cursor += 3
cursor += 1
output = StringList()
# Remove trailing whitespace from each block
for block in blocks:
if output and not block and not output[-1]:
# Remove duplicate new lines
continue
output.append('\n'.join(block).rstrip())
if not output[0]:
output.pop(0)
output.blankline(ensure_single=True)
return output