Coverage for britney2/installability/builder.py: 99%

181 statements  

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

1# Copyright (C) 2012 Niels Thykier <niels@thykier.net> 

2 

3# This program is free software; you can redistribute it and/or modify 

4# it under the terms of the GNU General Public License as published by 

5# the Free Software Foundation; either version 2 of the License, or 

6# (at your option) any later version. 

7 

8# This program is distributed in the hope that it will be useful, 

9# but WITHOUT ANY WARRANTY; without even the implied warranty of 

10# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

11# GNU General Public License for more details. 

12 

13from collections import defaultdict 

14from collections.abc import Iterable 

15from functools import partial 

16from itertools import filterfalse, product 

17from typing import ( 

18 TYPE_CHECKING, 

19 Any, 

20 TypeVar, 

21) 

22 

23import apt_pkg 

24from more_itertools import iter_except 

25 

26from britney2.installability.tester import InstallabilityTester 

27from britney2.installability.universe import ( 

28 BinaryPackageRelation, 

29 BinaryPackageUniverse, 

30) 

31from britney2.utils import get_dependency_solvers, ifilter_except 

32 

33if TYPE_CHECKING: 33 ↛ 34line 33 didn't jump to line 34 because the condition on line 33 was never true

34 from .. import BinaryPackage, BinaryPackageId, PackageId, Suite, Suites 

35 

36 

37def build_installability_tester( 

38 suite_info: "Suites", archs: list[str] 

39) -> tuple[BinaryPackageUniverse, InstallabilityTester]: 

40 """Create the installability tester""" 

41 

42 builder = InstallabilityTesterBuilder() 

43 

44 for suite, arch in product(suite_info, archs): 

45 _build_inst_tester_on_suite_arch(builder, suite_info, suite, arch) 

46 

47 return builder.build() 

48 

49 

50def _build_inst_tester_on_suite_arch( 

51 builder: "InstallabilityTesterBuilder", 

52 suite_info: "Suites", 

53 suite: "Suite", 

54 arch: str, 

55) -> None: 

56 packages_s_a: dict[str, "BinaryPackage"] = suite.binaries[arch] 

57 is_target: bool = suite.suite_class.is_target 

58 bin_prov: list[ 

59 tuple[dict[str, "BinaryPackage"], dict[str, set[tuple[str, str]]]] 

60 ] = [(s.binaries[arch], s.provides_table[arch]) for s in suite_info] 

61 solvers = get_dependency_solvers 

62 for pkgdata in packages_s_a.values(): 

63 pkg_id: "BinaryPackageId" = pkgdata.pkg_id 

64 if not builder.add_binary( 

65 pkg_id, essential=pkgdata.is_essential, in_testing=is_target 

66 ): 

67 continue 

68 

69 conflicts: list["BinaryPackageId"] | None = None 

70 if pkgdata.conflicts: 

71 conflicts = [] 

72 conflicts_parsed = apt_pkg.parse_depends(pkgdata.conflicts, False) 

73 # Breaks/Conflicts are so simple that we do not need to keep align the relation 

74 # with the suite. This enables us to do a few optimizations. 

75 for dep_binaries_s_a, dep_provides_s_a in bin_prov: 

76 for block in conflicts_parsed: 

77 # if a package satisfies its own conflicts relation, then it is using §7.6.2 

78 conflicts.extend( 

79 s.pkg_id 

80 for s in solvers(block, dep_binaries_s_a, dep_provides_s_a) 

81 if s.pkg_id != pkg_id 

82 ) 

83 

84 if pkgdata.depends: 

85 depends = _compute_depends(pkgdata, bin_prov) 

86 else: 

87 depends = None 

88 

89 builder.set_relations(pkg_id, depends, conflicts) 

90 

91 

92def _compute_depends( 

93 pkgdata: "BinaryPackage", 

94 bin_prov: list[tuple[dict[str, "BinaryPackage"], dict[str, set[tuple[str, str]]]]], 

95) -> list[frozenset["BinaryPackageId"]]: 

