Coverage for rust2rpm/patching.py: 53%

75 statements  

« prev     ^ index     » next       coverage.py v7.6.7, created at 2024-11-26 22:50 +0100

1"""Module containing functionality for patching Cargo.toml files.""" 

2 

3import shutil 

4import subprocess 

5import tempfile 

6import time 

7from datetime import UTC, datetime 

8from difflib import unified_diff 

9from pathlib import Path 

10 

11from rust2rpm import log 

12from rust2rpm.cli import Options 

13from rust2rpm.utils import detect_editor 

14 

15 

16class PatchError(ValueError): 

17 """Raised when re-applying an existing patch fails.""" 

18 

19 

20def file_mtime_in_iso_format(path: Path) -> str: 

21 """Format file modification timestamp in the format expected by patch files.""" 

22 mtime = path.stat().st_mtime 

23 return datetime.fromtimestamp(mtime, UTC).isoformat() 

24 

25 

26def make_diff_from_lines( 

27 diff_path: str, 

28 lines_before: list[str], 

29 mtime_before: str, 

30 lines_after: list[str], 

31 mtime_after: str, 

32) -> list[str]: 

33 """Format a patch file based on the "before" and "after" states. 

34 

35 Arguments: 

36 diff_path: Path of the patched file. 

37 lines_before: File contents (as list of lines) before changes were applied. 

38 mtime_before: File modification timestamp in the expected format. 

39 lines_after: File contents (as list of lines) after changes were applied. 

40 mtime_after: File modification timestamp in the expected format. 

41 

42 Returns: 

43 Patch in "unified diff" format (as list of lines). 

44 

45 """ 

46 return list( 

47 unified_diff( 

48 lines_before, 

49 lines_after, 

50 fromfile=diff_path, 

51 tofile=diff_path, 

52 fromfiledate=mtime_before, 

53 tofiledate=mtime_after, 

54 lineterm="", 

55 ), 

56 ) 

57 

58 

59def preprocess_cargo_toml(contents: str) -> str | None: 

60 """Automatically pre-process contents of a Cargo.toml file. 

61 

62 Arguments: 

63 contents: Original contents of the Cargo.toml file. 

64 

65 Returns: 

66 Modified Cargo.toml contents or `None` if no modifications were made. 

67 

68 """ 

69 if shutil.which("rust2rpm-helper"): 69 ↛ 72line 69 didn't jump to line 72 because the condition on line 69 was always true

70 return preprocess_cargo_toml_helper(contents) 

71 

72 log.warn("rust2rpm-helper is not installed. Skipping automatic patching of Cargo.toml.") 

73 return None 

74 

75 

76def preprocess_cargo_toml_helper(contents: str) -> str | None: 

77 """Call rust2rpm-helper executable to pre-process a Cargo.toml file. 

78 

79 Arguments: 

80 contents: Original contents of the Cargo.toml file. 

81 

82 Returns: 

83 Modified Cargo.toml contents or `None` if no modifications were made. 

84 

85 """ 

86 # S603: rust2rpm-helper validates input itself 

87 # S607: allowing use of "rust2rpm-helper" from $PATH is intentional 

88 

89 ret1 = subprocess.run( # noqa: S603 

90 ["rust2rpm-helper", "normalize-version", "-"], # noqa: S607 

91 input=contents, 

92 text=True, 

93 check=True, 

94 stdout=subprocess.PIPE, 

95 ) 

96 patched1 = ret1.stdout or contents 

97 

98 ret2 = subprocess.run( # noqa: S603 

99 ["rust2rpm-helper", "strip-foreign", "-"], # noqa: S607 

100 input=patched1, 

101 text=True, 

102 check=True, 

103 stdout=subprocess.PIPE, 

104 ) 

105 patched2 = ret2.stdout or patched1 

106 

107 if patched2 == contents: 

108 return None 

109 

110 return patched2 

111 

112 

113def reapply_patch(toml_path: Path, reapply_path: Path) -> bool: 

114 """Attempt to re-apply an existing patch file. 

115 

116 Arguments: 

117 toml_path: Path to the Cargo.toml file. 

118 reapply_path: Path to the patch file. 

119 

120 Returns: 

121 Boolean flag indicating whether the patch applied successfully. 

122 

123 """ 

124 patch = shutil.which("patch") 

125 

126 if patch is None: 

127 msg = "No 'patch' executable found." 

128 raise PatchError(msg) 

129 

130 if not reapply_path.exists(): 

