Coverage for britney2/inputs/suiteloader.py: 92%

291 statements  

« prev     ^ index     » next       coverage.py v7.6.0, created at 2026-08-18 12:43 +0000

1import logging 

2import optparse 

3import os 

4import sys 

5from abc import abstractmethod 

6from collections.abc import Callable, Iterable, Iterator 

7from itertools import chain 

8from typing import Any, Literal, TypeVar, overload 

9 

10import apt_pkg 

11 

12from britney2 import ( 

13 BinaryPackage, 

14 BinaryPackageId, 

15 MultiArch, 

16 SourcePackage, 

17 Suite, 

18 SuiteClass, 

19 Suites, 

20 TargetSuite, 

21) 

22from britney2.utils import ( 

23 create_provides_map, 

24 parse_builtusing, 

25 parse_provides, 

26 possibly_compressed, 

27 read_release_file, 

28 read_sources_file, 

29) 

30 

31 

32class MissingRequiredConfigurationError(RuntimeError): 

33 pass 

34 

35 

36_T = TypeVar("_T") 

37 

38 

39class SuiteContentLoader: 

40 def __init__(self, base_config: optparse.Values) -> None: 

41 self._base_config = base_config 

42 self._architectures: list[str] = SuiteContentLoader.config_str_as_list( 

43 base_config.architectures 

44 ) 

45 self._nobreakall_arches: list[str] = SuiteContentLoader.config_str_as_list( 

46 base_config.nobreakall_arches, [] 

47 ) 

48 self._outofsync_arches: list[str] = SuiteContentLoader.config_str_as_list( 

49 base_config.outofsync_arches, [] 

50 ) 

51 self._break_arches: list[str] = SuiteContentLoader.config_str_as_list( 

52 base_config.break_arches, [] 

53 ) 

54 self._new_arches: list[str] = SuiteContentLoader.config_str_as_list( 

55 base_config.new_arches, [] 

56 ) 

57 self._components: list[str] = [] 

58 self._all_binaries: dict[BinaryPackageId, BinaryPackage] = {} 

59 logger_name = ".".join((self.__class__.__module__, self.__class__.__name__)) 

60 self.logger = logging.getLogger(logger_name) 

61 

62 @overload 

63 @staticmethod 

64 def config_str_as_list(value: Literal[None], default_value: _T) -> _T: ... 64 ↛ exitline 64 didn't return from function 'config_str_as_list' because

65 

66 @overload 

67 @staticmethod 

68 def config_str_as_list(value: str, default_value: Any) -> list[str]: ... 68 ↛ exitline 68 didn't return from function 'config_str_as_list' because

69 

70 @overload 

71 @staticmethod 

72 def config_str_as_list(value: Any, default_value: Any | None = None) -> Any: ... 72 ↛ exitline 72 didn't return from function 'config_str_as_list' because

73 

74 @staticmethod 

75 def config_str_as_list(value: Any, default_value: Any | None = None) -> Any: 

76 if value is None: 

77 return default_value 

78 if isinstance(value, str): 78 ↛ 80line 78 didn't jump to line 80 because the condition on line 78 was always true

79 return value.split() 

80 return value 

81 

82 @property 

83 def architectures(self) -> list[str]: 

84 return self._architectures 

85 

86 @property 

87 def nobreakall_arches(self) -> list[str]: 

88 return self._nobreakall_arches 

89 

90 @property 

91 def outofsync_arches(self) -> list[str]: 

92 return self._outofsync_arches 

93 

94 @property 

95 def break_arches(self) -> list[str]: 

96 return self._break_arches 

97 

98 @property 

99 def new_arches(self) -> list[str]: 

100 return self._new_arches 

101 

102 @property 

103 def components(self) -> list[str]: 

104 return self._components 

105 

106 def all_binaries(self) -> dict[BinaryPackageId, BinaryPackage]: 

107 return self._all_binaries 

108 

109 @abstractmethod 

110 def load_suites(self) -> Suites: # pragma: no cover 

111 pass 

112 

113 

114def _strip_alternatives_from_depends( 

115 deps: str | None, architecture: str = "" 

116) -> Iterator[str]: 

117 if deps is None: 

118 return 

119 

120 for block in apt_pkg.parse_src_depends(deps, architecture=architecture): 

121 # Like the buildds, we don't care about alternatives 

122 first_block = block[0] 

123 if first_block[1] != "": 

124 # The extra space in the middle is a workaround for 

