Source code for ci_exec.patch

########################################################################################
# Copyright 2019 Stephen McDowell                                                      #
#                                                                                      #
# Licensed under the Apache License, Version 2.0 (the "License");                      #
# you may not use this file except in compliance with the License.                     #
# You may obtain a copy of the License at                                              #
#                                                                                      #
#     http://www.apache.org/licenses/LICENSE-2.0                                       #
#                                                                                      #
# Unless required by applicable law or agreed to in writing, software                  #
# distributed under the License is distributed on an "AS IS" BASIS,                    #
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.             #
# See the License for the specific language governing permissions and                  #
# limitations under the License.                                                       #
########################################################################################
"""Various utilities for patching files."""

import difflib
import re
import shutil
from pathlib import Path
from typing import Callable, Match, Optional, Union

from .core import fail


[docs]def filter_file(path: Union[Path, str], pattern: str, repl: Union[Callable[[Match], str], str], count: int = 0, flags: int = 0, backup_extension: str = ".orig", line_based: bool = False, demand_different: bool = True, encoding: Optional[str] = None) -> Path: """ Filter the contents of a file. 1. Backup ``path`` to ``{path} + {backup_extension}``. Typically, this would mean copying e.g., ``file.txt`` to ``file.txt.orig``. 2. Perform filtering using :func:`python:re.sub`. 3. If ``demand_different=True`` (default), verify that replacements were actually made. If not, |fail|. The only required arguments are ``path``, ``pattern``, and ``repl``. If any errors occur, including invalid input, this function will |fail|. .. |pass_through| replace:: Pass-through parameter to :func:`python:re.sub`. Parameters ---------- path : pathlib.Path or str The file that needs to be filtered. pattern : str The pattern to replace. |pass_through| repl : :any:`Callable[[Match], str] <python:typing.Callable>` or :class:`python:str` The replacement to be made. |pass_through| count : int The number of replacements to make (default ``0`` means replace all). |pass_through| flags : int Any flags such as :data:`python:re.IGNORECASE` or :data:`python:re.MULTILINE` (default ``0`` means no special flags). |pass_through| backup_extension : str The name to tack onto the back of ``path`` to make a backup with. Must be a non-empty string. Default: ``".orig"``. line_based : bool Whether or not replacements should be made on the entirety of the file, or on a per-line basis. Default: ``False``, do :func:`python:re.sub` on the entire contents. Setting ``line_based=True`` can make for simpler or more restrictive regular expressions depending on the replacement needed. demand_different : bool Whether or not this function should |fail| if no changes were actually made. Default: ``True``, |fail| if no filtering was performed. encoding : str or None The encoding to open files with. Default: ``None`` implies default. Pass-through parameter to :func:`python:open`. Return ------ pathlib.Path The path to the backup file that was created with the original contents. """ if isinstance(path, str): path = Path(path) if not path.is_file(): fail("Cannot filter '{path}', no such file!".format(path=str(path))) if backup_extension == "": fail("filter_file: 'backup_extension' may not be the empty string.") try: # Backup the original file before trying to filter. backup = Path(str(path) + backup_extension) shutil.copy(str(path), str(backup)) # If doing line-based replacement, change access pattern. if line_based: orig_contents = None with backup.open(encoding=encoding) as orig_f: with path.open("w", encoding=encoding) as new_f: for line in orig_f: new_f.write(re.sub( pattern, repl, line, count=count, flags=flags )) else: # Gather the contents to be replaced. with backup.open(encoding=encoding) as orig_f: orig_contents = orig_f.read() # Do the replacement directly. with path.open("w", encoding=encoding) as new_f: new_f.write(re.sub( pattern, repl, orig_contents, count=count, flags=flags )) # If requested (by default), make sure something actually changed. if demand_different: # In the line-based replacement we did not read the whole file at once. if not orig_contents: with backup.open(encoding=encoding) as orig_f: orig_contents = orig_f.read() # Read in the file that may or may not have had changes applied. with path.open(encoding=encoding) as new_f: new_contents = new_f.read() # Enforce that the files are different ;) if orig_contents == new_contents: fail("filter_file: no changes made to '{path}'".format(path=str(path))) return backup except Exception as e: fail("Unable to filter '{path}': {e}".format(path=str(path), e=e))
[docs]def unified_diff(from_path: Union[Path, str], to_path: Union[Path, str], n: int = 3, lineterm: str = "\n", encoding: Optional[str] = None, no_pygments: bool = False) -> str: r""" Return the :func:`unified_diff <difflib.unified_diff>` between two files. Any errors, such as not being able to read a file, will |fail| the application abruptly. Parameters ---------- from_path : pathlib.Path or str The file to diff from (the "original" file). to_path : pathlib.Path or str The file to diff to (the "changed" file). n : int Number of context lines. Default: ``3``. Pass-through parameter to :func:`difflib.unified_diff`. lineterm : str Default: ``"\n"``. Pass-through parameter to :func:`difflib.unified_diff`. encoding : str or None The encoding to open files with. Default: ``None`` implies default. Pass-through parameter to :func:`python:open`. no_pygments : bool Whether or not an attempt to colorize the output using `Pygments <http://pygments.org/>`_ using the ``console`` formatter. If Pygments is not installed, no errors will ensue. Default: ``False``, always try and make pretty output. Set to ``True`` if you need to enforce that the returned string does not have colors. Return ------ str A string ready to be printed to the console. """ # Make sure we have paths we can work with. if isinstance(from_path, str): from_path = Path(from_path) if isinstance(to_path, str): to_path = Path(to_path) if not from_path.is_file(): fail("unified_diff: from_path '{from_path}' does not exist!".format( from_path=str(from_path) )) if not to_path.is_file(): fail("unified_diff: to_path '{to_path}' does not exist!".format( to_path=str(to_path) )) try: # difflib wants list of strings, read them in with from_path.open(encoding=encoding) as from_file: from_lines = from_file.readlines() with to_path.open(encoding=encoding) as to_file: to_lines = to_file.readlines() # Compute the unified diff <3 diff_generator = difflib.unified_diff( from_lines, to_lines, fromfile=str(from_path), tofile=str(to_path), n=n, lineterm=lineterm ) diff_text = "".join(diff_generator) # Pygments will turn empty string (no diff) into \n, quit now. if diff_text == "": return diff_text if not no_pygments: try: import pygments from pygments import lexers, formatters lex = lexers.find_lexer_class_by_name("diff") fmt = formatters.get_formatter_by_name("console") diff_text = pygments.highlight(diff_text, lex(), fmt) except: # noqa: E722 pass return diff_text except Exception as e: fail("unified_diff: unable to diff '{from_path}' with '{to_path}': {e}".format( from_path=str(from_path), to_path=str(to_path), e=e ))