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>
This commit is contained in:
Adam J. Stewart 2024-03-13 07:22:10 +01:00 committed by GitHub
parent 0f080b38f4
commit 94a1d1414a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 107 additions and 10 deletions

View file

@ -660,6 +660,7 @@ def patch(
level: int = 1, level: int = 1,
when: WhenType = None, when: WhenType = None,
working_dir: str = ".", working_dir: str = ".",
reverse: bool = False,
sha256: Optional[str] = None, sha256: Optional[str] = None,
archive_sha256: Optional[str] = None, archive_sha256: Optional[str] = None,
) -> Patcher: ) -> Patcher:
@ -673,10 +674,10 @@ def patch(
level: patch level (as in the patch shell command) level: patch level (as in the patch shell command)
when: optional anonymous spec that specifies when to apply the patch when: optional anonymous spec that specifies when to apply the patch
working_dir: dir to change to before applying 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) 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 archive_sha256: sha256 sum of the *archive*, if the patch is compressed (only required for
compressed URL patches) compressed URL patches)
""" """
def _execute_patch(pkg_or_dep: Union["spack.package_base.PackageBase", Dependency]): def _execute_patch(pkg_or_dep: Union["spack.package_base.PackageBase", Dependency]):
@ -711,13 +712,14 @@ def _execute_patch(pkg_or_dep: Union["spack.package_base.PackageBase", Dependenc
url_or_filename, url_or_filename,
level, level,
working_dir=working_dir, working_dir=working_dir,
reverse=reverse,
ordering_key=ordering_key, ordering_key=ordering_key,
sha256=sha256, sha256=sha256,
archive_sha256=archive_sha256, archive_sha256=archive_sha256,
) )
else: else:
patch = spack.patch.FilePatch( 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) cur_patches.append(patch)

View file

