Coverage for rust2rpm/__main__.py: 67%

252 statements  

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

1import sys 

2from pathlib import Path 

3 

4import requests 

5from cargo2rpm.semver import Version 

6 

7from rust2rpm import log 

8from rust2rpm.cli import CliArgs, Options 

9from rust2rpm.conf import TomlConf, load_config_ini, load_config_toml 

10from rust2rpm.cratesio import NoVersionsError 

11from rust2rpm.distgit import get_package_info 

12from rust2rpm.generator import ( 

13 CrateTemplate, 

14 EpelEightTemplate, 

15 InvalidMetadataError, 

16 ProjectTemplate, 

17 WorkspaceTemplate, 

18) 

19from rust2rpm.metadata import WorkspaceError, warn_if_package_uses_restrictive_dependencies 

20from rust2rpm.patching import PatchError 

21from rust2rpm.project import CrateProject, InvalidVersionError, LocalProject, OfflineError, Project 

22from rust2rpm.sysinfo import Target 

23from rust2rpm.utils import detect_packager, detect_rpmautospec, guess_crate_name 

24 

25 

26def validate_extra_sources(conf: TomlConf, vendor_tarball: str | None): 

27 if extra_sources := conf.package.extra_sources: 

28 min_source_number = 2 if vendor_tarball else 1 

29 safe_source_number = 2 

30 

31 for extra_source in extra_sources: 

32 if extra_source.number == 0: 

33 log.error( 

34 f"Extra source {extra_source.file!r} conflicts with the primary tarball. " 

35 f"Use a number larger than {min_source_number} instead.", 

36 ) 

37 sys.exit(1) 

38 if extra_source.number == 1 and vendor_tarball: 38 ↛ 39line 38 didn't jump to line 39 because the condition on line 38 was never true

39 log.error( 

40 f"Extra source {extra_source.file!r} conflicts with the vendor tarball. " 

41 f"Use a number larger than {min_source_number} instead.", 

42 ) 

43 sys.exit(1) 

44 

45 # no conflict now, but maybe later 

46 if extra_source.number < safe_source_number: 46 ↛ 31line 46 didn't jump to line 31 because the condition on line 46 was always true

47 log.warn( 

48 f"Extra source {extra_source.file!r} might conflict with other files in the future. " 

49 "Using source numbers >= 2 is recommended.", 

50 ) 

51 

52 

53def validate_extra_patches(conf: TomlConf, *, auto_patch: bool, manual_patch: bool): 

54 if extra_patches := conf.package.extra_patches: 

55 min_patch_number = 0 

56 if auto_patch: 56 ↛ 57line 56 didn't jump to line 57 because the condition on line 56 was never true

57 min_patch_number += 1 

58 if manual_patch: 58 ↛ 59line 58 didn't jump to line 59 because the condition on line 58 was never true

59 min_patch_number += 1 

60 safe_patch_number = 2 

61 

62 for extra_patch in extra_patches: 

63 if extra_patch.number == 0 and auto_patch: 63 ↛ 64line 63 didn't jump to line 64 because the condition on line 63 was never true

64 log.error( 

65 f"Extra patch {extra_patch.file!r} conflicts with the automatically generated patch. " 

66 f"Use a number larger than {min_patch_number} instead.", 

67 ) 

68 sys.exit(1) 

69 if extra_patch.number == 0 and manual_patch: 69 ↛ 70line 69 didn't jump to line 70 because the condition on line 69 was never true

70 log.error( 

71 f"Extra patch {extra_patch.file!r} conflicts with the manually created patch. " 

72 f"Use a number larger than {min_patch_number} instead.", 

73 ) 

74 sys.exit(1) 

75 if extra_patch.number == 1 and auto_patch and manual_patch: 75 ↛ 76line 75 didn't jump to line 76 because the condition on line 75 was never true

76 log.error( 

77 f"Extra patch {extra_patch.file!r} conflicts with the manually created patch. " 

78 f"Use a number larger than {min_patch_number} instead.", 

79 ) 

