Coverage for rust2rpm/patching.py: 22%

105 statements  

« prev     ^ index     » next       coverage.py v7.6.4, created at 2024-10-27 15:21 +0100

1import ast 

2from datetime import datetime, timezone 

3from difflib import unified_diff 

4import re 

5import os 

6import shutil 

7import subprocess 

8from typing import Optional 

9 

10from rust2rpm import cfg, log 

11from rust2rpm.utils import detect_editor 

12 

13 

14# [target.'cfg(not(any(target_os="windows", target_os="macos")))'.dependencies] 

15# [target."cfg(windows)".dependencies.winapi] 

16# [target."cfg(target_arch = \"wasm32\")".dev-dependencies.wasm-bindgen-test] 

17 

18TARGET_DEPENDENCY_LINE = re.compile( 

19 r""" 

20 ^ \[ target\.(?P<cfg>(?P<quote>['"])cfg\(.*\)(?P=quote)) 

21 \. 

22 (?P<type> dependencies|build-dependencies|dev-dependencies) 

23 (?:\. (?P<feature>[a-zA-Z0-9_-]+) )? 

24 ] \s* $ 

25 """, 

26 re.VERBOSE, 

27) 

28 

29 

30def filter_out_features_re(dropped_features: set[str]) -> re.Pattern: 

31 # This is a bit simplistic. But it doesn't seem worth the trouble to write 

32 # a grammar for this. Can be always done later. If we parse this using a 

33 # grammar, we beget the question of how to preserve formatting idiosyncrasies. 

34 # Regexp replacement makes it trivial to minimize changes. 

35 match_features = "|".join(dropped_features) 

36 match_suffix = f"(?:/[{cfg.IDENT_CHARS[1]}]+)?" 

37 

38 return re.compile( 

39 rf""" 

40 (?P<comma> ,)? \s* (?P<quote>['"]) 

41 ({match_features}) {match_suffix} 

42 (?P=quote) \s* (?(comma) |,?) \s* 

43 """, 

44 re.VERBOSE, 

45 ) 

46 

47 

48def file_mtime_in_iso_format(path: str) -> str: 

49 mtime = os.stat(path).st_mtime 

50 return datetime.fromtimestamp(mtime, timezone.utc).isoformat() 

51 

52 

53def make_diff_from_lines( 

54 diff_path: str, lines_before: list[str], mtime_before: str, lines_after: list[str], mtime_after: str 

55) -> list[str]: 

56 return list( 

57 unified_diff( 

58 lines_before, 

59 lines_after, 

60 fromfile=diff_path, 

61 tofile=diff_path, 

62 fromfiledate=mtime_before, 

63 tofiledate=mtime_after, 

64 lineterm="", 

65 ) 

66 ) 

67 

68 

69def preprocess_cargo_toml(contents: str, features: set[str]) -> Optional[str]: 

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

71 return preprocess_cargo_toml_helper(contents) 

72 else: 

73 log.warn( 

74 "Falling back to broken built-in implementation for stripping non-applicable " 

75 + "target-specific depdendencies: rust2rpm-helper is not installed" 

76 ) 

77 return preprocess_cargo_toml_fallback(contents, features) 

78 

79 

80def preprocess_cargo_toml_helper(contents: str) -> Optional[str]: 

81 ret1 = subprocess.run( 

82 ["rust2rpm-helper", "normalize-version", "-"], input=contents, text=True, stdout=subprocess.PIPE 

83 ) 

84 ret1.check_returncode() 

85 patched1 = ret1.stdout or contents 

86 

87 ret2 = subprocess.run(["rust2rpm-helper", "strip-foreign", "-"], input=patched1, text=True, stdout=subprocess.PIPE) 

88 ret2.check_returncode() 

89 patched2 = ret2.stdout or patched1 

90 

91 if patched2 == contents: 

92 return None 

93 else: 

94 return patched2 

95 

96 

97def preprocess_cargo_toml_fallback(contents: str, features: set[str]) -> Optional[str]: 

98 lines = contents.splitlines() 

99 

100 kept_lines = [] 

