Coverage for rust2rpm/conf/ini.py: 92%

188 statements  

« prev     ^ index     » next       coverage.py v7.6.7, created at 2024-11-25 18:52 +0100

1"""Module containing functionality for the deprecated INI-based configuration format.""" 

2 

3import sys 

4from configparser import ConfigParser, ExtendedInterpolation, SectionProxy 

5from dataclasses import dataclass 

6from pathlib import Path 

7from typing import Any 

8 

9from rust2rpm import log 

10from rust2rpm.sysinfo import TARGET_NAMES 

11 

12from .common import ConfError 

13from .toml import TomlConf 

14 

15VALID_INI_KEYS = [ 

16 "summary", 

17 "supported-arches", 

18 "all-features", 

19 "unwanted-features", 

20 "enabled-features", 

21 "buildrequires", 

22 "testrequires", 

23 "lib.requires", 

24 "bin.requires", 

25] 

26 

27 

28def to_list(s: str, *, sort: bool = True) -> list[str]: 

29 """Parse a multi-line value into a list of strings.""" 

30 f = sorted if sort else list 

31 return f(filter(None, (elem.strip() for elem in s.splitlines()))) 

32 

33 

34def _validate_ini_conf_with_features(merged: SectionProxy, validate_features: set[str]): 

35 # validate configuration file 

36 valid_keys = VALID_INI_KEYS.copy() 

37 

38 # the "default" feature is always implicitly defined 

39 valid_keys.append("lib+default.requires") 

40 valid_keys.extend(f"lib+{feature}.requires" for feature in validate_features) 

41 

42 # check setting keys 

43 for key in merged: 

44 if key not in valid_keys: 44 ↛ 45line 44 didn't jump to line 45 because the condition on line 44 was never true

45 msg = f"Invalid key: {key!r}" 

46 raise ConfError(msg) 

47 

48 # check settings values 

49 if unwanted_features := merged.get("unwanted-features"): 

50 unwanted_features = to_list(unwanted_features) 

51 for unwanted_feature in unwanted_features: 

52 if unwanted_feature not in validate_features: 52 ↛ 53line 52 didn't jump to line 53 because the condition on line 52 was never true

53 msg = f'Unrecognized "unwanted" feature: {unwanted_feature!r}' 

54 raise ConfError(msg) 

55 

56 if enabled_features := merged.get("enabled-features"): 

57 enabled_features = to_list(enabled_features) 

58 for enabled_feature in enabled_features: 

59 if enabled_feature not in validate_features: 59 ↛ 60line 59 didn't jump to line 60 because the condition on line 59 was never true

60 msg = f'Unrecognized "enabled" feature: {enabled_feature!r}' 

61 raise ConfError(msg) 

62 

63 

64def _validate_ini_conf_without_features(merged: SectionProxy) -> set[str]: 

65 # validate configuration file 

66 valid_keys = VALID_INI_KEYS.copy() 

67 

68 # determine features that are mentioned in the configuration file 

69 mentioned_features = set() 

70 for key in merged: 

71 if key.startswith("lib+") and key.endswith(".requires"): 

72 mentioned_features.add(key.removeprefix("lib+").removesuffix(".requires")) 

73 

74 # check setting keys 

75 for key in merged: 

76 # cannot validate against actual crate features: 

77 # just validate against *mentioned* features and check for valid syntax 

78 if key not in valid_keys: 

79 if key in [f"lib+{feature}.requires" for feature in mentioned_features]: 79 ↛ 81line 79 didn't jump to line 81 because the condition on line 79 was always true

80 continue 

81 msg = f"Invalid key: {key!r}" 

82 raise ConfError(msg) 

83 

84 return mentioned_features 

85 

86 

87@dataclass(frozen=True) 

88class IniConf: 

89 """rust2rpm configuration loaded from the deprecated rust2rpm.conf file format.""" 

90 

91 summary: str | None = None 

92 """Override for the generated RPM Summary tag.""" 

93 

94 supported_arches: list[str] | None = None 

95 """List of architectures supported by the package. 

96 

97 For crates, this is used to conditionally run `%build` and `%check` scriptlets. 

98 For other projects, it is used as the value of an `ExclusiveArch` tag. 

99 """ 

100 

101 all_features: bool | None = None 

102 """Flag that controls whether the `--all-features` flag is passed to cargo.""" 

103 

104 unwanted_features: list[str] | None = None 

105 """List of features for which the generation of the corresponding subpackage is suppressed.""" 

106 

107 enabled_features: list[str] | None = None 

108 """List of features that are enabled and passed with `--features` to cargo.""" 

109 