80 sys.exit(1) 

81 

82 # no conflict now, but maybe later 

83 if extra_patch.number < safe_patch_number: 83 ↛ 62line 83 didn't jump to line 62 because the condition on line 83 was always true

84 log.warn( 

85 f"Extra patch {extra_patch.file!r} might conflict with other patches in the future. " 

86 "Using patch numbers >= 2 is recommended.", 

87 ) 

88 

89 

90def load_config( 

91 target: str, 

92 *, 

93 path: Path | None = None, 

94 features: set[str] | None = None, 

95 migrate: bool = False, 

96) -> TomlConf: 

97 ini_paths = [".rust2rpm.conf", "_rust2rpm.conf", "rust2rpm.conf"] 

98 toml_path = path or Path("rust2rpm.toml") 

99 

100 # check if both new and old config format are present 

101 if path is None: 

102 for ini_path in ini_paths: 

103 if Path(ini_path).exists() and toml_path.exists(): 103 ↛ 104line 103 didn't jump to line 104 because the condition on line 103 was never true

104 log.error(f"Both rust2rpm.toml and {ini_path!s} exist.") 

105 sys.exit(1) 

106 

107 tomlconf = load_config_toml(toml_path, features) 

108 if tomlconf is not None: 

109 return tomlconf 

110 

111 if path and tomlconf is None: 111 ↛ 112line 111 didn't jump to line 112 because the condition on line 111 was never true

112 log.error("Configuration file not found: {path!s}") 

113 sys.exit(1) 

114 

115 distconf = load_config_ini(ini_paths, target, features) 

116 if distconf is not None: 116 ↛ 118line 116 didn't jump to line 118 because the condition on line 116 was never true

117 

118 log.warn( 

119 "The rust2rpm.conf file format is deprecated and support for it will be removed " 

120 "removed in a future release of rust2rpm. The current settings have been " 

121 "automatically migrated to the new TOML based format (rust2rpm.toml).", 

122 ) 

123 

124 # automatically migrate to TOML format unless given explicit path 

125 if migrate and path is None: 

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

127 file.write(distconf.migrate(features)) 

128 

129 for ini_path in ini_paths: 

130 if Path(ini_path).exists(): 

131 Path(ini_path).unlink() 

132 

133 return distconf.upgrade() 

134 

135 return TomlConf() 

136 

137 

138def load_project(args: CliArgs, conf: TomlConf) -> Project: 

139 project: Project 

140 

141 try: 

142 if args.path: 

143 if args.path.is_dir(): 

144 project = LocalProject.from_project_folder( 

145 args.path, 

146 args.name, 

147 args.version, 

148 args.output_directory, 

149 conf, 

150 args.options, 

151 ) 

152 name = project.name 

153 return project 

154 

155 if args.path.name == "Cargo.toml": 

156 project = LocalProject.from_cargo_toml_path( 

157 args.path, 

158 args.name, 

159 args.version, 

160 args.output_directory, 

161 conf, 

162 args.options, 

163 ) 

164 name = project.name 

165 return project 

166 

167 if args.path.name.endswith(".crate"): 

168 project = CrateProject.from_local_crate( 

169 args.path, 

170 args.output_directory, 

171 conf, 

172 args.options, 

173 ) 

174 name = project.name 

175 return project 

176 

177 msg = ( 

178 "Invalid path argument." 

179 if args.path.exists() 

180 else f"Invalid path argument: {args.path!s} is not a local file or directory" 

181 ) 

182 log.error(msg) 

183 sys.exit(1) 

184 

185 else: # pragma nocover: requires internet access 

186 if guessed_name := args.name or guess_crate_name(): 

187 name = guessed_name 

188 else: 

189 log.error("Neither pkgid nor --path was specified and auto-detection failed.") 

190 sys.exit(1) 

191 

192 project = CrateProject.from_crates_io( 

193 name, 

194 args.version, 

195 args.output_directory, 

196 conf, 

197 args.options, 

198 ) 

