From 3f8ad8cd8ce4e1f0cf9866843dd30af05c5c9918 Mon Sep 17 00:00:00 2001 From: Jeffrey Gelens Date: Fri, 27 Jun 2025 11:22:21 +0200 Subject: [PATCH 1/4] Show a diff when a file will be or gets modified Co-authored-by: Xavier Vello --- pyinfra/operations/files.py | 18 +++++++++++++++- pyinfra/operations/util/files.py | 36 +++++++++++++++++++++++++++++++- tests/util.py | 10 +++++++++ 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/pyinfra/operations/files.py b/pyinfra/operations/files.py index b17c55626..3fb0df8c8 100644 --- a/pyinfra/operations/files.py +++ b/pyinfra/operations/files.py @@ -10,10 +10,11 @@ import traceback from datetime import datetime, timedelta, timezone from fnmatch import fnmatch -from io import StringIO +from io import BytesIO, StringIO from pathlib import Path from typing import IO, Any, Union +import click from jinja2 import TemplateRuntimeError, TemplateSyntaxError, UndefinedError from pyinfra import host, logger, state @@ -62,6 +63,7 @@ MetadataTimeField, adjust_regex, ensure_mode_int, + generate_color_diff, get_timestamp, sed_delete, sed_replace, @@ -1030,6 +1032,20 @@ def put( # File exists, check sum and check user/group/mode/atime/mtime if supplied else: if not _file_equal(local_sum_path, dest): + current_contents = BytesIO() + + # Generate diff when contents change + host.get_file(dest, current_contents) + current_lines = current_contents.getvalue().decode("utf-8").splitlines(keepends=True) + 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, diff --git a/pyinfra/operations/util/files.py b/pyinfra/operations/util/files.py index bc63d5043..24d37bafa 100644 --- a/pyinfra/operations/util/files.py +++ b/pyinfra/operations/util/files.py @@ -1,9 +1,12 @@ from __future__ import annotations +import difflib import re from datetime import datetime, timezone from enum import Enum -from typing import Callable +from typing import Callable, Generator + +import click from pyinfra.api import QuoteString, StringCommand @@ -207,3 +210,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") diff --git a/tests/util.py b/tests/util.py index 100c5e169..9b60a3926 100644 --- a/tests/util.py +++ b/tests/util.py @@ -190,6 +190,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__) From f6e99d5471fee3fd5df1aa73db75cdf929d030dc Mon Sep 17 00:00:00 2001 From: Jeffrey Gelens Date: Thu, 3 Jul 2025 15:39:33 +0200 Subject: [PATCH 2/4] Improve file diff using Facts --- pyinfra/facts/files.py | 15 +++++++++++++++ pyinfra/operations/files.py | 13 ++++++++----- tests/facts/files.FileContents/file.json | 6 ++++++ tests/facts/files.FileContents/no_file.json | 6 ++++++ tests/operations/files.put/different_remote.json | 3 +++ tests/operations/server.user/keys_delete.json | 3 +++ 6 files changed, 41 insertions(+), 5 deletions(-) create mode 100644 tests/facts/files.FileContents/file.json create mode 100644 tests/facts/files.FileContents/no_file.json diff --git a/pyinfra/facts/files.py b/pyinfra/facts/files.py index 3a32b1f12..bd8ea8e67 100644 --- a/pyinfra/facts/files.py +++ b/pyinfra/facts/files.py @@ -657,3 +657,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 diff --git a/pyinfra/operations/files.py b/pyinfra/operations/files.py index 3fb0df8c8..0e621eb8f 100644 --- a/pyinfra/operations/files.py +++ b/pyinfra/operations/files.py @@ -10,7 +10,7 @@ import traceback from datetime import datetime, timedelta, timezone from fnmatch import fnmatch -from io import BytesIO, StringIO +from io import StringIO from pathlib import Path from typing import IO, Any, Union @@ -47,6 +47,7 @@ Block, Directory, File, + FileContents, FindFiles, FindInFile, Flags, @@ -1032,11 +1033,13 @@ def put( # File exists, check sum and check user/group/mode/atime/mtime if supplied else: if not _file_equal(local_sum_path, dest): - current_contents = BytesIO() - # Generate diff when contents change - host.get_file(dest, current_contents) - current_lines = current_contents.getvalue().decode("utf-8").splitlines(keepends=True) + 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: diff --git a/tests/facts/files.FileContents/file.json b/tests/facts/files.FileContents/file.json new file mode 100644 index 000000000..8b71de1c1 --- /dev/null +++ b/tests/facts/files.FileContents/file.json @@ -0,0 +1,6 @@ +{ + "arg": "myfile", + "command": "cat myfile", + "output": ["line1", "line2"], + "fact": ["line1", "line2"] +} diff --git a/tests/facts/files.FileContents/no_file.json b/tests/facts/files.FileContents/no_file.json new file mode 100644 index 000000000..cab556293 --- /dev/null +++ b/tests/facts/files.FileContents/no_file.json @@ -0,0 +1,6 @@ +{ + "arg": ["test"], + "command": "cat test", + "output": null, + "fact": null +} diff --git a/tests/operations/files.put/different_remote.json b/tests/operations/files.put/different_remote.json index 683961d8e..610985c6f 100644 --- a/tests/operations/files.put/different_remote.json +++ b/tests/operations/files.put/different_remote.json @@ -24,6 +24,9 @@ }, "files.Sha1File": { "path=/home/somefile.txt": "nowt" + }, + "files.FileContents": { + "path=/home/somefile.txt": [] } }, "commands": [ diff --git a/tests/operations/server.user/keys_delete.json b/tests/operations/server.user/keys_delete.json index 0223ad573..3e2089c12 100644 --- a/tests/operations/server.user/keys_delete.json +++ b/tests/operations/server.user/keys_delete.json @@ -44,6 +44,9 @@ "files.Sha256File": { "path=homedir/.ssh/authorized_keys": null }, + "files.FileContents": { + "path=homedir/.ssh/authorized_keys": null + }, "server.Groups": {} }, "commands": [ From 8b638a7b81a123e9d06092b2c1c28a1ea2c3debe Mon Sep 17 00:00:00 2001 From: Jeffrey Gelens Date: Thu, 3 Jul 2025 15:39:33 +0200 Subject: [PATCH 3/4] Updated tests to introduced in main branch --- pyinfra/operations/git.py | 2 +- tests/operations/files.put/fallback_md5.json | 7 +++++-- tests/operations/files.put/fallback_sha256.json | 7 +++++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/pyinfra/operations/git.py b/pyinfra/operations/git.py index 6e53506f4..905d3ae12 100644 --- a/pyinfra/operations/git.py +++ b/pyinfra/operations/git.py @@ -148,7 +148,7 @@ def repo( if branch and host.get_fact(GitBranch, repo=dest) != branch: git_commands.append("fetch") # fetch to ensure we have the branch locally git_commands.append("checkout {0}".format(branch)) - if branch and branch in host.get_fact(GitTag, repo=dest): + if branch and branch in (host.get_fact(GitTag, repo=dest) or []): git_commands.append("checkout {0}".format(branch)) is_tag = True if pull and not is_tag: diff --git a/tests/operations/files.put/fallback_md5.json b/tests/operations/files.put/fallback_md5.json index ade86b3f5..3b4ce62c8 100644 --- a/tests/operations/files.put/fallback_md5.json +++ b/tests/operations/files.put/fallback_md5.json @@ -20,9 +20,12 @@ }, "files.Md5File": { "path=/home/somefile.txt": "nowt" - } + }, + "files.FileContents": { + "path=/home/somefile.txt": null + }, }, "commands": [ ["upload", "/somefile.txt", "/home/somefile.txt"] ] -} \ No newline at end of file +} diff --git a/tests/operations/files.put/fallback_sha256.json b/tests/operations/files.put/fallback_sha256.json index d1caffac8..7f66b6729 100644 --- a/tests/operations/files.put/fallback_sha256.json +++ b/tests/operations/files.put/fallback_sha256.json @@ -23,9 +23,12 @@ }, "files.Sha256File": { "path=/home/somefile.txt": "nowt" - } + }, + "files.FileContents": { + "path=/home/somefile.txt": null + }, }, "commands": [ ["upload", "/somefile.txt", "/home/somefile.txt"] ] -} \ No newline at end of file +} From 2381ab93f8b472df8fa3398938b80d4d0a0ce96c Mon Sep 17 00:00:00 2001 From: Jeffrey Gelens Date: Fri, 1 Aug 2025 12:23:25 +0200 Subject: [PATCH 4/4] Added --diff option to CLI --- pyinfra/api/config.py | 1 + pyinfra/operations/files.py | 25 +++++++++++++------------ pyinfra_cli/main.py | 12 ++++++++++++ tests/test_cli/test_cli.py | 1 + 4 files changed, 27 insertions(+), 12 deletions(-) diff --git a/pyinfra/api/config.py b/pyinfra/api/config.py index 3a8a4f6a6..076aaf1eb 100644 --- a/pyinfra/api/config.py +++ b/pyinfra/api/config.py @@ -53,6 +53,7 @@ class ConfigDefaults: IGNORE_ERRORS: bool = False # Shell to use to execute commands SHELL: str = "sh" + DIFF: bool = False config_defaults = {key: value for key, value in ConfigDefaults.__dict__.items() if key.isupper()} diff --git a/pyinfra/operations/files.py b/pyinfra/operations/files.py index 0e621eb8f..dd853bb1b 100644 --- a/pyinfra/operations/files.py +++ b/pyinfra/operations/files.py @@ -1033,21 +1033,22 @@ def put( # File exists, check sum and check user/group/mode/atime/mtime if supplied else: if not _file_equal(local_sum_path, dest): - # 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 = [] + if state.config.DIFF: + # 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)}") + logger.info(f"\n Will modify {click.style(dest, bold=True)}") - with get_file_io(src, "r") as f: - desired_lines = f.readlines() + 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("") + for line in generate_color_diff(current_lines, desired_lines): + logger.info(f" {line}") + logger.info("") yield FileUploadCommand( local_file, diff --git a/pyinfra_cli/main.py b/pyinfra_cli/main.py index 98cdb615e..907cfc5ed 100644 --- a/pyinfra_cli/main.py +++ b/pyinfra_cli/main.py @@ -67,6 +67,12 @@ def _print_support(ctx, param, value): default=False, help="Don't execute operations on the target hosts.", ) +@click.option( + "--diff", + is_flag=True, + default=False, + help="Show the differences when changing text files and templates.", +) @click.option( "-y", "--yes", @@ -267,6 +273,7 @@ def _main( group_data, config_filename: str, dry: bool, + diff: bool, yes: bool, limit: Iterable, no_wait: bool, @@ -310,6 +317,7 @@ def _main( shell_executable, fail_percent, yes, + diff, ) override_data = _set_override_data( data, @@ -549,6 +557,7 @@ def _set_config( shell_executable, fail_percent, yes, + diff, ): logger.info("--> Loading config...") @@ -583,6 +592,9 @@ def _set_config( if fail_percent is not None: config.FAIL_PERCENT = fail_percent + if diff: + config.DIFF = True + return config diff --git a/tests/test_cli/test_cli.py b/tests/test_cli/test_cli.py index c2677864f..c703b5f49 100644 --- a/tests/test_cli/test_cli.py +++ b/tests/test_cli/test_cli.py @@ -188,5 +188,6 @@ def test_deploy_operation_direct(self): debug_all=False, debug_operations=False, config_filename="config.py", + diff=True, ) assert e.args == (0,)