diff options
author | Adam J. Stewart <ajstewart426@gmail.com> | 2024-03-13 07:22:10 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-03-12 23:22:10 -0700 |
commit | 94a1d1414aa26fff55b55f1e9c93f342d12b6464 (patch) | |
tree | 39a5ab0a15c359d71c46825e1f9eaa9af59622cf /lib | |
parent | 0f080b38f4c81221f030554d7e723d7f3c303f9b (diff) | |
download | spack-94a1d1414aa26fff55b55f1e9c93f342d12b6464.tar.gz spack-94a1d1414aa26fff55b55f1e9c93f342d12b6464.tar.bz2 spack-94a1d1414aa26fff55b55f1e9c93f342d12b6464.tar.xz spack-94a1d1414aa26fff55b55f1e9c93f342d12b6464.zip |
spack.patch: support reversing patches (#43040)
The `patch()` directive can now be invoked with `reverse=True` to apply a patch in reverse.
This is useful for reverting commits that caused errors in projects, even if only the forward
patch is available, e.g. via a GitHub commit patch URL.
`patch(..., reverse=True)` runs `patch -R` behind the scenes. This is a POSIX option so we
can expect it to be available on the `patch` command.
---------
Co-authored-by: Massimiliano Culpo <massimiliano.culpo@gmail.com>
Diffstat (limited to 'lib')
-rw-r--r-- | lib/spack/spack/directives.py | 6 | ||||
-rw-r--r-- | lib/spack/spack/patch.py | 42 | ||||
-rw-r--r-- | lib/spack/spack/test/patch.py | 69 |
3 files changed, 107 insertions, 10 deletions
diff --git a/lib/spack/spack/directives.py b/lib/spack/spack/directives.py index ec9c5b4065..207624965d 100644 --- a/lib/spack/spack/directives.py +++ b/lib/spack/spack/directives.py @@ -660,6 +660,7 @@ def patch( level: int = 1, when: WhenType = None, working_dir: str = ".", + reverse: bool = False, sha256: Optional[str] = None, archive_sha256: Optional[str] = None, ) -> Patcher: @@ -673,10 +674,10 @@ def patch( level: patch level (as in the patch shell command) when: optional anonymous spec that specifies when to apply the patch working_dir: dir to change to before applying + reverse: reverse the patch sha256: sha256 sum of the patch, used to verify the patch (only required for URL patches) archive_sha256: sha256 sum of the *archive*, if the patch is compressed (only required for compressed URL patches) - """ def _execute_patch(pkg_or_dep: Union["spack.package_base.PackageBase", Dependency]): @@ -711,13 +712,14 @@ def patch( url_or_filename, level, working_dir=working_dir, + reverse=reverse, ordering_key=ordering_key, sha256=sha256, archive_sha256=archive_sha256, ) else: patch = spack.patch.FilePatch( - pkg, url_or_filename, level, working_dir, ordering_key=ordering_key + pkg, url_or_filename, level, working_dir, reverse, ordering_key=ordering_key ) cur_patches.append(patch) diff --git a/lib/spack/spack/patch.py b/lib/spack/spack/patch.py index c11ce9079d..531445b4f9 100644 --- a/lib/spack/spack/patch.py +++ b/lib/spack/spack/patch.py @@ -26,7 +26,11 @@ from spack.util.executable import which, which_string def apply_patch( - stage: "spack.stage.Stage", patch_path: str, level: int = 1, working_dir: str = "." + stage: "spack.stage.Stage", + patch_path: str, + level: int = 1, + working_dir: str = ".", + reverse: bool = False, ) -> None: """Apply the patch at patch_path to code in the stage. @@ -35,6 +39,7 @@ def apply_patch( patch_path: filesystem location for the patch to apply level: patch level working_dir: relative path *within* the stage to change to + reverse: reverse the patch """ git_utils_path = os.environ.get("PATH", "") if sys.platform == "win32": @@ -45,6 +50,10 @@ def apply_patch( git_root = git_root / "usr" / "bin" git_utils_path = os.pathsep.join([str(git_root), git_utils_path]) + args = ["-s", "-p", str(level), "-i", patch_path, "-d", working_dir] + if reverse: + args.append("-R") + # TODO: Decouple Spack's patch support on Windows from Git # for Windows, and instead have Spack directly fetch, install, and # utilize that patch. @@ -53,7 +62,7 @@ def apply_patch( # flag is passed. patch = which("patch", required=True, path=git_utils_path) with llnl.util.filesystem.working_dir(stage.source_path): - patch("-s", "-p", str(level), "-i", patch_path, "-d", working_dir) + patch(*args) class Patch: @@ -67,7 +76,12 @@ class Patch: sha256: str def __init__( - self, pkg: "spack.package_base.PackageBase", path_or_url: str, level: int, working_dir: str + self, + pkg: "spack.package_base.PackageBase", + path_or_url: str, + level: int, + working_dir: str, + reverse: bool = False, ) -> None: """Initialize a new Patch instance. @@ -76,6 +90,7 @@ class Patch: path_or_url: the relative path or URL to a patch file level: patch level working_dir: relative path *within* the stage to change to + reverse: reverse the patch """ # validate level (must be an integer >= 0) if not isinstance(level, int) or not level >= 0: @@ -87,6 +102,7 @@ class Patch: self.path: Optional[str] = None # must be set before apply() self.level = level self.working_dir = working_dir + self.reverse = reverse def apply(self, stage: "spack.stage.Stage") -> None: """Apply a patch to source in a stage. @@ -97,7 +113,7 @@ class Patch: if not self.path or not os.path.isfile(self.path): raise NoSuchPatchError(f"No such patch: {self.path}") - apply_patch(stage, self.path, self.level, self.working_dir) + apply_patch(stage, self.path, self.level, self.working_dir, self.reverse) # TODO: Use TypedDict once Spack supports Python 3.8+ only def to_dict(self) -> Dict[str, Any]: @@ -111,6 +127,7 @@ class Patch: "sha256": self.sha256, "level": self.level, "working_dir": self.working_dir, + "reverse": self.reverse, } def __eq__(self, other: object) -> bool: @@ -146,6 +163,7 @@ class FilePatch(Patch): relative_path: str, level: int, working_dir: str, + reverse: bool = False, ordering_key: Optional[Tuple[str, int]] = None, ) -> None: """Initialize a new FilePatch instance. @@ -155,6 +173,7 @@ class FilePatch(Patch): relative_path: path to patch, relative to the repository directory for a package. level: level to pass to patch command working_dir: path within the source directory where patch should be applied + reverse: reverse the patch ordering_key: key used to ensure patches are applied in a consistent order """ self.relative_path = relative_path @@ -182,7 +201,7 @@ class FilePatch(Patch): msg += "package %s.%s does not exist." % (pkg.namespace, pkg.name) raise ValueError(msg) - super().__init__(pkg, abs_path, level, working_dir) + super().__init__(pkg, abs_path, level, working_dir, reverse) self.path = abs_path self.ordering_key = ordering_key @@ -228,6 +247,7 @@ class UrlPatch(Patch): level: int = 1, *, working_dir: str = ".", + reverse: bool = False, sha256: str, # This is required for UrlPatch ordering_key: Optional[Tuple[str, int]] = None, archive_sha256: Optional[str] = None, @@ -239,12 +259,13 @@ class UrlPatch(Patch): url: URL where the patch can be fetched level: level to pass to patch command working_dir: path within the source directory where patch should be applied + reverse: reverse the patch ordering_key: key used to ensure patches are applied in a consistent order sha256: sha256 sum of the patch, used to verify the patch archive_sha256: sha256 sum of the *archive*, if the patch is compressed (only required for compressed URL patches) """ - super().__init__(pkg, url, level, working_dir) + super().__init__(pkg, url, level, working_dir, reverse) self.url = url self._stage: Optional["spack.stage.Stage"] = None @@ -350,13 +371,20 @@ def from_dict( dictionary["url"], dictionary["level"], working_dir=dictionary["working_dir"], + # Added in v0.22, fallback required for backwards compatibility + reverse=dictionary.get("reverse", False), sha256=dictionary["sha256"], archive_sha256=dictionary.get("archive_sha256"), ) elif "relative_path" in dictionary: patch = FilePatch( - pkg_cls, dictionary["relative_path"], dictionary["level"], dictionary["working_dir"] + pkg_cls, + dictionary["relative_path"], + dictionary["level"], + dictionary["working_dir"], + # Added in v0.22, fallback required for backwards compatibility + dictionary.get("reverse", False), ) # If the patch in the repo changes, we cannot get it back, so we diff --git a/lib/spack/spack/test/patch.py b/lib/spack/spack/test/patch.py index e025994cce..c8e9e8ece1 100644 --- a/lib/spack/spack/test/patch.py +++ b/lib/spack/spack/test/patch.py @@ -6,6 +6,7 @@ import collections import filecmp import os +import shutil import sys import pytest @@ -89,7 +90,6 @@ def test_url_patch(mock_patch_stage, filename, sha256, archive_sha256, config): # Make a patch object url = url_util.path_to_file_url(filename) s = Spec("patch").concretized() - patch = spack.patch.UrlPatch(s.package, url, sha256=sha256, archive_sha256=archive_sha256) # make a stage with Stage(url) as stage: # TODO: url isn't used; maybe refactor Stage @@ -105,6 +105,8 @@ first line second line """ ) + # save it for later comparison + shutil.copyfile("foo.txt", "foo-original.txt") # write the expected result of patching. with open("foo-expected.txt", "w") as f: f.write( @@ -115,6 +117,7 @@ third line """ ) # apply the patch and compare files + patch = spack.patch.UrlPatch(s.package, url, sha256=sha256, archive_sha256=archive_sha256) with patch.stage: patch.stage.create() patch.stage.fetch() @@ -124,6 +127,19 @@ third line with working_dir(stage.source_path): assert filecmp.cmp("foo.txt", "foo-expected.txt") + # apply the patch in reverse and compare files + patch = spack.patch.UrlPatch( + s.package, url, sha256=sha256, archive_sha256=archive_sha256, reverse=True + ) + with patch.stage: + patch.stage.create() + patch.stage.fetch() + patch.stage.expand_archive() + patch.apply(stage) + + with working_dir(stage.source_path): + assert filecmp.cmp("foo.txt", "foo-original.txt") + def test_patch_in_spec(mock_packages, config): """Test whether patches in a package appear in the spec.""" @@ -425,6 +441,19 @@ def test_patch_no_file(): patch.apply("") +def test_patch_no_sha256(): + # Give it the attributes we need to construct the error message + FakePackage = collections.namedtuple("FakePackage", ["name", "namespace", "fullname"]) + fp = FakePackage("fake-package", "test", "fake-package") + url = url_util.path_to_file_url("foo.tgz") + match = "Compressed patches require 'archive_sha256' and patch 'sha256' attributes: file://" + with pytest.raises(spack.patch.PatchDirectiveError, match=match): + spack.patch.UrlPatch(fp, url, sha256="", archive_sha256="") + match = "URL patches require a sha256 checksum" + with pytest.raises(spack.patch.PatchDirectiveError, match=match): + spack.patch.UrlPatch(fp, url, sha256="", archive_sha256="abc") + + @pytest.mark.parametrize("level", [-1, 0.0, "1"]) def test_invalid_level(level): # Give it the attributes we need to construct the error message @@ -432,3 +461,41 @@ def test_invalid_level(level): fp = FakePackage("fake-package", "test") with pytest.raises(ValueError, match="Patch level needs to be a non-negative integer."): spack.patch.Patch(fp, "nonexistent_file", level, "") + + +def test_equality(): + FakePackage = collections.namedtuple("FakePackage", ["name", "namespace", "fullname"]) + fp = FakePackage("fake-package", "test", "fake-package") + patch1 = spack.patch.UrlPatch(fp, "nonexistent_url1", sha256="abc") + patch2 = spack.patch.UrlPatch(fp, "nonexistent_url2", sha256="def") + assert patch1 == patch1 + assert patch1 != patch2 + assert patch1 != "not a patch" + + +def test_sha256_setter(mock_patch_stage, config): + path = os.path.join(data_path, "foo.patch") + s = Spec("patch").concretized() + patch = spack.patch.FilePatch(s.package, path, level=1, working_dir=".") + patch.sha256 = "abc" + + +def test_invalid_from_dict(mock_packages, config): + dictionary = {} + with pytest.raises(ValueError, match="Invalid patch dictionary:"): + spack.patch.from_dict(dictionary) + + dictionary = {"owner": "patch"} + with pytest.raises(ValueError, match="Invalid patch dictionary:"): + spack.patch.from_dict(dictionary) + + dictionary = { + "owner": "patch", + "relative_path": "foo.patch", + "level": 1, + "working_dir": ".", + "reverse": False, + "sha256": bar_sha256, + } + with pytest.raises(spack.fetch_strategy.ChecksumError, match="sha256 checksum failed for"): + spack.patch.from_dict(dictionary) |