101 dropped_lines = 0 

102 dropped_optional_deps = set() 

103 

104 keep = True 

105 feature = None 

106 

107 for line in lines: 

108 if m := TARGET_DEPENDENCY_LINE.match(line): 

109 expr = m.group("cfg") 

110 expr = ast.literal_eval(expr) 

111 

112 try: 

113 keep = cfg.parse_and_evaluate(expr) 

114 except (ValueError, cfg.ParseException): 

115 log.warn(f"Could not evaluate {expr!r}, treating as true.") 

116 keep = True 

117 

118 if not keep: 

119 feature = m.group("feature") 

120 log.info(f"Dropping target-specific dependency on {feature!r}.") 

121 

122 elif line == "optional = true\n" and feature: 

123 if not keep: 

124 # dropped feature was optional: 

125 # remove occurrences from feature dependencies 

126 if feature in features: 

127 dropped_optional_deps.add(feature) 

128 else: 

129 dropped_optional_deps.add(f"dep:{feature}") 

130 

131 else: 

132 # optional dependency occurs in multiple targets: 

133 # do not drop from feature dependencies 

134 if feature in dropped_optional_deps: 

135 dropped_optional_deps.remove(feature) 

136 if f"dep:{feature}" in dropped_optional_deps: 

137 dropped_optional_deps.remove(f"dep:{feature}") 

138 

139 elif line.startswith("["): 

140 # previous section ended, let's keep printing lines again 

141 keep = True 

142 

143 if keep: 

144 kept_lines.append(line) 

145 else: 

146 dropped_lines += 1 

147 

148 if not dropped_lines: 

149 # nothing to do, let's bail out 

150 return None 

151 

152 output_lines = [] 

153 in_features = False 

154 feature_filter = filter_out_features_re(dropped_optional_deps) 

155 

156 for line in kept_lines: 

157 if line.rstrip() == "[features]": 

158 in_features = True 

159 elif line.startswith("["): 

160 in_features = False 

161 elif in_features: 

162 line = re.sub(feature_filter, "", line) 

163 if not line: 

164 continue 

165 

166 output_lines += [line] 

167 

168 return "\n".join(output_lines) 

169 

170 

171def make_patches( 

172 name: str, version: str, patch: bool, patch_foreign: bool, toml_path: str, features: set[str] 

173) -> tuple[Optional[list[str]], Optional[list[str]]]: 

174 """Returns up to two patches (automatic and manual). 

175 

176 For the manual patch, an editor is spawned to open the file at `toml_path` 

177 and a diff is made after the editor returns. 

178 """ 

179 

180 mtime_before = file_mtime_in_iso_format(toml_path) 

181 

182 with open(toml_path) as file: 

183 toml_before = file.read() 

184 

185 diff1 = diff2 = None 

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

187 

188 # patching Cargo.toml involves multiple steps: 

189 # 

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

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

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

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

194 # 

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

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

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

198 # to Cargo.toml that were generated here. 

199 

200 if toml_after := patch_foreign and preprocess_cargo_toml(toml_before, features): 

201 # remove original Cargo.toml file 

202 os.remove(toml_path) 

203 

204 # write auto-patched Cargo.toml to disk 

205 with open(toml_path, "w") as file: 

206 file.write(toml_after) 

207 

208 mtime_after1 = file_mtime_in_iso_format(toml_path) 

209 diff1 = make_diff_from_lines( 

210 diff_path, toml_before.splitlines(), mtime_before, toml_after.splitlines(), mtime_after1 

211 ) 

212 else: 

213 toml_after = toml_before 

214 

215 if patch: 

216 # open editor for Cargo.toml 

217 editor = detect_editor() 

218 subprocess.check_call([editor, toml_path]) 

219 

220 with open(toml_path) as file: 

221 toml_after2 = file.read() 

222 

223 mtime_after2 = file_mtime_in_iso_format(toml_path) 

224 diff2 = make_diff_from_lines( 

225 diff_path, toml_after.splitlines(), mtime_before, toml_after2.splitlines(), mtime_after2 

226 ) 

227 

228 return diff1, diff2