96 depends: list[frozenset["BinaryPackageId"]] = [] 

97 possible_dep_ranges: dict[str, set["BinaryPackageId"]] = {} 

98 solvers = get_dependency_solvers 

99 assert pkgdata.depends is not None 

100 for block in apt_pkg.parse_depends(pkgdata.depends, False): 

101 sat = { 

102 s.pkg_id 

103 for binaries_s_a, provides_s_a in bin_prov 

104 for s in solvers(block, binaries_s_a, provides_s_a) 

105 } 

106 

107 if len(block) != 1: 

108 depends.append(frozenset(sat)) 

109 else: 

110 # This dependency might be a part 

111 # of a version-range a la: 

112 # 

113 # Depends: pkg-a (>= 1), 

114 # pkg-a (<< 2~) 

115 # 

116 # In such a case we want to reduce 

117 # that to a single clause for 

118 # efficiency. 

119 # 

120 # In theory, it could also happen 

121 # with "non-minimal" dependencies 

122 # a la: 

123 # 

124 # Depends: pkg-a, pkg-a (>= 1) 

125 # 

126 # But dpkg is known to fix that up 

127 # at build time, so we will 

128 # probably only see "ranges" here. 

129 key = block[0][0] 

130 if (dep_range := possible_dep_ranges.get(key)) is not None: 

131 dep_range &= sat 

132 else: 

133 possible_dep_ranges[key] = sat 

134 

135 if possible_dep_ranges: 

136 depends.extend(map(frozenset, possible_dep_ranges.values())) 

137 

138 return depends 

139 

140 

141_T = TypeVar("_T") 

142 

143 

144class InstallabilityTesterBuilder: 

145 """Builder to create instances of InstallabilityTester""" 

146 

147 def __init__(self) -> None: 

148 self._package_table: dict[ 

149 "BinaryPackageId", 

150 tuple[ 

151 frozenset[frozenset["BinaryPackageId"]], frozenset["BinaryPackageId"] 

152 ], 

153 ] = {} 

154 self._reverse_package_table: dict[ 

155 "BinaryPackageId", 

156 tuple[ 

157 set["BinaryPackageId"], 

158 set["BinaryPackageId"], 

159 set[frozenset["PackageId"]], 

160 ], 

161 ] = {} 

162 self._essentials: set["BinaryPackageId"] = set() 

163 self._testing: set["BinaryPackageId"] = set() 

164 self._internmap: dict[Any, frozenset[Any]] = {} 

165 self._broken: set["BinaryPackageId"] = set() 

166 self._empty_set: frozenset[Any] = self._intern_set(frozenset()) 

167 

168 def add_binary( 

169 self, 

170 binary: "BinaryPackageId", 

171 essential: bool = False, 

172 in_testing: bool = False, 

173 ) -> bool: 

174 """Add a new binary package 

175 

176 Adds a new binary package. The binary must be given as a 

177 (name, version, architecture)-tuple. Returns True if this 

178 binary is new (i.e. has never been added before) or False 

179 otherwise. 

180 

181 Keyword arguments: 

182 * essential - Whether this package is "Essential: yes". 

183 * in_testing - Whether this package is in testing. 

184 

185 The frozenset argument is a private optimisation. 

186 

187 Cave-at: arch:all packages should be "re-mapped" to given 

188 architecture. That is, (pkg, version, "all") should be 

189 added as: 

190 

191 for arch in architectures: 

192 binary = (pkg, version, arch) 

193 it.add_binary(binary) 

194 

195 The resulting InstallabilityTester relies on this for 

196 correctness! 

197 """ 

198 # Note, even with a dup, we need to do these 

199 if in_testing: 

200 self._testing.add(binary) 

201 if essential: 

202 self._essentials.add(binary) 

203 

204 if binary not in self._package_table: 

205 # Allow binaries to be added multiple times (happens 

