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:
parent
0f080b38f4
commit
94a1d1414a
3 changed files with 107 additions and 10 deletions
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
Loading…
Reference in a new issue