Coverage for rust2rpm/conf/toml.py: 85%

295 statements  

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

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

2 

3import json 

4import sys 

5import textwrap 

6import tomllib 

7from collections import defaultdict 

8from dataclasses import dataclass, field 

9from importlib import resources 

10from pathlib import Path 

11 

12import jsonschema 

13 

14from rust2rpm import log 

15 

16from .common import ConfError 

17 

18TOML_SCHEMA = json.loads( 

19 resources.files("rust2rpm").joinpath("rust2rpm.schema.json").read_text(), 

20) 

21 

22 

23def conf_comments_to_spec_comments(comments: list[str] | None) -> list[str]: 

24 """Format list of strings as pretty spec file comments. 

25 

26 Any string in the list of comments that contains a newline character 

27 is treated as pre-formatted and no line-wrapping is applied to it. 

28 

29 Arguments: 

30 comments: Comments as list of strings. 

31 

32 Returns: 

33 Pre-formatted comments as list of lines. 

34 

35 """ 

36 if not comments: 

37 return [] 

38 

39 lines = [] 

40 for comment in comments: 

41 # single-line comments: wrap to 80 columns 

42 if "\n" not in comment.strip(): 

43 lines.extend( 

44 textwrap.wrap( 

45 comment, 

46 width=80, 

47 initial_indent="# * ", 

48 subsequent_indent="# ", 

49 break_long_words=False, 

50 break_on_hyphens=False, 

51 ), 

52 ) 

53 

54 # multi-line comments: assume pre-formatted 

55 else: 

56 first, *rest = (line.strip() for line in comment.splitlines()) 

57 lines.append(f"# * {first}") 

58 lines.extend([f"# {line}" for line in rest]) 

59 

60 return lines 

61 

62 

63@dataclass(frozen=True) 

64class Source: 

65 """Properties of extra Source files.""" 

66 

67 file: str 

68 number: int 

69 comments: list[str] = field(default_factory=list) 

70 

71 @staticmethod 

72 def from_data(data: dict) -> "Source": 

73 """Initialize from TOML data directly.""" 

74 return Source(**data) 

75 

76 @property 

77 def comment_lines(self) -> list[str]: 

78 """Format comments as spec comments.""" 

79 return conf_comments_to_spec_comments(self.comments) 

80 

81 @property 

82 def whitespace(self) -> str: 

83 """Whitespace between "SourceX:" tag and the "file name".""" 

84 return " " * (16 - (len("Source") + len(str(self.number)) + 1)) 

85 

86 

87@dataclass(frozen=True) 

88class Patch: 

89 """Properties of extra Patch files.""" 

90 

91 file: str 

92 number: int 

93 comments: list[str] = field(default_factory=list) 

94 

95 @staticmethod 

96 def from_data(data: dict) -> "Patch": 

97 """Initialize from TOML data directly.""" 

98 return Patch(**data) 

99 

100 @property 

101 def comment_lines(self) -> list[str]: 

102 """Format comments as spec comments.""" 

103 return conf_comments_to_spec_comments(self.comments) 

104 

105 @property 

106 def whitespace(self) -> str: 

107 """Whitespace between "PatchX:" tag and the "file name".""" 

108 return " " * (16 - (len("Patch") + len(str(self.number)) + 1)) 

109 

110 

111@dataclass(frozen=True) 

112class FileInEx: 

113 """File inclusion and exclusion rules.""" 

114 

115 include: list[str] | None = None 

116 exclude: list[str] | None = None 

117 

118 

119@dataclass(frozen=True) 

120class Package: 

121 """Collection of package-specific settings.""" 

122 

123 summary: str | None = None 

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

125 

126 description: str | None = None 

127 """Override for the generated RPM %description.""" 

128 

129 url: str | None = None 

130 """Override for the generated RPM URL tag.""" 

131 

132 source_url: str | None = None 

133 """Override for the RPM Source tag.""" 

134 

135 supported_arches: list[str] | None = None 

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

137 

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

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