125 # an apt_pkg bug in bookworm 

126 yield f"{first_block[0]} ({first_block[2]} {first_block[1]})" 

127 else: 

128 yield f"{first_block[0]}" 

129 

130 

131class DebMirrorLikeSuiteContentLoader(SuiteContentLoader): 

132 CHECK_FIELDS = ( 

133 "source", 

134 "source_version", 

135 "architecture", 

136 "multi_arch", 

137 "depends", 

138 "conflicts", 

139 "provides", 

140 ) 

141 

142 def load_suites(self) -> Suites: 

143 suites = [] 

144 missing_config_msg = ( 

145 "Configuration %s is not set in the config (and cannot be auto-detected)" 

146 ) 

147 target_suite = None 

148 for suitename in ("testing", "unstable", "pu", "tpu"): 

149 suffix = suitename if suitename in ("pu", "tpu") else "" 

150 if hasattr(self._base_config, suitename): 

151 suite_path = getattr(self._base_config, suitename) 

152 suite_class = SuiteClass.TARGET_SUITE 

153 if suitename != "testing": 

154 suite_class = ( 

155 SuiteClass.ADDITIONAL_SOURCE_SUITE 

156 if suffix 

157 else SuiteClass.PRIMARY_SOURCE_SUITE 

158 ) 

159 suites.append( 

160 Suite( 

161 suite_class, suitename, suite_path, suite_short_name=suffix 

162 ) 

163 ) 

164 else: 

165 target_suite = TargetSuite( 

166 suite_class, suitename, suite_path, suite_short_name=suffix 

167 ) 

168 else: 

169 if suitename in ("testing", "unstable"): # pragma: no cover 

170 self.logger.error(missing_config_msg, suitename.upper()) 

171 raise MissingRequiredConfigurationError( 

172 missing_config_msg % suitename.upper() 

173 ) 

174 self.logger.info( 

175 "Optional suite %s is not defined (config option: %s) ", 

176 suitename, 

177 suitename.upper(), 

178 ) 

179 

180 assert target_suite is not None, "Logic regression, this should be impossible." 

181 

182 self._check_release_file(target_suite, missing_config_msg) 

183 self._setup_architectures() 

184 

185 # read the source and binary packages for the involved distributions. Notes: 

186 # - Load testing last as some live-data tests have more complete information in 

187 # unstable 

188 # - Load all sources before any of the binaries. 

189 for suite in chain((target_suite,), suites): 

190 sources = self._read_sources( 

191 suite.path, None if suite is target_suite else target_suite.sources 

192 ) 

193 self._update_suite_name(suite) 

194 suite.sources = sources 

195 (suite.binaries, suite.provides_table) = self._read_binaries( 

196 suite, self._architectures 

197 ) 

198 self._fixup_faux_arch_all_binaries(suite) 

199 if self._base_config.be_strict_with_build_deps: 199 ↛ 189line 199 didn't jump to line 189 because the condition on line 199 was always true

200 self._add_build_dep_faux_binaries(suite) 

201 

202 return Suites(target_suite, suites) 

203 

204 def _fixup_faux_arch_all_binaries(self, suite: Suite) -> None: 

205 """remove faux arch:all binary if a real arch:all binary is available 

206 

207 We don't know for which architectures bin/$something must be available 

208 except for arch:all, which should be available in each arch. The 

209 information that a source builds an arch:all binary is available during 

210 the loading of the sources, but we have to pick an order in which to 

211 load the files and the Sources is loaded before the Packages are 

212 read. Hence we fake an arch:all binary during source loading, but it 

213 shouldn't be there in the final list if real arch:all binaries are 

214 present in the Packages file. 

215 

216 Also, if we keep the fake binary, it should be added to the lists of 

217 known binaries in the suite, otherwise britney2 trips later on. 

218 

219 """ 

220 

221 all_binaries = self._all_binaries 

222 binaries = suite.binaries 

223 faux_arches = ( 

224 set(self.architectures) 

225 - set(self.break_arches) 

226 - set(self.outofsync_arches) 

227 - set(self.new_arches) 

228 ) 

229 

230 for srcpkg in suite.sources.values(): 

231 faux = {x for x in srcpkg.binaries if x.architecture == "faux"} 

232 if faux and any( 

233 x 

234 for x in (srcpkg.binaries - faux) 

235 if all_binaries[x].architecture == "all" 

236 ): 

237 srcpkg.binaries -= faux 

238 

239 # Calculate again because we may have changed the set 