@ -26,7 +26,11 @@
def apply_patch( 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: ) -> None:
"""Apply the patch at patch_path to code in the stage. """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 patch_path: filesystem location for the patch to apply
level: patch level level: patch level
working_dir: relative path *within* the stage to change to working_dir: relative path *within* the stage to change to
reverse: reverse the patch
""" """
git_utils_path = os.environ.get("PATH", "") git_utils_path = os.environ.get("PATH", "")
if sys.platform == "win32": if sys.platform == "win32":
@ -45,6 +50,10 @@ def apply_patch(
git_root = git_root / "usr" / "bin" git_root = git_root / "usr" / "bin"
git_utils_path = os.pathsep.join([str(git_root), git_utils_path]) 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 # TODO: Decouple Spack's patch support on Windows from Git
# for Windows, and instead have Spack directly fetch, install, and # for Windows, and instead have Spack directly fetch, install, and
# utilize that patch. # utilize that patch.
@ -53,7 +62,7 @@ def apply_patch(
# flag is passed. # flag is passed.
patch = which("patch", required=True, path=git_utils_path) patch = which("patch", required=True, path=git_utils_path)
with llnl.util.filesystem.working_dir(stage.source_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: class Patch:
@ -67,7 +76,12 @@ class Patch:
sha256: str sha256: str
def __init__( 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: ) -> None:
"""Initialize a new Patch instance. """Initialize a new Patch instance.
@ -76,6 +90,7 @@ def __init__(
path_or_url: the relative path or URL to a patch file path_or_url: the relative path or URL to a patch file
level: patch level level: patch level
working_dir: relative path *within* the stage to change to working_dir: relative path *within* the stage to change to
reverse: reverse the patch
""" """
# validate level (must be an integer >= 0) # validate level (must be an integer >= 0)
if not isinstance(level, int) or not level >= 0: if not isinstance(level, int) or not level >= 0:
@ -87,6 +102,7 @@ def __init__(
self.path: Optional[str] = None # must be set before apply() self.path: Optional[str] = None # must be set before apply()
self.level = level self.level = level
self.working_dir = working_dir self.working_dir = working_dir
self.reverse = reverse
def apply(self, stage: "spack.stage.Stage") -> None: def apply(self, stage: "spack.stage.Stage") -> None:
"""Apply a patch to source in a stage. """Apply a patch to source in a stage.
@ -97,7 +113,7 @@ def apply(self, stage: "spack.stage.Stage") -> None:
if not self.path or not os.path.isfile(self.path): if not self.path or not os.path.isfile(self.path):
raise NoSuchPatchError(f"No such patch: {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 # TODO: Use TypedDict once Spack supports Python 3.8+ only
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
@ -111,6 +127,7 @@ def to_dict(self) -> Dict[str, Any]:
"sha256": self.sha256, "sha256": self.sha256,
"level": self.level, "level": self.level,
"working_dir": self.working_dir, "working_dir": self.working_dir,
"reverse": self.reverse,
} }
def __eq__(self, other: object) -> bool: def __eq__(self, other: object) -> bool:
@ -146,6 +163,7 @@ def __init__(
relative_path: str, relative_path: str,
level: int, level: int,
working_dir: str, working_dir: str,
reverse: bool = False,
ordering_key: Optional[Tuple[str, int]] = None, ordering_key: Optional[Tuple[str, int]] = None,
) -> None: ) -> None:
"""Initialize a new FilePatch instance. """Initialize a new FilePatch instance.
@ -155,6 +173,7 @@ def __init__(
relative_path: path to patch, relative to the repository directory for a package. relative_path: path to patch, relative to the repository directory for a package.
level: level to pass to patch command level: level to pass to patch command
working_dir: path within the source directory where patch should be applied 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 ordering_key: key used to ensure patches are applied in a consistent order
""" """
self.relative_path = relative_path self.relative_path = relative_path
@ -182,7 +201,7 @@ def __init__(
msg += "package %s.%s does not exist." % (pkg.namespace, pkg.name) msg += "package %s.%s does not exist." % (pkg.namespace, pkg.name)
raise ValueError(msg) 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.path = abs_path
self.ordering_key = ordering_key self.ordering_key = ordering_key
@ -228,6 +247,7 @@ def __init__(
level: int = 1, level: int = 1,
*, *,
working_dir: str = ".", working_dir: str = ".",
reverse: bool = False,
sha256: str, # This is required for UrlPatch sha256: str, # This is required for UrlPatch
ordering_key: Optional[Tuple[str, int]] = None, ordering_key: Optional[Tuple[str, int]] = None,
archive_sha256: Optional[str] = None, archive_sha256: Optional[str] = None,
@ -239,12 +259,13 @@ def __init__(
url: URL where the patch can be fetched url: URL where the patch can be fetched
level: level to pass to patch command level: level to pass to patch command
working_dir: path within the source directory where patch should be applied 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 ordering_key: key used to ensure patches are applied in a consistent order
sha256: sha256 sum of the patch, used to verify the patch sha256: sha256 sum of the patch, used to verify the patch
archive_sha256: sha256 sum of the *archive*, if the patch is compressed archive_sha256: sha256 sum of the *archive*, if the patch is compressed
(only required for compressed URL patches) (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.url = url
self._stage: Optional["spack.stage.Stage"] = None self._stage: Optional["spack.stage.Stage"] = None
@ -350,13 +371,20 @@ def from_dict(
dictionary["url"], dictionary["url"],
dictionary["level"], dictionary["level"],
working_dir=dictionary["working_dir"], working_dir=dictionary["working_dir"],
# Added in v0.22, fallback required for backwards compatibility
reverse=dictionary.get("reverse", False),
sha256=dictionary["sha256"], sha256=dictionary["sha256"],
archive_sha256=dictionary.get("archive_sha256"), archive_sha256=dictionary.get("archive_sha256"),
) )
elif "relative_path" in dictionary: elif "relative_path" in dictionary:
patch = FilePatch( 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 # If the patch in the repo changes, we cannot get it back, so we

View file

@ -6,6 +6,7 @@
import collections import collections
import filecmp import filecmp
import os import os
import shutil
import sys import sys
import pytest import pytest
@ -89,7 +90,6 @@ def test_url_patch(mock_patch_stage, filename, sha256, archive_sha256, config):
# Make a patch object # Make a patch object
url = url_util.path_to_file_url(filename) url = url_util.path_to_file_url(filename)
s = Spec("patch").concretized() s = Spec("patch").concretized()
patch = spack.patch.UrlPatch(s.package, url, sha256=sha256, archive_sha256=archive_sha256)
# make a stage # make a stage
with Stage(url) as stage: # TODO: url isn't used; maybe refactor Stage with Stage(url) as stage: # TODO: url isn't used; maybe refactor Stage
@ -105,6 +105,8 @@ def test_url_patch(mock_patch_stage, filename, sha256, archive_sha256, config):
second line second line
""" """
) )
# save it for later comparison
shutil.copyfile("foo.txt", "foo-original.txt")
# write the expected result of patching. # write the expected result of patching.
with open("foo-expected.txt", "w") as f: with open("foo-expected.txt", "w") as f:
f.write( f.write(
@ -115,6 +117,7 @@ def test_url_patch(mock_patch_stage, filename, sha256, archive_sha256, config):
""" """
) )
# apply the patch and compare files # apply the patch and compare files
patch = spack.patch.UrlPatch(s.package, url, sha256=sha256, archive_sha256=archive_sha256)
with patch.stage: with patch.stage:
patch.stage.create() patch.stage.create()
patch.stage.fetch() patch.stage.fetch()
@ -124,6 +127,19 @@ def test_url_patch(mock_patch_stage, filename, sha256, archive_sha256, config):
with working_dir(stage.source_path): with working_dir(stage.source_path):
assert filecmp.cmp("foo.txt", "foo-expected.txt") 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): def test_patch_in_spec(mock_packages, config):
"""Test whether patches in a package appear in the spec.""" """Test whether patches in a package appear in the spec."""
@ -425,6 +441,19 @@ def test_patch_no_file():
patch.apply("") 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"]) @pytest.mark.parametrize("level", [-1, 0.0, "1"])
def test_invalid_level(level): def test_invalid_level(level):
# Give it the attributes we need to construct the error message # 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") fp = FakePackage("fake-package", "test")
with pytest.raises(ValueError, match="Patch level needs to be a non-negative integer."): with pytest.raises(ValueError, match="Patch level needs to be a non-negative integer."):
spack.patch.Patch(fp, "nonexistent_file", level, "") 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)