Skip to content

Show a diff when a file will be or gets modified #1385

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: 3.x
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions pyinfra/facts/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -534,3 +534,18 @@ def process(self, output):
if output and (output[0] == f"{MISSING}{self.path}"):
return None
return output


class FileContents(FactBase):
"""
Returns the contents of a file as a list of lines. Works with both sha1sum and sha1. Returns
``None`` if the file doest not exist.
"""

@override
def command(self, path):
return make_formatted_string_command("cat {0}", QuoteString(path))

@override
def process(self, output):
return output
19 changes: 19 additions & 0 deletions pyinfra/operations/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from pathlib import Path
from typing import IO, Any, Union

import click
from jinja2 import TemplateRuntimeError, TemplateSyntaxError, UndefinedError

from pyinfra import host, logger, state
Expand Down Expand Up @@ -44,6 +45,7 @@
Block,
Directory,
File,
FileContents,
FindFiles,
FindInFile,
Flags,
Expand All @@ -59,6 +61,7 @@
from .util.files import (
adjust_regex,
ensure_mode_int,
generate_color_diff,
get_timestamp,
sed_delete,
sed_replace,
Expand Down Expand Up @@ -915,6 +918,22 @@ def put(

# Check sha1sum, upload if needed
if local_sum != remote_sum:
# Generate diff when contents change
current_contents = host.get_fact(FileContents, path=dest)
if current_contents:
current_lines = [line + "\n" for line in current_contents]
else:
current_lines = []

logger.info(f"\n Will modify {click.style(dest, bold=True)}")

with get_file_io(src, "r") as f:
desired_lines = f.readlines()

for line in generate_color_diff(current_lines, desired_lines):
logger.info(f" {line}")
logger.info("")

yield FileUploadCommand(
local_file,
dest,
Expand Down
36 changes: 35 additions & 1 deletion pyinfra/operations/util/files.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
from __future__ import annotations

import difflib
import re
from datetime import datetime
from typing import Callable
from typing import Callable, Generator

import click

from pyinfra.api import QuoteString, StringCommand

Expand Down Expand Up @@ -173,3 +176,34 @@ def adjust_regex(line: str, escape_regex_characters: bool) -> str:
match_line = "{0}.*$".format(match_line)

return match_line


def generate_color_diff(
current_lines: list[str], desired_lines: list[str]
) -> Generator[str, None, None]:
def _format_range_unified(start: int, stop: int) -> str:
beginning = start + 1 # lines start numbering with one
length = stop - start
if length == 1:
return "{}".format(beginning)
if not length:
beginning -= 1 # empty ranges begin at line just before the range
return "{},{}".format(beginning, length)

for group in difflib.SequenceMatcher(None, current_lines, desired_lines).get_grouped_opcodes(2):
first, last = group[0], group[-1]
file1_range = _format_range_unified(first[1], last[2])
file2_range = _format_range_unified(first[3], last[4])
yield "@@ -{} +{} @@".format(file1_range, file2_range)

for tag, i1, i2, j1, j2 in group:
if tag == "equal":
for line in current_lines[i1:i2]:
yield " " + line.rstrip()
continue
if tag in {"replace", "delete"}:
for line in current_lines[i1:i2]:
yield click.style("- " + line.rstrip(), "red")
if tag in {"replace", "insert"}:
for line in desired_lines[j1:j2]:
yield click.style("+ " + line.rstrip(), "green")
6 changes: 6 additions & 0 deletions tests/facts/files.FileContents/file.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"arg": "myfile",
"command": "cat myfile",
"output": ["line1", "line2"],
"fact": ["line1", "line2"]
}
6 changes: 6 additions & 0 deletions tests/facts/files.FileContents/no_file.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"arg": ["test"],
"command": "cat test",
"output": null,
"fact": null
}
3 changes: 3 additions & 0 deletions tests/operations/files.put/different_remote.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@
},
"files.Sha1File": {
"path=/home/somefile.txt": "nowt"
},
"files.FileContents": {
"path=/home/somefile.txt": []
}
},
"commands": [
Expand Down
3 changes: 3 additions & 0 deletions tests/operations/server.user/keys_delete.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@
"files.Sha1File": {
"path=homedir/.ssh/authorized_keys": null
},
"files.FileContents": {
"path=homedir/.ssh/authorized_keys": null
},
"server.Groups": {}
},
"commands": [
Expand Down
10 changes: 10 additions & 0 deletions tests/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,16 @@ def noop(self, description):
def get_temp_filename(*args, **kwargs):
return "_tempfile_"

def get_file(
self,
remote_filename,
filename_or_io,
remote_temp_filename=None,
print_output=False,
*arguments,
):
return True

@staticmethod
def _get_fact_key(fact_cls):
return "{0}.{1}".format(fact_cls.__module__.split(".")[-1], fact_cls.__name__)
Expand Down