240 faux = {x for x in srcpkg.binaries if x.architecture == "faux"} 

241 for binpkg_id in faux: 

242 bin_data = BinaryPackage( 

243 "faux", 

244 srcpkg.source, 

245 srcpkg.version, 

246 "all", 

247 MultiArch.NO, 

248 None, 

249 None, 

250 None, 

251 False, 

252 binpkg_id, 

253 None, 

254 ) 

255 for arch_all in faux_arches: 

256 binaries[arch_all][binpkg_id.package_name] = bin_data 

257 all_binaries[binpkg_id] = bin_data 

258 suite.binaries = binaries 

259 

260 def _add_build_dep_faux_binaries(self, suite: Suite) -> None: 

261 """Add faux packages that keep track of build depends 

262 

263 To ensure that Build-Depends are fully protected against inappropriate 

264 removal or upgrade, we add faux packages to source packages containing 

265 the Build-Depends as Depends. 

266 """ 

267 

268 all_binaries = self._all_binaries 

269 for src_name, src_pkg in suite.sources.items(): 

270 # TODO: something with arch, which one? 

271 archall = self._nobreakall_arches[0] 

272 bd_pid = BinaryPackageId( 

273 f"{src_name}-faux-build-depends", sys.intern(src_pkg.version), archall 

274 ) 

275 deps = ",".join( 

276 chain( 

277 _strip_alternatives_from_depends(src_pkg.build_deps_arch, archall), 

278 _strip_alternatives_from_depends(src_pkg.build_deps_indep, archall), 

279 ) 

280 ) 

281 if deps == "": 

282 continue 

283 dpkg = BinaryPackage( 

284 "faux", 

285 src_name, 

286 src_pkg.version, 

287 archall, 

288 MultiArch.NO, 

289 deps, 

290 None, 

291 None, 

292 False, 

293 bd_pid, 

294 None, 

295 ) 

296 suite.binaries.setdefault(archall, {})[bd_pid.package_name] = dpkg 

297 src_pkg.binaries.add(bd_pid) 

298 all_binaries[bd_pid] = dpkg 

299 

300 def _setup_architectures(self) -> None: 

301 allarches = self._architectures 

302 # Re-order the architectures such as that the most important architectures are listed first 

303 # (this is to make the log easier to read as most important architectures will be listed 

304 # first) 

305 arches = [x for x in allarches if x in self._nobreakall_arches] 

306 arches += [ 

307 x for x in allarches if x not in arches and x not in self._outofsync_arches 

308 ] 

309 arches += [ 

310 x for x in allarches if x not in arches and x not in self._break_arches 

311 ] 

312 arches += [ 

313 x for x in allarches if x not in arches and x not in self._new_arches 

314 ] 

315 arches += [x for x in allarches if x not in arches] 

316 

317 # Intern architectures for efficiency; items in this list will be used for lookups and 

318 # building items/keys - by intern strings we reduce memory (considerably). 

319 self._architectures = [sys.intern(arch) for arch in allarches] 

320 assert "all" not in self._architectures, "all not allowed in architectures" 

321 

322 def _get_suite_name( 

323 self, suite: Suite, release_file: "apt_pkg.TagSection[str]" 

324 ) -> tuple[str, str]: 

325 name = None 

326 codename = None 

327 if "Suite" in release_file: 327 ↛ 329line 327 didn't jump to line 329 because the condition on line 327 was always true

328 name = release_file["Suite"] 

329 if "Codename" in release_file: 

330 codename = release_file["Codename"] 

331 

332 if name is None: 332 ↛ 333line 332 didn't jump to line 333 because the condition on line 332 was never true

333 name = codename 

334 elif codename is None: 

335 codename = name 

336 

337 if name is None: 337 ↛ 338line 337 didn't jump to line 338 because the condition on line 337 was never true

338 self.logger.warning( 

339 'Either of the fields "Suite" or "Codename" ' 

340 + "should be present in a release file." 

341 ) 

342 self.logger.error( 

343 "Release file for suite %s is missing both the " 

344 + '"Suite" and the "Codename" fields.', 

345 suite.name, 

346 ) 

347 raise KeyError("Suite") 

348 

349 assert codename is not None # required for type checking 

350 return (name, codename) 

351 

352 def _update_suite_name(self, suite: Suite) -> None: 

353 try: 

354 release_file = read_release_file(suite.path) 

355 except FileNotFoundError: 

