Skip to content

Commit 0fe97e4

Browse files
authored
Merge pull request #289 from jakub-nt/CFE-4561
CFE-4561: Implemented support for converting modified files into patch files in cfbs-convert
2 parents 3f602d9 + a0a833c commit 0fe97e4

7 files changed

Lines changed: 170 additions & 22 deletions

File tree

JSON.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,10 @@ In `cfbs.json`'s `"steps"`, the build step name must be separated from the rest
281281
- `replace_version <n> <to_replace> <filename>`
282282
- Replace the string inside the file with the version number of that module.
283283
- Works identically to `replace`, except the string to replace with, i.e. the version number, is found automatically.
284+
- `patch <patch_file>`
285+
- Apply a unified diff patch file.
286+
The patches are applied to files in `out/masterfiles` during build.
287+
Paths inside the .patch file should be given relative to `out/masterfiles`.
284288

285289
When `def.json` is modified during a `json`, `input`, `directory`, `bundles`, or `policy_files` build step, the values of some lists of strings are deduplicated, when this does not make any difference in behavior.
286290
These cases are:

cfbs/build.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,12 @@
1313
import os
1414
import logging as log
1515
import shutil
16+
import subprocess
1617
from cfbs.cfbs_config import CFBSConfig
1718
from cfbs.utils import (
19+
CFBSUserError,
1820
canonify,
21+
cli_tool_present,
1922
cp,
2023
cp_dry_overwrites,
2124
deduplicate_def_json,
@@ -126,6 +129,27 @@ def _perform_replacement(n, a, b, filename):
126129
raise CFBSExitError("Failed to write to '%s'" % (filename,))
127130

128131

132+
def _apply_masterfiles_patch(patch_path):
133+
if not cli_tool_present("patch"):
134+
raise CFBSUserError("Working with .patch files requires the 'patch' utility")
135+
136+
if not os.path.isfile(patch_path):
137+
raise CFBSExitError("Patch at path '%s' not found" % patch_path)
138+
139+
patch_path = os.path.relpath(patch_path, "out/masterfiles")
140+
141+
# reasoning for used flags:
142+
# * `-t`: do not interactively ask the user for another path if the patch fails to apply due to the path not being found
143+
# * `-p0`: use paths in the form specified in the .patch files
144+
cmd = "patch -u -t -p0 -i" + patch_path
145+
# the cwd needs to be the base path of the relative paths specified in the .patch files
146+
# currently, the output of the patch command is displayed
147+
cp = subprocess.run(cmd, shell=True, cwd="out/masterfiles")
148+
149+
if cp.returncode != 0:
150+
raise CFBSExitError("Failed to apply patch '%s'" % patch_path)
151+
152+
129153
def _perform_copy_step(args, source, destination, prefix):
130154
src, dst = args
131155
if dst in [".", "./"]:
@@ -358,6 +382,19 @@ def _perform_replace_version_step(module, i, args, name, destination, prefix):
358382
_perform_replacement(n, to_replace, version, filename)
359383

360384

385+
def _perform_patch_step(module, i, args, name, source, prefix):
386+
assert len(args) == 1
387+
388+
patch_relpath = args[0]
389+
print("%s patch '%s'" % (prefix, patch_relpath))
390+
# New build step so let's be a bit strict about validating it:
391+
validate_build_step(name, module, i, "patch", args, strict=True)
392+
393+
patch_path = os.path.join(source, patch_relpath)
394+
395+
_apply_masterfiles_patch(patch_path)
396+
397+
361398
def perform_build(config: CFBSConfig, diffs_filename=None) -> int:
362399
if not config.get("build"):
363400
raise CFBSExitError("No 'build' key found in the configuration")
@@ -430,6 +467,8 @@ def perform_build(config: CFBSConfig, diffs_filename=None) -> int:
430467
_perform_replace_version_step(
431468
module, i, args, name, destination, prefix
432469
)
470+
elif operation == "patch":
471+
_perform_patch_step(module, i, args, name, source, prefix)
433472

434473
if diffs_filename is not None:
435474
try:

cfbs/cfbs_config.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from cfbs.cfbs_types import CFBSCommandGitResult
2525
from cfbs.utils import (
2626
CFBSExitError,
27+
CFBSProgrammerError,
2728
CFBSUserError,
2829
is_a_commit_hash,
2930
read_file,
@@ -264,6 +265,25 @@ def _find_dependencies(self, modules, exclude):
264265
dependencies += self._find_dependencies(dependencies, exclude)
265266
return dependencies
266267

268+
def _module_by_name(self, name):
269+
for module in self["build"]:
270+
if module["name"] == name:
271+
return module
272+
273+
raise CFBSProgrammerError("Module of name %s not found" % name)
274+
275+
def add_patch_step(self, module_name, patch_filepath):
276+
"""Adds a patch step to a module's `"steps"` list, and saves the `CFBSConfig`.
277+
278+
API note: currently only used for a local module.
279+
280+
Error handling:
281+
* excepts when module of `module_name` is not present."""
282+
module = self._module_by_name(module_name)
283+
step = "patch " + patch_filepath
284+
module["steps"].append(step)
285+
self.save()
286+
267287
def _add_policy_files_build_step(self, module):
268288
name = module["name"]
269289
step = "policy_files services/cfbs/" + (

cfbs/commands.py

Lines changed: 78 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,12 @@ def search_command(terms: List[str]):
6969
cfbs_dir,
7070
cfbs_filename,
7171
display_diff,
72+
file_diff_text,
7273
is_cfbs_repo,
74+
mkdir,
7375
read_json,
7476
CFBSExitError,
77+
save_file,
7578
strip_right,
7679
pad_right,
7780
CFBSProgrammerError,
@@ -1127,6 +1130,7 @@ def cfbs_convert_cleanup():
11271130
def cfbs_convert_git_commit(
11281131
commit_message: str, add_scope: Union[str, Iterable[str]] = "all"
11291132
):
1133+
print("Creating Git commit...")
11301134
try:
11311135
git_commit_maybe_prompt(commit_message, non_interactive, scope=add_scope)
11321136
except CFBSGitError:
@@ -1263,7 +1267,6 @@ def cfbs_convert_git_commit(
12631267
for unmodified_mpf_file in analyzed_files.unmodified:
12641268
rm(os.path.join(dir_name, unmodified_mpf_file))
12651269

1266-
print("Creating Git commit...")
12671270
cfbs_convert_git_commit("Deleted unmodified policy files")
12681271

12691272
print(
@@ -1310,9 +1313,6 @@ def cfbs_convert_git_commit(
13101313
for file_d in files_to_delete:
13111314
rm(os.path.join(dir_name, file_d))
13121315

1313-
print(
1314-
"Creating Git commit with deletion of policy files from other versions..."
1315-
)
13161316
cfbs_convert_git_commit("Deleted policy files from other versions")
13171317
print("Done.", end=" ")
13181318
else:
@@ -1322,6 +1322,8 @@ def cfbs_convert_git_commit(
13221322
)
13231323
if not prompt_user_yesno(non_interactive, "Do you want to continue?"):
13241324
raise CFBSExitError("User did not proceed, exiting.")
1325+
1326+
first_patch_conversion = True
13251327
print("The following files have custom modifications:")
13261328
modified_files = analyzed_files.modified
13271329
for modified_file in modified_files:
@@ -1335,6 +1337,7 @@ def cfbs_convert_git_commit(
13351337
mpf_dir_path, masterfiles_version, "tarball", "masterfiles"
13361338
)
13371339
mpf_filepath = os.path.join(mpf_version_dir_path, modified_file)
1340+
modified_file_path = os.path.join(dir_name, modified_file)
13381341
display_diffs = True
13391342
if not os.path.exists(mpf_version_dir_path):
13401343
try:
@@ -1347,7 +1350,7 @@ def cfbs_convert_git_commit(
13471350
display_diffs = False
13481351
if display_diffs:
13491352
try:
1350-
display_diff(mpf_filepath, os.path.join(dir_name, modified_file))
1353+
display_diff(mpf_filepath, modified_file_path)
13511354
except:
13521355
log.warning(
13531356
"Displaying a diff between your file and the default file failed, continuing without displaying a diff..."
@@ -1366,22 +1369,85 @@ def cfbs_convert_git_commit(
13661369
prompt_str = "\nChoose an option:\n"
13671370
prompt_str += "1) Drop modifications - They are not important, or can be achieved in another way.\n"
13681371
prompt_str += "2) Keep modified file - File is kept as is, and can be handled later. Can make future upgrades more complicated.\n"
1369-
prompt_str += "3) (Not implemented yet) Keep patch file - "
1370-
prompt_str += "File is converted into a patch file (diff) that hopefully will apply to future versions as well.\n"
1372+
prompt_str += "3) Keep patch file - File is converted into a patch file (diff) that hopefully will apply to future versions as well.\n"
13711373

1372-
response = prompt_user(non_interactive, prompt_str, ["1", "2"], "1")
1374+
response = prompt_user(non_interactive, prompt_str, ["1", "2", "3"], "1")
13731375

13741376
if response == "1":
13751377
print("Deleting './%s'..." % modified_file)
1376-
rm(os.path.join(dir_name, modified_file))
1377-
commit_message = "Deleted './%s'" % modified_file
1378-
print("Creating Git commit - %s..." % commit_message)
1378+
rm(modified_file_path)
13791379
try:
1380-
cfbs_convert_git_commit(commit_message)
1380+
cfbs_convert_git_commit("Deleted './%s'" % modified_file)
13811381
except:
13821382
log.warning("Git commit failed, continuing without committing...")
13831383
if response == "2":
13841384
print("Keeping file as is, nothing to do.")
1385+
if response == "3":
1386+
print("Converting './%s' into a patch file..." % modified_file)
1387+
patches_dir = "custom-masterfiles-patches"
1388+
patches_module = "./" + patches_dir + "/"
1389+
1390+
file_diff_data = file_diff_text(
1391+
mpf_filepath,
1392+
modified_file_path,
1393+
modified_file,
1394+
modified_file,
1395+
)
1396+
1397+
patch_filename = modified_file.replace("/", "_") + ".patch"
1398+
patch_path = os.path.join(patches_dir, patch_filename)
1399+
# append a number if there are multiple files with the same filename
1400+
i = 1
1401+
while os.path.exists(patch_path):
1402+
patch_filename = (
1403+
modified_file.replace("/", "_") + "-" + str(i) + ".patch"
1404+
)
1405+
patch_path = os.path.join(patches_dir, patch_filename)
1406+
i += 1
1407+
1408+
# saving the .patch file should be done before creating the patches module
1409+
try:
1410+
save_file(patch_path, file_diff_data)
1411+
except:
1412+
log.warning(
1413+
"Saving the patch file failed - keeping the modified file as is instead and continuing..."
1414+
)
1415+
continue
1416+
1417+
# make the patches local module on first use
1418+
if first_patch_conversion:
1419+
print("Adding patches local module...")
1420+
mkdir(patches_dir)
1421+
1422+
config = CFBSConfig.get_instance()
1423+
# `explicit_build_steps=[]` would fail validation, therefore also add the first build step during the module's creation
1424+
config.add_command(
1425+
[patches_module],
1426+
added_by="cfbs convert",
1427+
explicit_build_steps=["patch " + patch_filename],
1428+
)
1429+
config.save()
1430+
else:
1431+
config = CFBSConfig.get_instance()
1432+
config.add_patch_step(patches_module, patch_filename)
1433+
1434+
print("Deleting './%s'..." % modified_file)
1435+
rm(modified_file_path)
1436+
1437+
try:
1438+
if first_patch_conversion:
1439+
cfbs_convert_git_commit(
1440+
"Added patches local module, converted './%s' into a .patch file"
1441+
% modified_file
1442+
)
1443+
else:
1444+
cfbs_convert_git_commit(
1445+
"Converted './%s' into a .patch file" % modified_file
1446+
)
1447+
except:
1448+
log.warning("Git commit failed, continuing without committing...")
1449+
1450+
first_patch_conversion = False
13851451

13861452
print("Conversion finished successfully.")
13871453

cfbs/git.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,15 @@
1515
from typing import Iterable, Union
1616

1717
from cfbs.prompts import prompt_user
18-
from cfbs.utils import CFBSExitError, are_paths_equal
18+
from cfbs.utils import CFBSExitError, are_paths_equal, cli_tool_present
1919

2020

2121
class CFBSGitError(Exception):
2222
pass
2323

2424

2525
def git_exists():
26-
try:
27-
check_call(["git", "--version"], stdout=DEVNULL)
28-
return True
29-
except FileNotFoundError:
30-
return False
26+
return cli_tool_present("git")
3127

3228

3329
def ls_remote(remote, branch):

cfbs/utils.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,17 @@ def sh(cmd: str, directory=None):
103103
return _sh(cmd)
104104

105105

106+
def cli_tool_present(name: str):
107+
"""Returns whether the CLI tool `name` exists on the system."""
108+
try:
109+
subprocess.check_call(
110+
["command -v " + name], stdout=subprocess.DEVNULL, shell=True
111+
)
112+
return True
113+
except subprocess.CalledProcessError:
114+
return False
115+
116+
106117
def display_diff(path_A, path_B):
107118
"""Also displays `stderr`."""
108119
cmd = "diff -u " + path_A + " " + path_B
@@ -112,19 +123,24 @@ def display_diff(path_A, path_B):
112123
raise
113124

114125

115-
def file_diff(filepath_A, filepath_B):
126+
def file_diff(filepath_A, filepath_B, target_A=None, target_B=None):
116127
with open(filepath_A) as f:
117128
lines_A = f.readlines()
118129
with open(filepath_B) as f:
119130
lines_B = f.readlines()
120131

121-
u_diff = difflib.unified_diff(lines_A, lines_B, filepath_A, filepath_B)
132+
if target_A is None:
133+
target_A = filepath_A
134+
if target_B is None:
135+
target_B = filepath_B
136+
137+
u_diff = difflib.unified_diff(lines_A, lines_B, target_A, target_B)
122138

123139
return u_diff
124140

125141

126-
def file_diff_text(filepath_A, filepath_B):
127-
u_diff = file_diff(filepath_A, filepath_B)
142+
def file_diff_text(filepath_A, filepath_B, target_A=None, target_B=None):
143+
u_diff = file_diff(filepath_A, filepath_B, target_A, target_B)
128144
diff_text = "".join(u_diff)
129145

130146
return diff_text

cfbs/validate.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
"bundles": "1+",
5858
"replace": 4, # n, a, b, filename
5959
"replace_version": 3, # n, string to replace, filename
60+
"patch": 1,
6061
}
6162

6263
# Constants / regexes / limits for validating build steps:
@@ -356,6 +357,12 @@ def validate_build_step(name, module, i, operation, args, strict=False):
356357
validate_build_step(
357358
name, module, i, "replace", [n, to_replace, version, filename], strict
358359
)
360+
elif operation == "patch":
361+
# TODO: consider what this should validate (CFE-4607):
362+
# * perhaps check that the patch file exists here instead of in `apply_patch`?
363+
# * what kinds of paths should be legal?
364+
# * more validation?
365+
pass
359366
else:
360367
# TODO: Add more validation of other build steps.
361368
pass

0 commit comments

Comments
 (0)