199 

200 except (InvalidVersionError, NoVersionsError, OfflineError, PatchError, requests.RequestException) as exc: 

201 log.error(str(exc)) 

202 sys.exit(1) 

203 

204 else: 

205 return project 

206 

207 

208def use_rpmautospec(args: CliArgs, spec_file: Path) -> bool: 

209 return detect_rpmautospec(args.target, spec_file) if args.options.rpmautospec is None else args.options.rpmautospec 

210 

211 

212def existence_check(project: Project, args: CliArgs, spec_file: Path): # pragma nocover: requires internet access 

213 if ( 

214 args.target in {Target.FEDORA} 

215 and args.options.existence_check 

216 and not args.options.offline 

217 and not spec_file.exists() 

218 and (package_info := get_package_info(project.rpm_name)) 

219 ): 

220 if args.options.compat: 

221 version_suffix = project.rpm_name.removeprefix(project.name) 

222 log.warn( 

223 f"Version {version_suffix}.* of the crate {project.name!r} is already " 

224 f"packaged for Fedora: {package_info['full_url']}", 

225 ) 

226 else: 

227 log.warn(f"Crate {project.name!r} is already packaged for Fedora: {package_info['full_url']}") 

228 

229 log.info("Re-run with --no-existence-check to create a new spec file from scratch.") 

230 sys.exit(1) 

231 

232 

233def license_file_check(project: Project, options: Options): 

234 if not project.license_files and not options.ignore_missing_license_files: 234 ↛ 235line 234 didn't jump to line 235 because the condition on line 234 was never true

235 log.error( 

236 "No license files were detected. " 

237 "In almost all cases, this is an issue with the upstream project that should be reported.", 

238 ) 

239 log.info( 

240 "To temporarily ignore this issue, use the '--ignore-missing-license-files' / '-I' flag - " 

241 "for example, when manually including license files from the upstream project, or for test builds.", 

242 ) 

243 sys.exit(1) 

244 

245 

246def handle_crate(project: CrateProject, args: CliArgs, conf: TomlConf) -> tuple[Path, str]: 

247 package = project.metadata.packages[0] 

248 spec_file = (args.output_directory or Path()) / f"{project.rpm_name}.spec" 

249 packager = detect_packager() 

250 

251 license_file_check(project, args.options) 

252 existence_check(project, args, spec_file) 

253 

254 # basic validation for extra sources and patches 

255 validate_extra_sources(conf, project.vendor_tarball) 

256 validate_extra_patches( 

257 conf, 

258 auto_patch=project.auto_patch is not None, 

259 manual_patch=project.manual_patch is not None, 

260 ) 

261 

262 if build_meta := Version.parse(package.version).build: 262 ↛ 263line 262 didn't jump to line 263 because the condition on line 262 was never true

263 log.error(f"Crate version {package.version!r} contains build metadata: '+{build_meta}'") 

264 log.error(f"This is not supported by rust2rpm; remove the '+{build_meta}' suffix.") 

265 sys.exit(1) 

266 

267 if project.vendor_tarball and not (package.is_bin() or package.is_cdylib()): 267 ↛ 268line 267 didn't jump to line 268 because the condition on line 267 was never true

268 log.error("Building library-only crates with vendored dependencies is not supported.") 

269 sys.exit(1) 

270 

271 if conf.package.cargo_toml_patch_comments and project.manual_patch is None: 271 ↛ 272line 271 didn't jump to line 272 because the condition on line 271 was never true

272 log.error( 

273 "The rust2rpm.toml configuration file specifies comments for a Cargo.toml patch, " 

274 "but Cargo.toml was not patched.", 

275 ) 

276 sys.exit(1) 

277 

278 if conf.package.url: 278 ↛ 279line 278 didn't jump to line 279 because the condition on line 278 was never true

279 log.error( 

280 "The rust2rpm.toml configuration file specifies an override for the package URL, " 

281 "but this is not valid for crates packaged from crates.io.", 

282 ) 

283 sys.exit(1) 