356 self.logger.info( 

357 "The %s suite does not have a Release file, unable to update the name", 

358 suite.name, 

359 ) 

360 release_file = None 

361 

362 if release_file is not None: 

363 (suite.name, suite.codename) = self._get_suite_name(suite, release_file) 

364 self.logger.info("Using suite name from Release file: %s", suite.name) 

365 self.logger.debug( 

366 "Using suite codename from Release file: %s", suite.codename 

367 ) 

368 

369 def _check_release_file(self, target_suite: Suite, missing_config_msg: str) -> None: 

370 try: 

371 release_file = read_release_file(target_suite.path) 

372 self.logger.info( 

373 "Found a Release file in %s - using that for defaults", 

374 target_suite.name, 

375 ) 

376 except FileNotFoundError: 

377 self.logger.info( 

378 "The %s suite does not have a Release file.", target_suite.name 

379 ) 

380 release_file = None 

381 

382 if release_file is not None: 

383 self._components = release_file["Components"].split() 

384 self.logger.info( 

385 "Using components listed in Release file: %s", 

386 " ".join(self._components), 

387 ) 

388 

389 if self._architectures is None: 

390 if release_file is None: # pragma: no cover 

391 self.logger.error( 

392 "No configured architectures and there is no release file in the %s suite.", 

393 target_suite.name, 

394 ) 

395 self.logger.error( 

396 'Please check if there is a "Release" file in %s', target_suite.path 

397 ) 

398 self.logger.error( 

399 'or if the config file contains a non-empty "ARCHITECTURES" field' 

400 ) 

401 raise MissingRequiredConfigurationError( 

402 missing_config_msg % "ARCHITECTURES" 

403 ) 

404 self._architectures = sorted( 

405 x for x in release_file["Architectures"].split() if x != "all" 

406 ) 

407 self.logger.info( 

408 "Using architectures listed in Release file: %s", 

409 " ".join(self._architectures), 

410 ) 

411 

412 def _read_sources( 

413 self, basedir: str, sources_target_suite: dict[str, SourcePackage] | None = None 

414 ) -> dict[str, SourcePackage]: 

415 """Read the list of source packages from the specified directory 

416 

417 The source packages are read from the `Sources' file within the 

418 directory specified as `basedir' parameter. Considering the 

419 large amount of memory needed, not all the fields are loaded 

420 in memory. The available fields are Version, Maintainer and Section. 

421 

422 The method returns a list where every item represents a source 

423 package as a dictionary. 

424 """ 

425 

426 if self._components: 

427 sources: dict[str, SourcePackage] = {} 

428 for component in self._components: 

429 filename = os.path.join(basedir, component, "source", "Sources") 

430 try: 

431 filename = possibly_compressed(filename) 

432 except FileNotFoundError: 

433 if component == "non-free-firmware": 

434 self.logger.info("Skipping %s as it doesn't exist", filename) 

435 continue 

436 raise 

437 self.logger.info("Loading source packages from %s", filename) 

438 read_sources_file( 

439 filename, 

440 sources, 

441 not self._base_config.archall_inconsistency_allowed, 

442 sources_target_suite, 

443 ) 

444 else: 

445 filename = os.path.join(basedir, "Sources") 

446 self.logger.info("Loading source packages from %s", filename) 

447 sources = read_sources_file( 

448 filename, 

449 None, 

450 not self._base_config.archall_inconsistency_allowed, 

451 sources_target_suite, 

452 ) 

453 

454 return sources 

455 

456 @staticmethod 

457 def merge_fields( 

458 get_field: Callable[[str], str | None], 

459 *field_names: str, 

460 separator: str = ", ", 

461 ) -> str | None: 

462 """Merge two or more fields (filtering out empty fields; returning None if all are empty)""" 

463 return separator.join(filter(None, (get_field(x) for x in field_names))) or None 

464 

465 def _read_packages_file( 

466 self, 

467 filename: str, 

468 arch: str, 

469 srcdist: dict[str, SourcePackage], 

470 packages: dict[str, BinaryPackage] | None = None, 

471 ) -> dict[str, BinaryPackage]: 

472 self.logger.info("Loading binary packages from %s", filename) 

473 

474 if packages is None: 

475 packages = {} 

476 

477 all_binaries = self._all_binaries 

478 

479 tag_file = apt_pkg.TagFile(filename) 

480 get_field = tag_file.section.get 

481 step = tag_file.step 

482 intern = sys.intern 

483 

484 while step(): 