110 buildrequires: list[str] | None = None 

111 """List of additional `BuildRequires`.""" 

112 

113 testrequires: list[str] | None = None 

114 """List of additional `BuildRequires` that only apply when building the package with tests.""" 

115 

116 bin_requires: list[str] | None = None 

117 """List of additional `Requires` for the built binary subpackage.""" 

118 

119 lib_requires: dict[str | None, list[str]] | None = None 

120 """List of additional `Requires` for built library subpackages.""" 

121 

122 @staticmethod 

123 def _from_configparser(merged: SectionProxy, validate_features: set[str] | None) -> "IniConf": 

124 # validate configuration 

125 if validate_features is not None: 

126 _validate_ini_conf_with_features(merged, validate_features) 

127 known_features = validate_features 

128 else: 

129 mentioned_features = _validate_ini_conf_without_features(merged) 

130 known_features = mentioned_features 

131 

132 # parse configuration 

133 summary = value if (value := merged.get("summary")) else None 

134 supported_arches = to_list(value, sort=False) if (value := merged.get("supported-arches")) else None 

135 

136 all_features = value if (value := merged.getboolean("all-features")) and value is not None else None 

137 unwanted_features = to_list(value) if (value := merged.get("unwanted-features")) else None 

138 enabled_features = to_list(value) if (value := merged.get("enabled-features")) else None 

139 

140 buildrequires = to_list(value) if (value := merged.get("buildrequires")) else None 

141 testrequires = to_list(value) if (value := merged.get("testrequires")) else None 

142 bin_requires = to_list(value) if (value := merged.get("bin.requires")) else None 

143 

144 lib_requires: dict[str | None, list[str]] | None = None 

145 if value := merged.get("lib.requires"): 

146 lib_requires = {} 

147 lib_requires[None] = to_list(value) # type: ignore[index] 

148 

149 for feature in known_features: 

150 if value := merged.get(f"lib+{feature}.requires"): 

151 if lib_requires is None: 

152 lib_requires = {} 

153 lib_requires[feature] = to_list(value) # type: ignore[index] 

154 

155 return IniConf( 

156 summary, 

157 supported_arches, 

158 all_features, 

159 unwanted_features, 

160 enabled_features, 

161 buildrequires, 

162 testrequires, 

163 bin_requires, 

164 lib_requires, 

165 ) 

166 

167 @staticmethod 

168 def load(filenames: list[str], target: str, validate_features: set[str] | None) -> "IniConf": 

169 """Load and validate rust2rpm configuration for the given target. 

170 

171 Arguments: 

172 filenames: List of file paths that are attempted to be read. 

173 target: Name of the spec file generation target. 

174 validate_features: Optional set of valid feature names. 

175 Used to validate some settings. 

176 

177 Returns: 

178 Loaded and validated rust2rpm configuration. 

179 

180 Raises: 

181 `FileNotFoundError` when none of the expected file paths existed. 

182 `FileExistsError` when more than one config file was found. 

183 `ConfError` if the configuration file failed validation. 

184 

185 """ 

186 conf = ConfigParser(interpolation=ExtendedInterpolation()) 

187 confs = conf.read(filenames) 

188 

189 if len(confs) == 0: 

190 raise FileNotFoundError 

191 

192 if len(confs) > 1: # pragma nocover 

193 raise FileExistsError 

194 

195 # clean up configuration files with deprecated names 

196 if ".rust2rpm.conf" in confs: # pragma nocover 

197 Path(".rust2rpm.conf").rename("rust2rpm.conf") 

198 log.info("Renamed deprecated, hidden .rust2rpm.conf file to rust2rpm.conf.") 

199 

200 if "_rust2rpm.conf" in confs: # pragma nocover 

201 Path("_rust2rpm.conf").rename("rust2rpm.conf") 

202 log.info("Renamed deprecated _rust2rpm.conf file to rust2rpm.conf.") 

203 

204 # merge target-specific configuration with default configuration 

205 if target not in conf: 

206 conf.add_section(target) 

207 merged = conf[target] 

208 

209 # check section names 

210 for section in conf: 

211 if section != "DEFAULT" and section not in TARGET_NAMES: 211 ↛ 212line 211 didn't jump to line 212 because the condition on line 211 was never true

212 msg = f"Invalid section: {section!r}" 

213 raise ConfError(msg) 

214 

215 return IniConf._from_configparser(merged, validate_features) 

216 

217 def upgrade(self) -> TomlConf: # noqa: PLR0912 

218 """Upgrade from old to new configuration file format.""" 

219 settings: dict[str, Any] = {} 

220 

221 package: dict[str, Any] = {} 