206 # when sid and testing have the same version) 

207 self._package_table[binary] = (frozenset(), frozenset()) 

208 return True 

209 return False 

210 

211 def set_relations( 

212 self, 

213 pkg_id: "BinaryPackageId", 

214 dependency_clauses: Iterable[frozenset["BinaryPackageId"]] | None, 

215 breaks: Iterable["BinaryPackageId"] | None, 

216 ) -> None: 

217 """The dependency and breaks relations for a given package 

218 

219 :param pkg_id: determines which package will have its relations set 

220 :param dependency_clauses: A list/set of OR clauses (i.e. CNF with each element in 

221 dependency_clauses being a disjunction). Each OR cause (disjunction) should be a 

222 set/list of BinaryPackageIDs that satisfy that relation. 

223 :param breaks: An list/set of BinaryPackageIDs that has a Breaks/Conflicts relation 

224 on the current package. Can be None 

225 """ 

226 if dependency_clauses is not None: 

227 interned_or_clauses: frozenset[frozenset["BinaryPackageId"]] = ( 

228 self._intern_set(self._intern_set(c) for c in dependency_clauses) 

229 ) 

230 satisfiable = True 

231 for or_clause in interned_or_clauses: 

232 if not or_clause: 

233 satisfiable = False 

234 for dep_tuple in or_clause: 

235 rdeps, _, rdep_relations = self._reverse_relations(dep_tuple) 

236 rdeps.add(pkg_id) 

237 rdep_relations.add(or_clause) 

238 

239 if not satisfiable: 

240 self._broken.add(pkg_id) 

241 else: 

242 interned_or_clauses = self._empty_set 

243 

244 if breaks is not None: 

245 # Breaks 

246 breaks_relations = self._intern_set(breaks) 

247 for broken_binary in breaks_relations: 

248 reverse_relations = self._reverse_relations(broken_binary) 

249 reverse_relations[1].add(pkg_id) 

250 else: 

251 breaks_relations = self._empty_set 

252 

253 self._package_table[pkg_id] = (interned_or_clauses, breaks_relations) 

254 

255 def _intern_set(self, s: Iterable[_T]) -> frozenset[_T]: 

256 """Freeze and intern a given sequence (set variant of intern()) 

257 

258 Given a sequence, create a frozenset copy (if it is not 

259 already a frozenset) and intern that frozen set. Returns the 

260 interned set. 

261 

262 At first glance, interning sets may seem absurd. However, 

263 it does enable memory savings of up to 600MB when applied 

264 to the "inner" sets of the dependency clauses and all the 

265 conflicts relations as well. 

266 """ 

267 if isinstance(s, frozenset): 

268 fset = s 

269 else: 

270 fset = frozenset(s) 

271 if (r := self._internmap.get(fset)) is not None: 

272 return r 

273 self._internmap[fset] = fset 

274 return fset 

275 

276 def _reverse_relations(self, binary: "BinaryPackageId") -> tuple[ 

277 set["BinaryPackageId"], 

278 set["BinaryPackageId"], 

279 set[frozenset["PackageId"]], 

280 ]: 

281 """Return the reverse relations for a binary 

282 

283 Fetch the reverse relations for a given binary, which are 

284 created lazily. 

285 """ 

286 

287 if (relations := self._reverse_package_table.get(binary)) is not None: 

288 return relations 

289 rel: tuple[ 

290 set["BinaryPackageId"], 

291 set["BinaryPackageId"], 

292 set[frozenset["PackageId"]], 

293 ] = (set(), set(), set()) 

294 self._reverse_package_table[binary] = rel 

295 return rel 

296 

297 def build(self) -> tuple[BinaryPackageUniverse, InstallabilityTester]: 

298 """Compile the installability tester 

299 

300 This method will compile an installability tester from the 

301 information given and (where possible) try to optimise a 

302 few things. 

303 """ 

304 package_table = self._package_table 