485 pkg = get_field("Package") 

486 version = get_field("Version") 

487 

488 # There may be multiple versions of any arch:all packages 

489 # (in unstable) if some architectures have out-of-date 

490 # binaries. We only ever consider the package with the 

491 # largest version for migration. 

492 pkg = intern(pkg) 

493 version = intern(version) 

494 pkg_id = BinaryPackageId(pkg, version, arch) 

495 

496 if (old_pkg_data := packages.get(pkg)) is not None: 

497 if apt_pkg.version_compare(old_pkg_data.version, version) > 0: 

498 continue 

499 old_pkg_id = old_pkg_data.pkg_id 

500 old_src_binaries = srcdist[old_pkg_data.source].binaries 

501 old_src_binaries.remove(old_pkg_id) 

502 # This may seem weird at first glance, but the current code rely 

503 # on this behaviour to avoid issues like #709460. Admittedly it 

504 # is a special case, but Britney will attempt to remove the 

505 # arch:all packages without this. Even then, this particular 

506 # stop-gap relies on the packages files being sorted by name 

507 # and the version, so it is not particularly resilient. 

508 if pkg_id not in old_src_binaries: 508 ↛ 514line 508 didn't jump to line 514 because the condition on line 508 was always true

509 old_src_binaries.add(pkg_id) 

510 

511 # Merge Pre-Depends with Depends and Conflicts with 

512 # Breaks. Britney is not interested in the "finer 

513 # semantic differences" of these fields anyway. 

514 deps = DebMirrorLikeSuiteContentLoader.merge_fields( 

515 get_field, "Pre-Depends", "Depends" 

516 ) 

517 conflicts = DebMirrorLikeSuiteContentLoader.merge_fields( 

518 get_field, "Conflicts", "Breaks" 

519 ) 

520 

521 ess = get_field("Essential", "no") == "yes" 

522 

523 source = pkg 

524 source_version = version 

525 # retrieve the name and the version of the source package 

526 source_raw = get_field("Source") 

527 if source_raw: 

528 source = source_raw.split(" ")[0] 

529 if "(" in source_raw: 

530 source_version = source_raw[ 

531 source_raw.find("(") + 1 : source_raw.find(")") 

532 ] 

533 

534 provides_raw = get_field("Provides") 

535 if provides_raw: 

536 provides = parse_provides( 

537 provides_raw, pkg_id=pkg_id, logger=self.logger 

538 ) 

539 else: 

540 provides = None 

541 

542 raw_arch = get_field("Architecture") 

543 if raw_arch not in {"all", arch}: # pragma: no cover 

544 raise AssertionError( 

545 f"{pkg_id!s} has wrong architecture ({raw_arch}) - should be either {arch} or all" 

546 ) 

547 

548 builtusing_raw = get_field("Built-Using") 

549 if builtusing_raw: 

550 builtusing = parse_builtusing( 

551 builtusing_raw, pkg_id=pkg_id, logger=self.logger 

552 ) 

553 else: 

554 builtusing = None 

555 

556 dpkg = BinaryPackage( 

557 get_field("Section"), 

558 source, 

559 source_version, 

560 raw_arch, 

561 MultiArch.from_str(get_field("Multi-Arch")), 

562 deps, 

563 conflicts, 

564 provides, 

565 ess, 

566 pkg_id, 

567 builtusing, 

568 ) 

569 

570 # if the source package is available in the distribution, then register this binary package 

571 if (source_pkg := srcdist.get(source)) is not None: 

572 # There may be multiple versions of any arch:all packages 

573 # (in unstable) if some architectures have out-of-date 

574 # binaries. We only want to include the package in the 

575 # source -> binary mapping once. It doesn't matter which 

576 # of the versions we include as only the package name and 

577 # architecture are recorded. 

578 source_pkg.binaries.add(pkg_id) 

579 # if the source package doesn't exist, create a fake one 

580 else: 

581 srcdist[source] = SourcePackage( 

582 source, 

583 source_version, 

584 "faux", 

585 {pkg_id}, 

586 None, 

587 True, 

588 ) 

589 

590 # add the resulting dictionary to the package list 

591 if (old_binary := all_binaries.get(pkg_id)) is not None: 

592 # If the binary package is the same in across suites, we reuse 

593 # existing BinaryPackage instances to reduce memory usage. 

594 self._merge_pkg_entries(pkg, arch, old_binary, dpkg) 

595 packages[pkg] = old_binary 