284 

285 if conf.package.source_url: 285 ↛ 286line 285 didn't jump to line 286 because the condition on line 285 was never true

286 log.error( 

287 "The rust2rpm.toml configuration file specifies an override for the Source URL, " 

288 "but this is not valid for crates packaged from crates.io.", 

289 ) 

290 sys.exit(1) 

291 

292 warn_if_package_uses_restrictive_dependencies(package) 

293 

294 try: 

295 generator = CrateTemplate( 

296 project=project, 

297 conf=conf, 

298 target=args.target, 

299 date=None, 

300 packager=packager, 

301 use_relative_license_paths=args.options.relative_license_paths, 

302 use_rpmautospec=use_rpmautospec(args, spec_file), 

303 make_changelog_entry=args.options.auto_changelog_entry, 

304 ) 

305 spec_contents = generator.render() 

306 

307 except InvalidMetadataError as exc: 

308 # this can happen for crates that do not specify a license 

309 log.error(str(exc.args)) 

310 sys.exit(1) 

311 

312 except ValueError as exc: 

313 # this can happen for weird version requirements in ancient crates 

314 # that cannot be translated correctly into RPM dependencies 

315 log.error(f"Could not generate spec file due to invalid version requirement: {exc!s}") 

316 sys.exit(1) 

317 

318 return spec_file, spec_contents 

319 

320 

321def handle_epel8(project: CrateProject, args: CliArgs, conf: TomlConf) -> tuple[Path, str]: 

322 package = project.metadata.packages[0] 

323 spec_file = (args.output_directory or Path()) / f"{project.rpm_name}.spec" 

324 packager = detect_packager() 

325 

326 license_file_check(project, args.options) 

327 existence_check(project, args, spec_file) 

328 

329 # basic validation for extra sources and patches 

330 validate_extra_sources(conf, project.vendor_tarball) 

331 validate_extra_patches( 

332 conf, 

333 auto_patch=project.auto_patch is not None, 

334 manual_patch=project.manual_patch is not None, 

335 ) 

336 

337 if build_meta := Version.parse(package.version).build: 337 ↛ 338line 337 didn't jump to line 338 because the condition on line 337 was never true

338 log.error(f"Crate version {package.version!r} contains build metadata: '+{build_meta}'") 

339 log.error(f"This is not supported by rust2rpm; remove the '+{build_meta}' suffix.") 

340 sys.exit(1) 

341 

342 if project.vendor_tarball and not (package.is_bin() or package.is_cdylib()): 342 ↛ 343line 342 didn't jump to line 343 because the condition on line 342 was never true

343 log.error("Building library-only crates with vendored dependencies is not supported.") 

344 sys.exit(1) 

345 

346 if conf.package.cargo_toml_patch_comments and project.manual_patch is None: 346 ↛ 347line 346 didn't jump to line 347 because the condition on line 346 was never true

347 log.error( 

348 "The rust2rpm.toml configuration file specifies comments for a Cargo.toml patch, " 

349 "but Cargo.toml was not patched.", 

350 ) 

351 sys.exit(1) 

352 

353 if conf.package.url: 353 ↛ 354line 353 didn't jump to line 354 because the condition on line 353 was never true

354 log.error( 

355 "The rust2rpm.toml configuration file specifies an override for the package URL, " 

356 "but this is not valid for crates packaged from crates.io.", 

357 ) 

358 sys.exit(1) 

359 

360 if conf.package.source_url: 360 ↛ 361line 360 didn't jump to line 361 because the condition on line 360 was never true

361 log.error( 

362 "The rust2rpm.toml configuration file specifies an override for the Source URL, " 

363 "but this is not valid for crates packaged from crates.io.", 

364 ) 

365 sys.exit(1) 

366 

367 warn_if_package_uses_restrictive_dependencies(package) 

368 

369 try: 

370 generator = EpelEightTemplate( 

371 project=project, 

372 conf=conf, 

373 date=None, 

374 packager=packager, 

375 use_rpmautospec=use_rpmautospec(args, spec_file), 

376 make_changelog_entry=args.options.auto_changelog_entry, 

377 ) 