305 reverse_package_table = self._reverse_package_table 

306 intern_set = self._intern_set 

307 broken = self._broken 

308 not_broken: partial[filterfalse["BinaryPackageId"]] = ifilter_except(broken) 

309 

310 # Merge reverse conflicts with conflicts - this saves some 

311 # operations in _check_loop since we only have to check one 

312 # set (instead of two) and we remove a few duplicates here 

313 # and there. 

314 # 

315 # At the same time, intern the rdep sets 

316 for pkg in reverse_package_table: 

317 assert pkg in package_table, f"{str(pkg)} referenced but not added!" 

318 

319 deps, con = package_table[pkg] 

320 rdeps, rcon, rdep_relations = reverse_package_table[pkg] 

321 if rcon: 

322 if not con: 

323 con = intern_set(rcon) 

324 else: 

325 con = intern_set(con | rcon) 

326 package_table[pkg] = (deps, con) 

327 reverse_package_table[pkg] = ( 

328 intern_set(rdeps), 

329 con, # type: ignore[assignment] 

330 intern_set(rdep_relations), 

331 ) 

332 # this is not great, sometimes self._reverse_package_table returns 

333 # frozensets, sometimes it does not 

334 

335 # Check if we can expand broken. 

336 check = set() 

337 for b in broken: 

338 if (rev := reverse_package_table.get(b)) is not None: 

339 check.update(rev[0] - broken) 

340 for t in not_broken(iter_except(check.pop, KeyError)): 

341 # This package is not known to be broken... but it might be now 

342 isb = False 

343 for depgroup in package_table[t][0]: 

344 if not any(not_broken(depgroup)): 

345 # A single clause is unsatisfiable, the 

346 # package can never be installed - add it to 

347 # broken. 

348 isb = True 

349 break 

350 

351 if not isb: 

352 continue 

353 

354 broken.add(t) 

355 

356 if (rev := reverse_package_table.get(t)) is not None: 356 ↛ 357line 356 didn't jump to line 357 because the condition on line 356 was never true

357 check.update(rev[0] - broken) 

358 

359 if broken: 

360 # Since a broken package will never be installable, nothing that depends on it 

361 # will ever be installable. Thus, there is no point in keeping relations on 

362 # the broken package. 

363 seen = set() 

364 empty_set: frozenset[Any] = frozenset() 

365 null_data = (frozenset([empty_set]), empty_set) 

366 for b in (x for x in broken if x in reverse_package_table): 

367 for rdep in ( 

368 r for r in not_broken(reverse_package_table[b][0]) if r not in seen 

369 ): 

370 ndep = intern_set((x - broken) for x in package_table[rdep][0]) 

371 package_table[rdep] = (ndep, package_table[rdep][1] - broken) 

372 seen.add(rdep) 

373 

374 # Since they won't affect the installability of any other package, we might as 

375 # as well null their data. This memory for these packages, but likely there 

376 # will only be a handful of these "at best" (fsvo of "best") 

377 for b in broken: 

378 package_table[b] = null_data 

379 if b in reverse_package_table: 

380 del reverse_package_table[b] 

381 

382 relations, eqv_set = self._build_relations_and_eqv_packages_set( 

383 package_table, reverse_package_table 

384 ) 

385 

386 universe = BinaryPackageUniverse( 

387 relations, 

388 intern_set(self._essentials), 

389 intern_set(broken), 

390 intern_set(eqv_set), 

391 ) 

392 

393 solver = InstallabilityTester(universe, self._testing) 

394 

395 return universe, solver 

396 

397 def _build_relations_and_eqv_packages_set( 

398 self, 

399 package_table: dict[ 

400 "BinaryPackageId", 

401 tuple[ 

402 frozenset[frozenset["BinaryPackageId"]], frozenset["BinaryPackageId"] 

403 ], 

404 ], 

405 reverse_package_table: dict[ 

406 "BinaryPackageId", 

407 tuple[ 

408 set["BinaryPackageId"], 

409 set["BinaryPackageId"], 

410 set[frozenset["PackageId"]], 

411 ], 

412 ], 

413 ) -> tuple[ 

414 dict["BinaryPackageId", "BinaryPackageRelation"], set["BinaryPackageId"] 

415 ]: 

