patches: make re-applied patches idempotent (#26784)
We use POSIX `patch` to apply patches to files when building, but `patch` by default prompts the user when it looks like a patch has already been applied. This means that: 1. If a patch lands in upstream and we don't disable it in a package, the build will start failing. 2. `spack develop` builds (which keep the stage around) will fail the second time you try to use them. To avoid that, we can run `patch` with `-N` (also called `--forward`, but the long option is not in POSIX). `-N` causes `patch` to just ignore patches that have already been applied. This *almost* makes `patch` idempotent, except that it returns 1 when it detects already applied patches with `-N`, so we have to look at the output of the command to see if it's safe to ignore the error. - [x] Remove non-POSIX `-s` option from `patch` call - [x] Add `-N` option to `patch` - [x] Ignore error status when `patch` returns 1 due to `-N` - [x] Add tests for applying a patch twice and applying a bad patch - [x] Tweak `spack.util.executable` so that it saves the error that *would have been* raised with `fail_on_error=True`. This lets us easily re-raise it. Co-authored-by: Greg Becker <becker33@llnl.gov>
This commit is contained in:
parent
a118e799ae
commit
c5ca0db27f
@ -7,6 +7,7 @@
|
|||||||
import inspect
|
import inspect
|
||||||
import os
|
import os
|
||||||
import os.path
|
import os.path
|
||||||
|
import sys
|
||||||
|
|
||||||
import llnl.util.filesystem
|
import llnl.util.filesystem
|
||||||
import llnl.util.lang
|
import llnl.util.lang
|
||||||
@ -25,6 +26,12 @@
|
|||||||
def apply_patch(stage, patch_path, level=1, working_dir='.'):
|
def apply_patch(stage, patch_path, level=1, working_dir='.'):
|
||||||
"""Apply the patch at patch_path to code in the stage.
|
"""Apply the patch at patch_path to code in the stage.
|
||||||
|
|
||||||
|
Spack runs ``patch`` with ``-N`` so that it does not reject already-applied
|
||||||
|
patches. This is useful for develop specs, so that the build does not fail
|
||||||
|
due to repeated application of patches, and for easing requirements on patch
|
||||||
|
specifications in packages -- packages won't stop working when patches we
|
||||||
|
previously had to apply land in upstream.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
stage (spack.stage.Stage): stage with code that will be patched
|
stage (spack.stage.Stage): stage with code that will be patched
|
||||||
patch_path (str): filesystem location for the patch to apply
|
patch_path (str): filesystem location for the patch to apply
|
||||||
@ -34,10 +41,31 @@ def apply_patch(stage, patch_path, level=1, working_dir='.'):
|
|||||||
"""
|
"""
|
||||||
patch = which("patch", required=True)
|
patch = which("patch", required=True)
|
||||||
with llnl.util.filesystem.working_dir(stage.source_path):
|
with llnl.util.filesystem.working_dir(stage.source_path):
|
||||||
patch('-s',
|
output = patch(
|
||||||
'-p', str(level),
|
'-N', # don't reject already-applied patches
|
||||||
'-i', patch_path,
|
'-p', str(level), # patch level (directory depth)
|
||||||
'-d', working_dir)
|
'-i', patch_path, # input source is the patch file
|
||||||
|
'-d', working_dir, # patch chdir's to here before patching
|
||||||
|
output=str,
|
||||||
|
fail_on_error=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if patch.returncode != 0:
|
||||||
|
# `patch` returns 1 both:
|
||||||
|
# a) when an error applying a patch, and
|
||||||
|
# b) when -N is supplied and the patch has already been applied
|
||||||
|
#
|
||||||
|
# It returns > 1 if there's something more serious wrong.
|
||||||
|
#
|
||||||
|
# So, the best we can do is to look for return code 1, look for output
|
||||||
|
# indicating that the patch was already applied, and ignore the error
|
||||||
|
# if we see it. Most implementations (BSD and GNU) seem to have the
|
||||||
|
# same messages, so we expect these checks to be reliable.
|
||||||
|
if patch.returncode > 1 or not any(
|
||||||
|
s in output for s in ("Skipping patch", "ignored")
|
||||||
|
):
|
||||||
|
sys.stderr.write(output)
|
||||||
|
raise patch.error
|
||||||
|
|
||||||
|
|
||||||
class Patch(object):
|
class Patch(object):
|
||||||
|
@ -15,8 +15,9 @@
|
|||||||
import spack.paths
|
import spack.paths
|
||||||
import spack.repo
|
import spack.repo
|
||||||
import spack.util.compression
|
import spack.util.compression
|
||||||
|
import spack.util.crypto
|
||||||
from spack.spec import Spec
|
from spack.spec import Spec
|
||||||
from spack.stage import Stage
|
from spack.stage import DIYStage, Stage
|
||||||
from spack.util.executable import Executable
|
from spack.util.executable import Executable
|
||||||
|
|
||||||
# various sha256 sums (using variables for legibility)
|
# various sha256 sums (using variables for legibility)
|
||||||
@ -33,6 +34,43 @@
|
|||||||
url2_archive_sha256 = 'abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd'
|
url2_archive_sha256 = 'abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd'
|
||||||
|
|
||||||
|
|
||||||
|
# some simple files for patch tests
|
||||||
|
file_to_patch = """\
|
||||||
|
first line
|
||||||
|
second line
|
||||||
|
"""
|
||||||
|
|
||||||
|
patch_file = """\
|
||||||
|
diff a/foo.txt b/foo-expected.txt
|
||||||
|
--- a/foo.txt
|
||||||
|
+++ b/foo-expected.txt
|
||||||
|
@@ -1,2 +1,3 @@
|
||||||
|
+zeroth line
|
||||||
|
first line
|
||||||
|
-second line
|
||||||
|
+third line
|
||||||
|
"""
|
||||||
|
|
||||||
|
expected_patch_result = """\
|
||||||
|
zeroth line
|
||||||
|
first line
|
||||||
|
third line
|
||||||
|
"""
|
||||||
|
|
||||||
|
file_patch_cant_apply_to = """\
|
||||||
|
this file
|
||||||
|
is completely different
|
||||||
|
from anything in the files
|
||||||
|
or patch above
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def write_file(filename, contents):
|
||||||
|
"""Helper function for setting up tests."""
|
||||||
|
with open(filename, 'w') as f:
|
||||||
|
f.write(contents)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def mock_patch_stage(tmpdir_factory, monkeypatch):
|
def mock_patch_stage(tmpdir_factory, monkeypatch):
|
||||||
# Don't disrupt the spack install directory with tests.
|
# Don't disrupt the spack install directory with tests.
|
||||||
@ -67,19 +105,9 @@ def test_url_patch(mock_patch_stage, filename, sha256, archive_sha256):
|
|||||||
|
|
||||||
mkdirp(stage.source_path)
|
mkdirp(stage.source_path)
|
||||||
with working_dir(stage.source_path):
|
with working_dir(stage.source_path):
|
||||||
# write a file to be patched
|
write_file("foo.txt", file_to_patch)
|
||||||
with open('foo.txt', 'w') as f:
|
write_file("foo-expected.txt", expected_patch_result)
|
||||||
f.write("""\
|
|
||||||
first line
|
|
||||||
second line
|
|
||||||
""")
|
|
||||||
# write the expected result of patching.
|
|
||||||
with open('foo-expected.txt', 'w') as f:
|
|
||||||
f.write("""\
|
|
||||||
zeroth line
|
|
||||||
first line
|
|
||||||
third line
|
|
||||||
""")
|
|
||||||
# apply the patch and compare files
|
# apply the patch and compare files
|
||||||
patch.fetch()
|
patch.fetch()
|
||||||
patch.apply(stage)
|
patch.apply(stage)
|
||||||
@ -89,6 +117,47 @@ def test_url_patch(mock_patch_stage, filename, sha256, archive_sha256):
|
|||||||
assert filecmp.cmp('foo.txt', 'foo-expected.txt')
|
assert filecmp.cmp('foo.txt', 'foo-expected.txt')
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_patch_twice(mock_patch_stage, tmpdir):
|
||||||
|
"""Ensure that patch doesn't fail if applied twice."""
|
||||||
|
|
||||||
|
stage = DIYStage(str(tmpdir))
|
||||||
|
with tmpdir.as_cwd():
|
||||||
|
write_file("foo.txt", file_to_patch)
|
||||||
|
write_file("foo-expected.txt", expected_patch_result)
|
||||||
|
write_file("foo.patch", patch_file)
|
||||||
|
|
||||||
|
FakePackage = collections.namedtuple(
|
||||||
|
'FakePackage', ['name', 'namespace', 'fullname'])
|
||||||
|
fake_pkg = FakePackage('fake-package', 'test', 'fake-package')
|
||||||
|
|
||||||
|
def make_patch(filename):
|
||||||
|
path = os.path.realpath(str(tmpdir.join(filename)))
|
||||||
|
url = 'file://' + path
|
||||||
|
sha256 = spack.util.crypto.checksum("sha256", path)
|
||||||
|
return spack.patch.UrlPatch(fake_pkg, url, sha256=sha256)
|
||||||
|
|
||||||
|
# apply the first time
|
||||||
|
patch = make_patch('foo.patch')
|
||||||
|
patch.fetch()
|
||||||
|
|
||||||
|
patch.apply(stage)
|
||||||
|
with working_dir(stage.source_path):
|
||||||
|
assert filecmp.cmp('foo.txt', 'foo-expected.txt')
|
||||||
|
|
||||||
|
# ensure apply() is idempotent
|
||||||
|
patch.apply(stage)
|
||||||
|
with working_dir(stage.source_path):
|
||||||
|
assert filecmp.cmp('foo.txt', 'foo-expected.txt')
|
||||||
|
|
||||||
|
# now write a file that can't be patched
|
||||||
|
with working_dir(stage.source_path):
|
||||||
|
write_file("foo.txt", file_patch_cant_apply_to)
|
||||||
|
|
||||||
|
# this application should fail with a real error
|
||||||
|
with pytest.raises(spack.util.executable.ProcessError):
|
||||||
|
patch.apply(stage)
|
||||||
|
|
||||||
|
|
||||||
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."""
|
||||||
spec = Spec('patch')
|
spec = Spec('patch')
|
||||||
|
@ -92,6 +92,11 @@ def checksum(hashlib_algo, filename, **kwargs):
|
|||||||
"""Returns a hex digest of the filename generated using an
|
"""Returns a hex digest of the filename generated using an
|
||||||
algorithm from hashlib.
|
algorithm from hashlib.
|
||||||
"""
|
"""
|
||||||
|
if isinstance(hashlib_algo, str):
|
||||||
|
if hashlib_algo not in hashes:
|
||||||
|
raise ValueError("Invalid hash algorithm: ", hashlib_algo)
|
||||||
|
hashlib_algo = hash_fun_for_algo(hashlib_algo)
|
||||||
|
|
||||||
block_size = kwargs.get('block_size', 2**20)
|
block_size = kwargs.get('block_size', 2**20)
|
||||||
hasher = hashlib_algo()
|
hasher = hashlib_algo()
|
||||||
with open(filename, 'rb') as file:
|
with open(filename, 'rb') as file:
|
||||||
|
@ -27,6 +27,7 @@ def __init__(self, name):
|
|||||||
from spack.util.environment import EnvironmentModifications # no cycle
|
from spack.util.environment import EnvironmentModifications # no cycle
|
||||||
self.default_envmod = EnvironmentModifications()
|
self.default_envmod = EnvironmentModifications()
|
||||||
self.returncode = None
|
self.returncode = None
|
||||||
|
self.error = None # saved ProcessError when fail_on_error
|
||||||
|
|
||||||
if not self.exe:
|
if not self.exe:
|
||||||
raise ProcessError("Cannot construct executable for '%s'" % name)
|
raise ProcessError("Cannot construct executable for '%s'" % name)
|
||||||
@ -90,7 +91,8 @@ def __call__(self, *args, **kwargs):
|
|||||||
the environment (neither requires nor precludes env)
|
the environment (neither requires nor precludes env)
|
||||||
fail_on_error (bool): Raise an exception if the subprocess returns
|
fail_on_error (bool): Raise an exception if the subprocess returns
|
||||||
an error. Default is True. The return code is available as
|
an error. Default is True. The return code is available as
|
||||||
``exe.returncode``
|
``exe.returncode``, and a saved ``ProcessError`` that would
|
||||||
|
have been raised is in ``exe.error``.
|
||||||
ignore_errors (int or list): A list of error codes to ignore.
|
ignore_errors (int or list): A list of error codes to ignore.
|
||||||
If these codes are returned, this process will not raise
|
If these codes are returned, this process will not raise
|
||||||
an exception even if ``fail_on_error`` is set to ``True``
|
an exception even if ``fail_on_error`` is set to ``True``
|
||||||
@ -213,7 +215,7 @@ def streamify(arg, mode):
|
|||||||
sys.stderr.write(errstr)
|
sys.stderr.write(errstr)
|
||||||
|
|
||||||
rc = self.returncode = proc.returncode
|
rc = self.returncode = proc.returncode
|
||||||
if fail_on_error and rc != 0 and (rc not in ignore_errors):
|
if rc != 0:
|
||||||
long_msg = cmd_line_string
|
long_msg = cmd_line_string
|
||||||
if result:
|
if result:
|
||||||
# If the output is not captured in the result, it will have
|
# If the output is not captured in the result, it will have
|
||||||
@ -222,8 +224,11 @@ def streamify(arg, mode):
|
|||||||
# stdout/stderr (e.g. if 'output' is not specified)
|
# stdout/stderr (e.g. if 'output' is not specified)
|
||||||
long_msg += '\n' + result
|
long_msg += '\n' + result
|
||||||
|
|
||||||
raise ProcessError('Command exited with status %d:' %
|
self.error = ProcessError(
|
||||||
proc.returncode, long_msg)
|
'Command exited with status %d:' % proc.returncode, long_msg
|
||||||
|
)
|
||||||
|
if fail_on_error and (rc not in ignore_errors):
|
||||||
|
raise self.error
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@ -232,10 +237,15 @@ def streamify(arg, mode):
|
|||||||
'%s: %s' % (self.exe[0], e.strerror), 'Command: ' + cmd_line_string)
|
'%s: %s' % (self.exe[0], e.strerror), 'Command: ' + cmd_line_string)
|
||||||
|
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
|
self.error = ProcessError(
|
||||||
|
str(e),
|
||||||
|
'\nExit status %d when invoking command: %s' % (
|
||||||
|
proc.returncode,
|
||||||
|
cmd_line_string,
|
||||||
|
),
|
||||||
|
)
|
||||||
if fail_on_error:
|
if fail_on_error:
|
||||||
raise ProcessError(
|
raise self.error
|
||||||
str(e), '\nExit status %d when invoking command: %s' %
|
|
||||||
(proc.returncode, cmd_line_string))
|
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
if close_ostream:
|
if close_ostream:
|
||||||
|
Loading…
Reference in New Issue
Block a user