378 spec_contents = generator.render() 

379 

380 except InvalidMetadataError as exc: 

381 # this can happen for crates that do not specify a license 

382 log.error(str(exc.args)) 

383 sys.exit(1) 

384 

385 except ValueError as exc: 

386 # this can happen for weird version requirements in ancient crates 

387 # that cannot be translated correctly into RPM dependencies 

388 log.error(f"Could not generate spec file due to invalid version requirement: {exc!s}") 

389 sys.exit(1) 

390 

391 return spec_file, spec_contents 

392 

393 

394def handle_local(project: LocalProject, args: CliArgs, conf: TomlConf) -> tuple[Path, str]: 

395 package = project.metadata.packages[0] 

396 spec_file = (args.output_directory or Path()) / f"{project.rpm_name}.spec" 

397 packager = detect_packager() 

398 

399 license_file_check(project, args.options) 

400 existence_check(project, args, spec_file) 

401 

402 # basic validation for extra sources and patches 

403 validate_extra_sources(conf, project.vendor_tarball) 

404 validate_extra_patches( 

405 conf, 

406 auto_patch=project.auto_patch is not None, 

407 manual_patch=project.manual_patch is not None, 

408 ) 

409 

410 if build_meta := Version.parse(package.version).build: 410 ↛ 411line 410 didn't jump to line 411 because the condition on line 410 was never true

411 log.error(f"Crate version {package.version!r} contains build metadata: '+{build_meta}'") 

412 log.error(f"This is not supported by rust2rpm; remove the '+{build_meta}' suffix.") 

413 sys.exit(1) 

414 

415 if conf.package.cargo_toml_patch_comments and project.manual_patch is None: 415 ↛ 416line 415 didn't jump to line 416 because the condition on line 415 was never true

416 log.error( 

417 "The rust2rpm.toml configuration file specifies comments for a Cargo.toml patch, " 

418 "but Cargo.toml was not patched.", 

419 ) 

420 sys.exit(1) 

421 

422 warn_if_package_uses_restrictive_dependencies(package) 

423 

424 try: 

425 generator = ProjectTemplate( 

426 project=project, 

427 conf=conf, 

428 target=args.target, 

429 date=None, 

430 packager=packager, 

431 use_rpmautospec=use_rpmautospec(args, spec_file), 

432 make_changelog_entry=args.options.auto_changelog_entry, 

433 ) 

434 spec_contents = generator.render() 

435 

436 except InvalidMetadataError as exc: 

437 # this can happen for crates that do not specify a license 

438 log.error(str(exc.args)) 

439 sys.exit(1) 

440 

441 return spec_file, spec_contents 

442 

443 

444def handle_workspace(project: LocalProject, args: CliArgs, conf: TomlConf) -> tuple[Path, str]: 

445 spec_file = (args.output_directory or Path()) / f"{project.rpm_name}.spec" 

446 packager = detect_packager() 

447 

448 license_file_check(project, args.options) 

449 existence_check(project, args, spec_file) 

450 

451 # basic validation for extra sources and patches 

452 validate_extra_sources(conf, project.vendor_tarball) 

453 

454 try: 

455 generator = WorkspaceTemplate( 

456 project=project, 

457 conf=conf, 

458 target=args.target, 

459 date=None, 

460 packager=packager, 

461 use_rpmautospec=use_rpmautospec(args, spec_file), 

462 make_changelog_entry=args.options.auto_changelog_entry, 

463 ) 

464 spec_contents = generator.render() 

465 

466 except InvalidMetadataError as exc: 

467 # this can happen for crates that do not specify a license 

468 log.error(str(exc.args)) 

469 sys.exit(1) 

470 

471 except WorkspaceError as exc: 

472 # this can happen for confusing workspace metadata or invalid hints 

473 log.error(str(exc.args)) 

474 sys.exit(1) 

475 