222 if self.summary: 

223 package["summary"] = self.summary 

224 if self.supported_arches: 

225 package["supported-arches"] = self.supported_arches 

226 if package: 

227 settings["package"] = package 

228 

229 features: dict[str, Any] = {} 

230 if self.all_features is not None: 

231 features["enable-all"] = self.all_features 

232 if self.enabled_features: 

233 features["enable"] = self.enabled_features 

234 if self.unwanted_features: 

235 features["hide"] = self.unwanted_features 

236 if features: 

237 settings["features"] = features 

238 

239 requires: dict[str, Any] = {} 

240 if self.buildrequires: 

241 requires["build"] = self.buildrequires 

242 if self.testrequires: 

243 requires["test"] = self.testrequires 

244 if self.bin_requires: 

245 requires["bin"] = self.bin_requires 

246 if self.lib_requires: 

247 for key, value in self.lib_requires.items(): 

248 if key is None: 

249 requires["lib"] = value 

250 else: 

251 if "features" not in requires: 

252 requires["features"] = {} 

253 requires["features"][key] = value 

254 if requires: 

255 settings["requires"] = requires 

256 

257 # feature names were already validated 

258 return TomlConf.from_data(settings, None) 

259 

260 def migrate(self, validate_features: set[str] | None) -> str: 

261 """Migrate from old to new configuration file format. 

262 

263 Arguments: 

264 validate_features: Optional set of valid feature names. 

265 Used to validate some settings. 

266 

267 Returns: 

268 Configuration in rust2rpm.toml format equivalent to the 

269 settings in the original config file. 

270 

271 """ 

272 # check if the written config would be valid 

273 old = self.upgrade() 

274 

275 lines = [] 

276 

277 def write_array(name: str, values: list[str] | None): 

278 if values is None: 

279 return 

280 lines.append(f"{name} = [") 

281 lines.extend([f' "{value}",' for value in values]) 

282 lines.append("]") 

283 

284 if self.summary or self.supported_arches: 

285 lines.append("[package]") 

286 if summary := self.summary: 

287 lines.append(f'summary = "{summary}"') 

288 write_array("supported-arches", self.supported_arches) 

289 lines.append("") 

290 

291 if self.all_features or self.unwanted_features or self.enabled_features: 

292 lines.append("[features]") 

293 if self.all_features is True: 

294 lines.append("enable-all = true") 

295 write_array("enable", self.enabled_features) 

296 write_array("hide", self.unwanted_features) 

297 lines.append("") 

298 

299 if self.buildrequires or self.testrequires or self.bin_requires or self.lib_requires: 

300 lines.append("[requires]") 

301 write_array("build", self.buildrequires) 

302 write_array("test", self.testrequires) 

303 write_array("bin", self.bin_requires) 

304 if lib_requires := self.lib_requires: 

305 write_array("lib", lib_requires.pop(None, None)) 

306 for feature, feature_deps in sorted(lib_requires.items()): 

307 write_array(f"features.{feature}", feature_deps) 

308 lines.append("") 

309 

310 toml = "\n".join(lines) 

311 

312 new = TomlConf.from_str(toml, validate_features) 

313 

314 # check if contents are equivalent 

315 if old != new: # pragma nocover: no known failure cases 

316 msg = "Failed to convert rust2rpm.conf to rust2rpm.toml automatically." 

317 raise ConfError(msg) 

318 

319 return toml 

320 

321 

322def load_config_ini(paths: list[str], target: str, validate_features: set[str] | None = None) -> IniConf | None: 

323 """Load rust2rpm configuration from the deprecated rust2rpm.conf file format. 

324 

325 Arguments: 

326 paths: List of accepted file paths (at most one of them should point to 

327 an existing file). 

328 target: Spec file generation target. 

329 validate_features: Optional set of valid feature names. 

330 Used to validate some settings. 

331 

332 Returns: 

333 Configuration loaded from a file in INI format, or `None` if no file was found. 

334 

335 Raises: 

336 `SystemExit` on fatal errors. 

337 

338 """ 

339 try: 

340 distconf = IniConf.load(paths, target, validate_features) 

341 

342 except FileNotFoundError: 

343 return None 

344 

345 except FileExistsError: 

346 log.error( 

347 "More than one *rust2rpm.conf file is present in this directory. " 

348 "Ensure that there is only one, and that it has the correct contents.", 

349 ) 

350 sys.exit(1) 

351 

352 except ConfError as exc: 

353 log.error("Invalid rust2rpm configuration file:") 

354 log.error(str(exc)) 

355 sys.exit(1) 

356 

357 else: 

358 return distconf