140 """ 

141 

142 suppress_cdylib_install_fixme: bool | None = None 

143 """Flag that controls injection of the "FIXME" comment for packages that contain "cdylib" targets.""" 

144 

145 bin_package_name: str | None = None 

146 """Override for the generated binary subpackage name.""" 

147 

148 cargo_install_bin: bool | None = None 

149 """Flag that controls injection of the `%cargo_install_bin` macro and its value.""" 

150 

151 cargo_install_lib: bool | None = None 

152 """Flag that controls injection of the `%cargo_install_lib` macro and its value.""" 

153 

154 debuginfo_level: int | None = None 

155 """Flag that controls injection of the `%rustflags_debuginfo` macro and its value.""" 

156 

157 cargo_toml_patch_comments: list[str] = field(default_factory=list) 

158 """List of comments associated with manually applied changes to Cargo.toml.""" 

159 

160 license_files: list[str] | FileInEx = field(default_factory=FileInEx) 

161 """Settings for overriding the results of crawling the project sources for license files.""" 

162 

163 doc_files: list[str] | FileInEx = field(default_factory=FileInEx) 

164 """Settings for overriding the results of crawling the project sources for documentation files.""" 

165 

166 extra_sources: list[Source] = field(default_factory=list) 

167 """Settings for including additional `Source` files.""" 

168 

169 extra_patches: list[Patch] = field(default_factory=list) 

170 """Settings for including additional `Patch` files.""" 

171 

172 extra_files: list[str] = field(default_factory=list) 

173 """Setting for including additional files in the `%files` list of the built binary subpackage.""" 

174 

175 exclude_crate_files: list[str] = field(default_factory=list) 

176 """Setting for excluding files from the `%files` list of the subpackage that contains the crate sources.""" 

177 

178 bin_renames: dict[str, str] = field(default_factory=dict) 

179 """Setting for renaming executables, usually to avoid file conflicts with other packages.""" 

180 

181 @staticmethod 

182 def from_data(data: dict) -> "Package": 

183 """Initialize from TOML data directly.""" 

184 args = {key.replace("-", "_"): value for key, value in data.items()} 

185 

186 if extra_sources := data.get("extra-sources"): 

187 args["extra_sources"] = [Source.from_data(source) for source in extra_sources] 

188 

189 if extra_patches := data.get("extra-patches"): 

190 args["extra_patches"] = [Patch.from_data(patch) for patch in extra_patches] 

191 

192 if lf_obj := data.get("license-files"): 

193 args["license_files"] = FileInEx(**lf_obj) if isinstance(lf_obj, dict) else lf_obj 

194 

195 if df_obj := data.get("doc-files"): 

196 args["doc_files"] = FileInEx(**df_obj) if isinstance(df_obj, dict) else df_obj 

197 

198 return Package(**args) 

199 

200 @property 

201 def cargo_toml_patch_comment_lines(self) -> list[str]: 

202 """Format comments as spec comments.""" 

203 return conf_comments_to_spec_comments(self.cargo_toml_patch_comments) 

204 

205 

206@dataclass(frozen=True) 

207class PrePostScripts: 

208 """Collection of extra commands for an RPM package scriptlet.""" 

209 

210 pre: list[str] = field(default_factory=list) 

211 """List of additional commands that are injected before the `%cargo_*` macro in this scriptlet.""" 

212 

213 post: list[str] = field(default_factory=list) 

214 """List of additional commands that are injected after the `%cargo_*` macro in this scriptlet.""" 

215 

216 

217@dataclass(frozen=True) 

218class Scripts: 

219 """Collection of extra commands for RPM package scriptlets.""" 

220 

221 prep: PrePostScripts = field(default_factory=PrePostScripts) 

222 """Additional commands for the `%prep` scriptlet.""" 

223 

224 build: PrePostScripts = field(default_factory=PrePostScripts) 

225 """Additional commands for the `%build` scriptlet.""" 

226 

227 install: PrePostScripts = field(default_factory=PrePostScripts) 

228 """Additional commands for the `%install` scriptlet.""" 

229 

230 check: PrePostScripts = field(default_factory=PrePostScripts) 

231 """Additional commands for the `%check` scriptlet.""" 

232 

233 @staticmethod 

234 def from_data(data: dict) -> "Scripts": 

235 """Initialize from TOML data directly.""" 

236 prep = PrePostScripts(**value) if (value := data.get("prep")) else PrePostScripts() 

237 build = PrePostScripts(**value) if (value := data.get("build")) else PrePostScripts() 

238 install = PrePostScripts(**value) if (value := data.get("install")) else PrePostScripts() 

239 check = PrePostScripts(**value) if (value := data.get("check")) else PrePostScripts() 

240 

241 return Scripts(prep, build, install, check) 

242 

243 

244@dataclass(frozen=True) 

245class TestsSkip: 

246 """Collections of names that are included as `--skip` arguments in `%cargo_test` macro arguments.""" 

247 

248 lib: list[str] = field(default_factory=list) 

249 bin: list[str] = field(default_factory=list) 

250 doc: list[str] = field(default_factory=list) 

251 bins: list[str] = field(default_factory=list) 

252 tests: list[str] = field(default_factory=list) 

253 

254 

255@dataclass(frozen=True) 

256class TestsSkipExact: 

257 """Collection of flags that control the inclusion of the `--exact` flag in `%cargo_test` macro arguments.""" 

258 

259 lib: bool = False 

260 bin: bool = False 

261 doc: bool = False 

262 bins: bool = False 

263 tests: bool = False 

264 

265 

266@dataclass(frozen=True) 

267class TestsComments: 

268 """Collections of strings that are included as pre-formatted comments for `%cargo_test` macro invocations.""" 

269 

270 lib: list[str] = field(default_factory=list) 

271 bin: list[str] = field(default_factory=list) 

272 doc: list[str] = field(default_factory=list) 

273 bins: list[str] = field(default_factory=list) 

274 tests: list[str] = field(default_factory=list) 

275 

276 

277@dataclass(frozen=True) 

278class Tests: 

279 """Collection of test-specific settings.""" 

280 

281 run: str | list[str] = field(default_factory=list) 

282 """Setting that controls which kinds of cargo tests are run.""" 

283 

284 skip: list[str] | TestsSkip = field(default_factory=list) 

285 """Setting that controls which tests are skipped.""" 

286 

287 skip_exact: bool | TestsSkipExact = False 

288 """Setting that controls whether exact matches or substring matches are used for determining skipped tests.""" 

289 

290 comments: list[str] | TestsComments = field(default_factory=list) 

291 """Collections of strings that are included as pre-formatted comments.""" 

292 

293 @staticmethod 

294 def from_data(data: dict) -> "Tests": 

295 """Initialize from TOML data directly.""" 

296 args = {key.replace("-", "_"): value for key, value in data.items()} 

297 

298 # validate the "tests.run" setting: 

299 # both "all" and "none" cannot be combimed with other values 

300 if (tests_run := data.get("run")) and isinstance(tests_run, list): 

301 if ("all" in tests_run or "none" in tests_run) and len(tests_run) != 1: 

302 msg = f"Invalid set of tests to run: {tests_run!r}" 

303 raise ConfError(msg) 

304 else: 

305 tests_run = [] 

306 

307 if (tests_skip := data.get("skip")) and isinstance(tests_skip, dict): 307 ↛ 308line 307 didn't jump to line 308 because the condition on line 307 was never true

308 args["skip"] = TestsSkip(**tests_skip) 

309 

310 # validate the "tests.skip" setting: 

311 # skipping tests for targets that are not separately run has no effect 

312 for key in tests_skip: 

313 if key not in tests_run: 

314 log.warn( 

315 f"Skipping tests for {key!r} targets only has no effect if they are not run separately.", 

316 ) 

317 

318 if (tests_skip_exact := data.get("skip-exact")) and isinstance(tests_skip_exact, dict): 318 ↛ 319line 318 didn't jump to line 319 because the condition on line 318 was never true

319 args["skip_exact"] = TestsSkipExact(**tests_skip_exact) 

320 

321 # validate the "tests.skip-exact" setting: 

322 # exact test name matching for targets that are not separately run has no effect 

323 for key in tests_skip_exact: 

324 if key not in tests_run: 

325 log.warn( 

326 f"Using exact test name matches for {key!r} targets only has no effect " 

327 "if they are not run separately.", 

328 ) 

329 

330 if (tests_comments := data.get("comments")) and isinstance(tests_comments, dict): 330 ↛ 331line 330 didn't jump to line 331 because the condition on line 330 was never true

331 args["comments"] = TestsComments(**tests_comments) 

332 

333 # validate the "tests.comments" setting: 

334 # specifying comments for test targets that are not separately run has no effect 

335 for key in tests_comments: 

336 if key not in tests_run: 

337 log.warn( 

338 f"Specifying comments for {key!r} target tests only has no effect " 

339 "if they are not run separately.", 

340 ) 

341 

342 return Tests(**args) 

343 

344 

345@dataclass(frozen=True) 

346class Features: 

347 """Collection of feature-specific settings.""" 

348 

349 enable_all: bool = False 

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

351 

352 enable: list[str] = field(default_factory=list) 

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

354 

355 disable_default: bool = False 

356 """Flag that controls whether the `--no-default-features` flag is passed to cargo.""" 

357 

358 hide: list[str] = field(default_factory=list) 

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

360 

361 @staticmethod 

362 def from_data(data: dict, validate_features: set[str] | None = None) -> "Features": 

363 """Initialize from TOML data directly.""" 

364 # validate the "features.enable-all" and "features.disable-default" settings: 

365 # both cannot be enabled at the same time 

366 if data.get("enable-all") is True and data.get("disable-default") is True: 

367 msg = "Conflicting settings for features: 'enable-all' and 'disable-default'" 

368 raise ConfError(msg) 

369 

370 if validate_features is not None: 

371 # validate the "features.enable" setting: 

372 # list elements must be valid feature names 

373 if enable_list := data.get("enable"): 

374 for enabled in enable_list: 

375 if enabled not in validate_features: 

376 msg = f"Unrecognized enabled feature: {enabled}" 

377 raise ConfError(msg) 

378 

379 # validate the "features.hide" setting: 

380 # list elements must be valid feature names 

381 if hide_list := data.get("hide"): 

382 for hidden in hide_list: 

383 if hidden not in validate_features: 

384 msg = f"Unrecognized hidden feature: {hidden}" 

385 raise ConfError(msg) 

386 

387 # warn when conflicting settings are used 

388 if (data.get("enable-all") is True) and (data.get("hide") is not None and len(data["hide"]) > 0): 

389 log.warn( 

390 "Conflicting settings for features: " 

391 "All features are enabled for the build but some feature subpackages are hidden. " 

392 "This is likely an error.", 

393 ) 

394 

395 args = {key.replace("-", "_"): value for key, value in data.items()} 

396 return Features(**args) 

397 

398 

399@dataclass(frozen=True) 

400class Requires: 

401 """Collection of settings related to additional Requires and BuildRequires.""" 

402 

403 build: list[str] = field(default_factory=list) 

404 """List of additional `BuildRequires`.""" 

405 

406 test: list[str] = field(default_factory=list) 

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

408 

409 lib: list[str] = field(default_factory=list) 

410 """List of additional `Requires` for the subpackage that contains the crate sources.""" 

411 

412 bin: list[str] = field(default_factory=list) 

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

414 

415 features: defaultdict[str, list[str]] = field(default_factory=lambda: defaultdict(list)) 

416 """List of additional `Requires` for built subpackages associated with crate features.""" 

417 

418 @staticmethod 

419 def from_data(data: dict, validate_features: set[str] | None = None) -> "Requires": 

420 """Initialize from TOML data directly.""" 

421 args = {key.replace("-", "_"): value for key, value in data.items()} 

422 

423 # validate the "requires.features" setting 

424 # dictionary keys must be valid feature names 

425 if validate_features is not None and (feature_requires := data.get("features")): 

426 for feature in feature_requires: 

427 if feature not in validate_features: 

428 msg = f"Unrecognized Requires for feature: {feature}" 

429 raise ConfError(msg) 

430 

431 return Requires(**args) 

432 

433 

434@dataclass(frozen=True) 

435class TomlConf: 

436 """rust2rpm configuration loaded from the rust2rpm.toml file format.""" 

437 

438 package: Package = field(default_factory=Package) 

439 """Settings from the [package] table.""" 

440 

441 scripts: Scripts = field(default_factory=Scripts) 

442 """Settings from the [scripts] table.""" 

443 

444 tests: Tests = field(default_factory=Tests) 

445 """Settings from the [tests] table.""" 

446 

447 features: Features = field(default_factory=Features) 

448 """Settings from the [features] table.""" 

449 

450 requires: Requires = field(default_factory=Requires) 

451 """Settings from the [requires] table.""" 

452 

453 @staticmethod 

454 def from_data(data: dict, validate_features: set[str] | None) -> "TomlConf": 

455 """Initialize from TOML data directly.""" 

456 jsonschema.validate(data, TOML_SCHEMA) 

457 

458 # the "default" feature is always implicitly defined 

459 if validate_features is not None and "default" not in validate_features: 

460 validate_features.add("default") 

461 

462 package = Package.from_data(data.get("package") or {}) 

463 scripts = Scripts.from_data(data.get("scripts") or {}) 

464 tests = Tests.from_data(data.get("tests") or {}) 

465 

466 features = Features.from_data(data.get("features") or {}, validate_features) 

467 requires = Requires.from_data(data.get("requires") or {}, validate_features) 

468 

469 return TomlConf(package, scripts, tests, features, requires) 

470 

471 @staticmethod 

472 def from_str(value: str, validate_features: set[str] | None) -> "TomlConf": 

473 """Load configuration from a TOML string.""" 

474 toml = tomllib.loads(value) 

475 return TomlConf.from_data(toml, validate_features) 

476 

477 @staticmethod 

478 def load(path: Path, validate_features: set[str] | None) -> "TomlConf": 

479 """Load configuration from a file path.""" 

480 with path.open() as file: 

481 contents = file.read() 

482 

483 return TomlConf.from_str(contents, validate_features) 

484 

485 

486def load_config_toml(path: Path, validate_features: set[str] | None = None) -> TomlConf | None: 

487 """Load rust2rpm configuration from the rust2rpm.toml file format. 

