Skip to content

Support options for patch command #4886

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions easybuild/framework/easyblock.py
Original file line number Diff line number Diff line change
Expand Up @@ -2892,9 +2892,10 @@ def patch_step(self, beginpath=None, patches=None):
srcpathsuffix = patch.get('sourcepath', patch.get('copy', ''))
# determine whether 'patch' file should be copied rather than applied
copy_patch = 'copy' in patch and 'sourcepath' not in patch
options = patch.get('opts', None) # Extra options for patch command

self.log.debug("Source index: %s; patch level: %s; source path suffix: %s; copy patch: %s",
srcind, level, srcpathsuffix, copy_patch)
self.log.debug("Source index: %s; patch level: %s; source path suffix: %s; copy patch: %s; options: %s",
srcind, level, srcpathsuffix, copy_patch, options)

if beginpath is None:
try:
Expand All @@ -2910,7 +2911,7 @@ def patch_step(self, beginpath=None, patches=None):
src = os.path.abspath(weld_paths(beginpath, srcpathsuffix))
self.log.debug("Applying patch %s in path %s", patch, src)

apply_patch(patch['path'], src, copy=copy_patch, level=level)
apply_patch(patch['path'], src, copy=copy_patch, level=level, options=options)

def prepare_step(self, start_dir=True, load_tc_deps_modules=True):
"""
Expand Down
21 changes: 11 additions & 10 deletions easybuild/tools/filetools.py
Original file line number Diff line number Diff line change
Expand Up @@ -1554,10 +1554,10 @@ def create_patch_info(patch_spec):
Create info dictionary from specified patch spec.
"""
# Valid keys that can be used in a patch spec dict
valid_keys = ['name', 'copy', 'level', 'sourcepath', 'alt_location']
valid_keys = ['name', 'copy', 'level', 'sourcepath', 'alt_location', 'opts']

if isinstance(patch_spec, (list, tuple)):
if not len(patch_spec) == 2:
if len(patch_spec) != 2:
error_msg = "Unknown patch specification '%s', only 2-element lists/tuples are supported!"
raise EasyBuildError(error_msg, str(patch_spec))

Expand Down Expand Up @@ -1598,18 +1598,16 @@ def create_patch_info(patch_spec):
)

# Dict must contain at least the patchfile name
if 'name' not in patch_info.keys():
if 'name' not in patch_info:
raise EasyBuildError(
"Wrong patch spec '%s', when using a dict 'name' entry must be supplied", str(patch_spec),
exit_code=EasyBuildExit.EASYCONFIG_ERROR
)
if 'copy' not in patch_info.keys():
if 'copy' not in patch_info:
validate_patch_spec(patch_info['name'])
else:
if 'sourcepath' in patch_info.keys() or 'level' in patch_info.keys():
raise EasyBuildError("Wrong patch spec '%s', you can't use 'sourcepath' or 'level' with 'copy' (since "
"this implies you want to copy a file to the 'copy' location)",
str(patch_spec))
elif 'sourcepath' in patch_info or 'level' in patch_info:
raise EasyBuildError(f"Wrong patch spec '{patch_spec}', you can't use 'sourcepath' or 'level' with 'copy' "
"(since this implies you want to copy a file to the 'copy' location)")
else:
error_msg = (
"Wrong patch spec, should be string, 2-tuple with patch name + argument, or a dict "
Expand All @@ -1629,10 +1627,11 @@ def validate_patch_spec(patch_spec):
)