596 else: 

597 packages[pkg] = dpkg 

598 all_binaries[pkg_id] = dpkg 

599 

600 return packages 

601 

602 def _read_binaries( 

603 self, suite: Suite, architectures: Iterable[str] 

604 ) -> tuple[ 

605 dict[str, dict[str, BinaryPackage]], dict[str, dict[str, set[tuple[str, str]]]] 

606 ]: 

607 """Read the list of binary packages from the specified directory 

608 

609 This method reads all the binary packages for a given suite. 

610 

611 If the "components" config parameter is set, the directory should 

612 be the "suite" directory of a local mirror (i.e. the one containing 

613 the "Release" file). Otherwise, Britney will read the packages 

614 information from all the "Packages_${arch}" files referenced by 

615 the "architectures" parameter. 

616 

617 Considering the 

618 large amount of memory needed, not all the fields are loaded 

619 in memory. The available fields are Version, Source, Multi-Arch, 

620 Depends, Conflicts, Provides and Architecture. 

621 

622 The `Provides' field is used to populate the virtual packages list. 

623 

624 The method returns a tuple of two dicts with architecture as key and 

625 another dict as value. The value dicts of the first dict map 

626 from binary package name to "BinaryPackage" objects; the other second 

627 value dicts map a package name to the packages providing them. 

628 """ 

629 binaries: dict[str, dict[str, BinaryPackage]] = {} 

630 provides_table: dict[str, dict[str, set[tuple[str, str]]]] = {} 

631 basedir = suite.path 

632 

633 if self._components: 

634 release_file = read_release_file(basedir) 

635 listed_archs = set(release_file["Architectures"].split()) 

636 for arch in architectures: 

637 packages: dict[str, BinaryPackage] = {} 

638 if arch not in listed_archs: 638 ↛ 639line 638 didn't jump to line 639 because the condition on line 638 was never true

639 self.logger.info( 

640 "Skipping arch %s for %s: It is not listed in the Release file", 

641 arch, 

642 suite.name, 

643 ) 

644 binaries[arch] = {} 

645 provides_table[arch] = {} 

646 continue 

647 for component in self._components: 

648 binary_dir = f"binary-{arch}" 

649 filename = os.path.join(basedir, component, binary_dir, "Packages") 

650 try: 

651 filename = possibly_compressed(filename) 

652 except FileNotFoundError: 

653 if component == "non-free-firmware": 

654 self.logger.info( 

655 "Skipping %s as it doesn't exist", filename 

656 ) 

657 continue 

658 raise 

659 udeb_filename = os.path.join( 

660 basedir, component, "debian-installer", binary_dir, "Packages" 

661 ) 

662 # We assume the udeb Packages file is present if the 

663 # regular one is present 

664 udeb_filename = possibly_compressed(udeb_filename) 

665 self._read_packages_file(filename, arch, suite.sources, packages) 

666 self._read_packages_file( 

667 udeb_filename, arch, suite.sources, packages 

668 ) 

669 # create provides 

670 provides = create_provides_map(packages) 

671 binaries[arch] = packages 

672 provides_table[arch] = provides 

673 else: 

674 for arch in architectures: 

675 filename = os.path.join(basedir, f"Packages_{arch}") 

676 packages = self._read_packages_file(filename, arch, suite.sources) 

677 provides = create_provides_map(packages) 

678 binaries[arch] = packages 

679 provides_table[arch] = provides 

680 

681 return (binaries, provides_table) 

682 

683 def _merge_pkg_entries( 

684 self, 

685 package: str, 

686 parch: str, 

687 pkg_entry1: BinaryPackage, 

688 pkg_entry2: BinaryPackage, 

689 ) -> None: 

690 bad = [] 

691 for f in self.CHECK_FIELDS: 

692 v1 = getattr(pkg_entry1, f) 

693 v2 = getattr(pkg_entry2, f) 

694 if v1 != v2: # pragma: no cover 

695 bad.append((f, v1, v2)) 

696 

697 if bad: # pragma: no cover 

698 self.logger.error( 

699 "Mismatch found %s %s %s differs", package, pkg_entry1.version, parch 

700 ) 

701 for f, v1, v2 in bad: 

702 self.logger.info(" ... %s %s != %s", f, v1, v2) 

703 raise ValueError("Inconsistent / Unsupported data set") 

704 

705 # Merge ESSENTIAL if necessary 

706 assert pkg_entry1.is_essential or not pkg_entry2.is_essential