476 return spec_file, spec_contents 

477 

478 

479def print_deprecation_warnings(args: CliArgs): 

480 if args.options.relative_license_paths is True: 480 ↛ 481line 480 didn't jump to line 481 because the condition on line 480 was never true

481 log.warn( 

482 "The '--relative-license-paths' flag produces slightly unexpected results." 

483 "It is deprecated and will be removed in a future version of rust2rpm.", 

484 ) 

485 

486 if args.options.auto_changelog_entry is False: 486 ↛ 487line 486 didn't jump to line 487 because the condition on line 486 was never true

487 log.warn("The '--no-auto-changelog-entry' is deprecated and will be removed in a future version of rust2rpm.") 

488 

489 

490def print_migration_errors(args: CliArgs): 

491 if (pkgid := args.pkgid) and ("/" in pkgid): 491 ↛ 492line 491 didn't jump to line 492 because the condition on line 491 was never true

492 log.error("Paths are no longer accepted as positional arguments. Use the '--path' argument instead.") 

493 sys.exit(1) 

494 

495 

496def main(argv: list[str] | None = None): 

497 args = CliArgs.parse(argv) 

498 print_deprecation_warnings(args) 

499 print_migration_errors(args) 

500 

501 # validate config and exit early if config is invalid 

502 conf = load_config(args.target, path=args.config_file, features=None, migrate=False) 

503 if args.options.validate_only: 503 ↛ 504line 503 didn't jump to line 504 because the condition on line 503 was never true

504 sys.exit(0) 

505 

506 # force generation of a Cargo.toml patch if comments are configured 

507 args.options.patch = args.options.patch or ( 

508 # only force opening an editor if comments are present but neither --patch nor --reuse-patch were specified 

509 len(conf.package.cargo_toml_patch_comments) > 0 

510 and not args.options.patch 

511 and not args.options.reuse_patch 

512 ) 

513 

514 project = load_project(args, conf) 

515 

516 # validate config with known feature names 

517 if isinstance(project, CrateProject): 

518 feature_names = project.package.get_feature_names() 

519 conf = load_config(args.target, path=args.config_file, features=feature_names, migrate=True) 

520 else: 

521 conf = load_config(args.target, path=args.config_file, features=None, migrate=True) 

522 

523 # special handling for epel8 target 

524 if args.target == Target.EPEL8 and isinstance(project, CrateProject): 

525 spec_file, spec_contents = handle_epel8(project, args, conf) 

526 

527 # package for a workspace project 

528 elif isinstance(project, LocalProject) and project.metadata.is_workspace(): 

529 spec_file, spec_contents = handle_workspace(project, args, conf) 

530 

531 # package for non-crate, non-workspace project 

532 elif isinstance(project, LocalProject): 

533 spec_file, spec_contents = handle_local(project, args, conf) 

534 

535 # package for an actual Rust crate 

536 elif isinstance(project, CrateProject): 

537 spec_file, spec_contents = handle_crate(project, args, conf) 

538 

539 else: # pragma nocover 

540 log.error("Invalid project - this should not happen.") 

541 sys.exit(1) 

542 

543 # ensure output directory exists 

544 if out_dir := args.output_directory: 544 ↛ 547line 544 didn't jump to line 547 because the condition on line 544 was always true

545 out_dir.mkdir(parents=True, exist_ok=True) 

546 else: 

547 out_dir = Path() 

548 

549 # write spec file 

550 with spec_file.open("w") as file: 

551 file.write(spec_contents) 

552 log.success(f"Generated: {file.name}") 

553 

554 # write patch files 

555 for patch in [project.auto_patch, project.manual_patch]: 

556 if patch is not None: 

557 with (out_dir / patch.name).open("w") as file: 

558 file.write("\n".join(patch.contents) + "\n") 

559 log.success(f"Generated: {file.name}") 

560 

561 

562if __name__ == "__main__": # pragma nocover 

563 try: 

564 main() 

565 except KeyboardInterrupt: 

566 sys.exit(0)