131 msg = "No applicable patch file found." 

132 raise PatchError(msg) 

133 

134 # copy file to a temporary directory to work around weird behaviour of "patch" 

135 with tempfile.TemporaryDirectory() as temp_dir: 

136 temp_toml = Path(temp_dir) / toml_path.name 

137 shutil.copy2(toml_path, temp_toml) 

138 

139 try: 

140 subprocess.run( # noqa: S603 

141 [patch, "-p1", str(temp_toml), str(reapply_path)], 

142 check=True, 

143 capture_output=True, 

144 ) 

145 except subprocess.CalledProcessError: 

146 return False 

147 else: 

148 shutil.copy2(temp_toml, toml_path) 

149 return True 

150 

151 

152def make_patches( 

153 name: str, 

154 version: str, 

155 toml_path: Path, 

156 reapply_path: Path | None, 

157 options: Options, 

158) -> tuple[list[str] | None, list[str] | None]: 

159 """Prepare patches for Cargo.toml (automatic and manual changes). 

160 

161 Any automatically applied changes to Cargo.toml are returned as the first 

162 patch. Manual changes to Cargo.toml are either based on changes made 

163 interactively in the opened editor, or based on changes from a successfully 

164 re-applied existing patch, or both. 

165 

166 Arguments: 

167 name: Name of the crate (used to construct the file path in the patch). 

168 version: Version of the crate (used to construct the file path in the patch). 

169 toml_path: Path to the Cargo.toml file that will be patched. 

170 reapply_path: Optional path to an existing patch file that is attempted 

171 to be reapplied. 

172 options: Options and flags derived from command-line arguments. 

173 

174 Returns: 

175 Tuple of (automatic, manual) patch file contents as lists of lines, 

176 or `None` in case no changes were made. 

177 

178 """ 

179 mtime_before = file_mtime_in_iso_format(toml_path) 

180 

181 with toml_path.open() as file: 

182 toml_before = file.read() 

183 

184 diff1 = diff2 = None 

185 diff_path = f"{name}-{version}/Cargo.toml" 

186 

187 # patching Cargo.toml involves multiple steps: 

188 # 

189 # 1) attempt to automatically drop "foreign" dependencies 

190 # If this succeeded, remove the original Cargo.toml file, write the 

191 # changed contents back to disk, and generate a diff. 

192 # 2) if requested, open Cargo.toml in an editor for further, manual changes 

193 # 

194 # The changes from *both* steps must be reflected in the Cargo.toml file 

195 # that ends up on disk after this function returns. Otherwise, the 

196 # calculated metadata and generated spec file will not reflect the patches 

197 # to Cargo.toml that were generated here. 

198 

199 if toml_after := options.patch_foreign and preprocess_cargo_toml(toml_before): 

200 # remove original Cargo.toml file 

201 toml_path.unlink() 

202 

203 # write auto-patched Cargo.toml to disk 

204 with toml_path.open("w") as file: 

205 file.write(toml_after) 

206 

207 mtime_after1 = file_mtime_in_iso_format(toml_path) 

208 diff1 = make_diff_from_lines( 

209 diff_path, 

210 toml_before.splitlines(), 

211 mtime_before, 

212 toml_after.splitlines(), 

213 mtime_after1, 

214 ) 

215 else: 

216 toml_after = toml_before 

217 

218 # early exit 

219 if not (options.patch or reapply_path): 219 ↛ 223line 219 didn't jump to line 223 because the condition on line 219 was always true

220 return diff1, diff2 

221 

222 # attempt to reapply existing patch 

223 if reapply_path and not reapply_patch(toml_path, reapply_path): 

224 if options.patch: 

225 log.warn("Re-applying existing patch failed, opening editor instead.") 

226 # sleep so the user has an actual chance to see this log message 

227 time.sleep(2) 

228 else: 

229 msg = "Failed to re-apply existing patch." 

230 raise PatchError(msg) 

231 

232 # open editor for Cargo.toml 

233 if options.patch: 

234 editor = detect_editor() 

235 subprocess.check_call([editor, toml_path]) # noqa: S603 

236 

237 with toml_path.open() as file: 

238 toml_after2 = file.read() 

239 

240 mtime_after2 = file_mtime_in_iso_format(toml_path) 

241 diff2 = make_diff_from_lines( 

242 diff_path, 

243 toml_after.splitlines(), 

244 mtime_before, 

245 toml_after2.splitlines(), 

246 mtime_after2, 

247 ) 

248 

249 return diff1, diff2