488 

489 Arguments: 

490 path: Expected file path of the rust2rpm.toml file. 

491 validate_features: Optional set of valid feature names. 

492 Used to validate some settings. 

493 

494 Returns: 

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

496 

497 Raises: 

498 `SystemExit` on fatal errors. 

499 

500 """ 

501 try: 

502 tomlconf = TomlConf.load(path, validate_features) 

503 

504 except FileNotFoundError: 

505 return None 

506 

507 except tomllib.TOMLDecodeError as exc: 

508 log.error("Cannot read rust2rpm.toml file (TOML syntax error):") 

509 log.error(str(exc)) 

510 sys.exit(1) 

511 

512 except jsonschema.ValidationError as exc: 

513 if not exc.path: 

514 log.error("Invalid rust2rpm.toml file (unknown setting or table):") 

515 log.error(exc.message) 

516 else: 

517 err_path = "" 

518 for elem in exc.path: 

519 if isinstance(elem, int): 

520 err_path += f"[{elem}]" 

521 else: 

522 err_path += f".{elem}" 

523 log.error(f"Invalid rust2rpm.toml file (invalid setting at {err_path!r}):") 

524 log.error(exc.message) 

525 sys.exit(1) 

526 

527 except ConfError as exc: 

528 log.error("Invalid rust2rpm.toml file:") 

529 log.error(str(exc)) 

530 sys.exit(1) 

531 

532 else: 

533 return tomlconf