Source code for hammurabi.rules.text

"""
Text module contains simple but powerful general file content manipulations.
Combined with other simple rules like :class:`hammurabi.rules.files.FileExists`
or :class:`hammurabi.rules.attributes.ModeChanged` almost anything can be
achieved. Although any file's content can be changed using these rules, for
common file formats like ``ini``, ``yaml`` or ``json`` dedicated rules are
created.
"""


import logging
from pathlib import Path
import re
from typing import List, Optional, Tuple

from hammurabi.rules.common import SinglePathRule


[docs]class LineExists(SinglePathRule): """ Make sure that the given file contains the required line. This rule is capable for inserting the expected text before or after the unique target text respecting the indentation of its context. The default behaviour is to insert the required text exactly after the target line, and respect its indentation. Please note that ``text``and ``target`` parameters are required. Example usage: .. code-block:: python >>> from pathlib import Path >>> from hammurabi import Law, Pillar, LineExists, IsLineNotExists >>> >>> gunicorn_config = Path("./gunicorn.conf.py") >>> example_law = Law( >>> name="Name of the law", >>> description="Well detailed description what this law does.", >>> rules=( >>> LineExists( >>> name="Extend gunicorn config", >>> path=gunicorn_config, >>> text="keepalive = 65", >>> target=r"^bind.*", >>> preconditions=[ >>> IsLineNotExists(path=gunicorn_config, criteria=r"^keepalive.*") >>> ] >>> ), >>> ) >>> ) >>> >>> pillar = Pillar() >>> pillar.register(example_law) .. note:: The indentation of the target text will be extracted by a simple regular expression. If a more complex regexp is required, please inherit from this class. """ def __init__( self, name: str, path: Optional[Path] = None, text: Optional[str] = None, target: Optional[str] = None, position: int = 1, respect_indentation: bool = True, ensure_trailing_newline: bool = False, **kwargs, ) -> None: self.text = self.validate(text, required=True) self.target = re.compile(self.validate(target, required=True)) self.position = position self.respect_indentation = respect_indentation self.indentation_pattern = re.compile(r"^\s+") self.ensure_trailing_newline = ensure_trailing_newline super().__init__(name, path, **kwargs) def __get_target_match(self, lines: List[str]) -> str: """ Get the matching target from the content of the given file. In case the matching number of lines are more than one or no match found, an exception will be raised accordingly. :param lines: Content of the given file :type lines: List[str] :raises: ``LookupError`` if no matching line can be found for target :return: List of the matching line :rtype: str """ target_match = list(filter(self.target.match, lines)) if not target_match: raise LookupError(f'No matching line for "{self.target}"') return target_match.pop() def __get_lines_from_file(self) -> Tuple[List[str], bool]: """ Get the lines from the given file. In case of the file is empty, then append the expected line. :return: Returns the parsed lines and an indicator if the file was empty :rtype: tuple """ file_was_empty = False with self.param.open("r") as file: logging.debug('Reading from "%s"', str(self.param)) lines: List[str] = file.read().splitlines() if self.ensure_trailing_newline and lines[-1].strip() != "": lines.append("") if not lines: logging.debug('Adding "%s" to "%s"', self.text, str(self.param)) lines.append(self.text) file_was_empty = True return lines, file_was_empty def __write_content_to_file(self, lines: List[str]): """ Write the extended content of the file back. When writing the lines it is important to watch out for the new line character at the end of every line. :param lines: The new content of the original file :type lines: List[str] """ with self.param.open("w") as file: file.writelines((f"{line}\n" for line in lines)) def __add_line(self, lines: List[str]) -> None: """ Make sure that the expected line is added to the list of lines. :param lines: Lines read from the input file :type lines: List[str] """ target_match = self.__get_target_match(lines) # Get the index of the element from the right target_match_index = len(lines) - lines[::-1].index(target_match) - 1 insert_position = target_match_index + self.position logging.debug('Inserting "%s" to position "%d"', self.text, insert_position) indentation = self.indentation_pattern.match(lines[target_match_index]) if self.respect_indentation and indentation: self.text = indentation.group() + self.text lines.insert(insert_position, self.text)
[docs] def task(self) -> Path: """ Make sure that the given file contains the required line. This rule is capable for inserting the expected rule before or after the unique target text respecting the indentation of its context. :raises: ``LookupError`` :return: Returns the path of the modified file :rtype: Path """ lines, file_was_empty = self.__get_lines_from_file() if not file_was_empty: self.__add_line(lines) self.__write_content_to_file(lines) return self.param
[docs]class LineNotExists(SinglePathRule): """ Make sure that the given file not contains the specified line. Example usage: .. code-block:: python >>> from pathlib import Path >>> from hammurabi import Law, Pillar, LineNotExists >>> >>> example_law = Law( >>> name="Name of the law", >>> description="Well detailed description what this law does.", >>> rules=( >>> LineNotExists( >>> name="Remove keepalive", >>> path=Path("./gunicorn.conf.py"), >>> text="keepalive = 65", >>> ), >>> ) >>> ) >>> >>> pillar = Pillar() >>> pillar.register(example_law) """ def __init__( self, name: str, path: Optional[Path] = None, text: Optional[str] = None, **kwargs, ) -> None: self.text = re.compile(self.validate(text, cast_to=str, required=True)) super().__init__(name, path, **kwargs)
[docs] def task(self) -> Path: """ Make sure that the given file not contains the specified line. :return: Returns the path of the modified file :rtype: Path """ with self.param.open("r") as file: lines = file.read().splitlines() new_lines = list(filter(lambda l: not self.text.match(l), lines)) if new_lines != lines: with self.param.open("w") as file: file.writelines((f"{line}\n" for line in new_lines)) return self.param
[docs]class LineReplaced(SinglePathRule): """ Make sure that the given text is replaced in the given file. The default behaviour is to replace the required text with the exact same indentation that the target line has. This behaviour can be turned off by setting the ``respect_indentation`` parameter to False. Please note that ``text`` and ``target`` parameters are required. Example usage: .. code-block:: python >>> from pathlib import Path >>> from hammurabi import Law, Pillar, LineReplaced >>> >>> example_law = Law( >>> name="Name of the law", >>> description="Well detailed description what this law does.", >>> rules=( >>> LineReplaced( >>> name="Replace typo using regex", >>> path=Path("./gunicorn.conf.py"), >>> text="keepalive = 65", >>> target=r"^kepalive.*", >>> ), >>> ) >>> ) >>> >>> pillar = Pillar() >>> pillar.register(example_law) .. note:: The indentation of the target text will be extracted by a simple regular expression. If a more complex regexp is required, please inherit from this class. .. warning:: This rule will replace all the matching lines in the given file. Make sure the given target regular expression is tested before the rule used against production code. """ def __init__( self, name: str, path: Optional[Path] = None, text: Optional[str] = None, target: Optional[str] = None, respect_indentation: bool = True, **kwargs, ) -> None: self.text = self.validate(text, required=True) self.target = re.compile(self.validate(target, required=True)) self.respect_indentation = respect_indentation self.indentation_pattern = re.compile(r"^\s+") super().__init__(name, path, **kwargs) def __get_lines_from_file(self) -> Tuple[List[str], bool]: """ Get the lines from the given file. :return: Returns the parsed lines and an indicator if the file was empty :rtype: tuple """ with self.param.open("r") as file: logging.debug('Reading from "%s"', str(self.param)) lines = file.read().splitlines() return lines, not lines def __write_content_to_file(self, lines: List[str]): """ Write the extended content of the file back. When writing the lines it is important to watch out for the new line character at the end of every line. :param lines: The new content of the original file :type lines: List[str] """ with self.param.open("w") as file: file.writelines((f"{line}\n" for line in lines)) def __replace_line(self, lines: List[str], target: str): """ Replace the target texts with the given text. :param lines: The new content of the original file :type lines: List[str] :param target: The matching target in the given file's content :type target: str """ target_index = lines.index(target) indentation = self.indentation_pattern.match(lines[target_index]) if self.respect_indentation and indentation: self.text = indentation.group() + self.text lines[target_index] = self.text
[docs] def task(self) -> Path: """ Make sure that the given text is replaced in the given file. :raises: ``LookupError`` if we can not decide or can not find what should be replaced :return: Returns the path of the modified file :rtype: Path """ lines, _ = self.__get_lines_from_file() target_match = list(filter(self.target.match, lines)) text = list(filter(lambda l: l.strip() == self.text, lines)) if target_match and text: raise LookupError(f'Both "{self.target}" and "{self.text}" exists') if text: return self.param if not target_match: raise LookupError(f'No matching line for "{self.target}"') for target in target_match: self.__replace_line(lines, target) self.__write_content_to_file(lines) return self.param