def apply_patch(patch_file, dest, fn=None, copy=False, level=None, use_git=False):
def apply_patch(patch_file, dest, fn=None, copy=False, level=None, use_git=False, options=None):
"""
Apply a patch to source code in directory dest
- assume unified diff created with "diff -ru old new"
- options are additional CLI options to pass to patch command

Raises EasyBuildError on any error and returns True on success
"""
Expand Down Expand Up @@ -1712,6 +1711,8 @@ def apply_patch(patch_file, dest, fn=None, copy=False, level=None, use_git=False
backup_option = '-b ' if build_option('backup_patched_files') else ''
patch_cmd = f"patch {backup_option} -p{level} -i {abs_patch_file}"

if options:
patch_cmd += f' {options}'
res = run_shell_cmd(patch_cmd, fail_on_error=False, hidden=True, work_dir=abs_dest)

if res.exit_code:
Expand Down
32 changes: 23 additions & 9 deletions test/framework/easyblock.py
Original file line number Diff line number Diff line change
Expand Up @@ -2470,26 +2470,26 @@ def test_exclude_path_to_top_of_module_tree(self):
def test_patch_step(self):
"""Test patch step."""
test_easyconfigs = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'easyconfigs', 'test_ecs')
ec = process_easyconfig(os.path.join(test_easyconfigs, 't', 'toy', 'toy-0.0.eb'))[0]
orig_sources = ec['ec']['sources'][:]
ec = process_easyconfig(os.path.join(test_easyconfigs, 't', 'toy', 'toy-0.0.eb'))[0]['ec']
orig_sources = ec['sources'][:]

toy_patches = [
'toy-0.0_fix-silly-typo-in-printf-statement.patch', # test for applying patch
('toy-extra.txt', 'toy-0.0'), # test for patch-by-copy
]
self.assertEqual(ec['ec']['patches'], toy_patches)
self.assertEqual(ec['patches'], toy_patches)

# test applying patches without sources
ec['ec']['sources'] = []
eb = EasyBlock(ec['ec'])
ec['sources'] = []
eb = EasyBlock(ec)
with self.mocked_stdout_stderr():
eb.fetch_step()
eb.extract_step()
self.assertErrorRegex(EasyBuildError, '.*', eb.patch_step)

# test actual patching of unpacked sources
ec['ec']['sources'] = orig_sources
eb = EasyBlock(ec['ec'])
ec['sources'] = orig_sources
eb = EasyBlock(ec)
with self.mocked_stdout_stderr():
eb.fetch_step()
eb.extract_step()
Expand All @@ -2502,18 +2502,32 @@ def test_patch_step(self):

# check again with backup of patched files enabled
update_build_option('backup_patched_files', True)
eb = EasyBlock(ec['ec'])
eb = EasyBlock(ec)
with self.mocked_stdout_stderr():
eb.fetch_step()
eb.extract_step()
eb.patch_step()
# verify that patches were applied
toydir = os.path.join(eb.builddir, 'toy-0.0')
backup_file = os.path.join(toydir, 'toy.source.orig')
self.assertEqual(sorted(os.listdir(toydir)), ['toy-extra.txt', 'toy.source', 'toy.source.orig'])
self.assertIn("and very proud of it", read_file(os.path.join(toydir, 'toy.source')))
self.assertNotIn("and very proud of it", read_file(os.path.join(toydir, 'toy.source.orig')))
self.assertNotIn("and very proud of it", read_file(backup_file))
self.assertEqual(read_file(os.path.join(toydir, 'toy-extra.txt')), 'moar!\n')

# Check with options set
update_build_option('backup_patched_files', False)
ec['patches'] = [{'name': toy_patches[0], 'opts': '--backup --quiet'}]
eb = EasyBlock(ec)
with self.mocked_stdout_stderr():
eb.fetch_step()
eb.extract_step()
remove_file(backup_file)
eb.patch_step()
# Backup created by manual option
self.assertExists(backup_file)
self.assertRegex(read_file(eb.logfile), fr'patch .*{toy_patches[0]} .*--backup --quiet')

def test_extensions_sanity_check(self):
"""Test sanity check aspect of extensions."""
init_config(build_options={'silent': True})
Expand Down
6 changes: 6 additions & 0 deletions test/framework/filetools.py
Original file line number Diff line number Diff line change
Expand Up @@ -1894,6 +1894,12 @@ def test_apply_patch(self):
# trying the patch again should fail
self.assertErrorRegex(EasyBuildError, "Couldn't apply patch file", ft.apply_patch, toy_patch, path)

# Passing an option works
with self.mocked_stdout_stderr():
ft.apply_patch(toy_patch_gz, path, options=' --reverse')
# Change was really removed
self.assertNotIn(pattern, ft.read_file(os.path.join(path, 'toy-0.0', 'toy.source')))

# test copying of files, both to an existing directory and a non-existing location
test_file = os.path.join(self.test_prefix, 'foo.txt')
ft.write_file(test_file, '123')
Expand Down