416 """Attempt to build a table of equivalent packages 

417 

418 This method attempts to create a table of packages that are 

419 equivalent (in terms of installability). If two packages (A 

420 and B) are equivalent then testing the installability of A is 

421 the same as testing the installability of B. This equivalency 

422 also applies to co-installability. 

423 

424 The example cases: 

425 * aspell-* 

426 * ispell-* 

427 

428 Cases that do *not* apply: 

429 * MTA's 

430 

431 The theory: 

432 

433 The packages A and B are equivalent iff: 

434 

435 reverse_depends(A) == reverse_depends(B) AND 

436 conflicts(A) == conflicts(B) AND 

437 depends(A) == depends(B) 

438 

439 Where "reverse_depends(X)" is the set of reverse dependencies 

440 of X, "conflicts(X)" is the set of negative dependencies of X 

441 (Breaks and Conflicts plus the reverse ones of those combined) 

442 and "depends(X)" is the set of strong dependencies of X 

443 (Depends and Pre-Depends combined). 

444 

445 To be honest, we are actually equally interested another 

446 property as well, namely substitutability. The package A can 

447 always used instead of B, iff: 

448 

449 reverse_depends(A) >= reverse_depends(B) AND 

450 conflicts(A) <= conflicts(B) AND 

451 depends(A) == depends(B) 

452 

453 (With the same definitions as above). Note that equivalency 

454 is just a special-case of substitutability, where A and B can 

455 substitute each other (i.e. a two-way substitution). 

456 

457 Finally, note that the "depends(A) == depends(B)" for 

458 substitutability is actually not a strict requirement. There 

459 are cases where those sets are different without affecting the 

460 property. 

461 """ 

462 # Despite talking about substitutability, the method currently 

463 # only finds the equivalence cases. Lets leave 

464 # substitutability for a future version. 

465 

466 find_eqv_set: dict[ 

467 tuple[ 

468 frozenset[frozenset["BinaryPackageId"]], 

469 frozenset["BinaryPackageId"], 

470 set[frozenset["PackageId"]], 

471 ], 

472 list["BinaryPackageId"], 

473 ] = defaultdict(list) 

474 eqv_set = set() 

475 relations = {} 

476 intern_set = self._intern_set 

477 

478 for pkg_r, (_, _, rdeps_r) in reverse_package_table.items(): 

479 if not rdeps_r: 

480 # we don't care for things without rdeps (because 

481 # it is not worth it) 

482 continue 

483 deps, con = package_table[pkg_r] 

484 ekey = (deps, con, rdeps_r) 

485 find_eqv_set[ekey].append(pkg_r) 

486 

487 for pkg_relations, pkg_list in find_eqv_set.items(): 

488 rdeps_e = reverse_package_table[pkg_list[0]][0] 

489 rel = BinaryPackageRelation( 

490 intern_set(pkg_list), 

491 pkg_relations[0], 

492 pkg_relations[1] or None, 

493 rdeps_e or None, 

494 ) 

495 if len(pkg_list) < 2: 

496 relations[pkg_list[0]] = rel 

497 continue 

498 

499 eqv_set.update(pkg_list) 

500 for pkg_e in pkg_list: 

501 relations[pkg_e] = rel 

502 

503 for pkg_f, forward_relations in package_table.items(): 

504 if pkg_f in relations: 

505 continue 

506 rel = BinaryPackageRelation( 

507 intern_set((pkg_f,)), 

508 forward_relations[0], 

509 forward_relations[1] or None, 

510 None, 

511 ) 

512 relations[pkg_f] = rel 

513 

514 return relations, eqv_set