Coverage for britney2/excuse.py: 97%

321 statements  

« prev     ^ index     » next       coverage.py v7.6.0, created at 2026-06-17 09:00 +0000

1# Copyright (C) 2001-2004 Anthony Towns <ajt@debian.org> 

2# Andreas Barth <aba@debian.org> 

3# Fabio Tranchitella <kobold@debian.org> 

4 

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

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

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

8# (at your option) any later version. 

9 

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

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

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

13# GNU General Public License for more details. 

14 

15import re 

16from collections import defaultdict 

17from dataclasses import dataclass 

18from typing import TYPE_CHECKING, Any, Union, cast 

19 

20from britney2 import BinaryPackageId, DependencyType, PackageId 

21from britney2.excusedeps import ( 

22 DependencySpec, 

23 DependencyState, 

24 ImpossibleDependencyState, 

25) 

26from britney2.migrationitem import MigrationItem 

27from britney2.policies import PolicyVerdict 

28 

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

30 from .hints import Hint 

31 

32ExcusesType = Union[dict[PackageId, "Excuse"], dict[str, "Excuse"]] 

33 

34 

35VERDICT2DESC = { 

36 PolicyVerdict.PASS: "Will attempt migration (Any information below is purely informational)", 

37 PolicyVerdict.PASS_HINTED: "Will attempt migration due to a hint (Any information below is purely informational)", 

38 PolicyVerdict.REJECTED_TEMPORARILY: ( 

39 "Waiting for test results or another package, or too young (no action " 

40 "required now - check later)" 

41 ), 

42 PolicyVerdict.REJECTED_WAITING_FOR_ANOTHER_ITEM: ( 

43 "Waiting for another item to be ready to migrate (no action required now " 

44 "- check later)" 

45 ), 

46 PolicyVerdict.REJECTED_BLOCKED_BY_ANOTHER_ITEM: ( 

47 "BLOCKED: Cannot migrate due to another item, which is blocked (please " 

48 "check which dependencies are stuck)" 

49 ), 

50 PolicyVerdict.REJECTED_NEEDS_APPROVAL: ( 

51 "BLOCKED: Needs an approval (either due to a freeze, the source suite or " 

52 "a manual hint)" 

53 ), 

54 PolicyVerdict.REJECTED_CANNOT_DETERMINE_IF_PERMANENT: ( 

55 "BLOCKED: Maybe temporary, maybe blocked but Britney is missing information " 

56 "(check below)" 

57 ), 

58 PolicyVerdict.REJECTED_PERMANENTLY: "BLOCKED: Rejected/violates migration policy/introduces a regression", 

59} 

60 

61 

62@dataclass(slots=True) 

63class ExcuseDependency: 

64 """Object to represent a specific dependency of an excuse on a package 

65 (source or binary) or on other excuses""" 

66 

67 spec: DependencySpec 

68 # list of DependencyState, each of which can satisfy the dependency 

69 depstates: list[DependencyState] 

70 

71 @property 

72 def deptype(self) -> DependencyType: 

73 return self.spec.deptype 

74 

75 @property 

76 def valid(self) -> bool: 

77 return any(d for d in self.depstates if d.valid) 

78 

79 @property 

80 def deps(self) -> set[str | PackageId | None]: 

81 return {d.dep for d in self.depstates} 

82 

83 @property 

84 def possible(self) -> bool: 

85 return any(d for d in self.depstates if d.possible) 

86 

87 @property 

88 def first_dep(self) -> str | PackageId | None: 

89 """return the first valid dependency, if there is one, otherwise the 

90 first possible one 

91 

92 return None if there are only impossible dependencies 

93 """ 

94 first = None 

95 for d in self.depstates: 

96 if d.valid: 

97 return d.dep 

98 elif d.possible and not first: 

99 first = d.dep 

100 return first 

101 

102 @property 

103 def first_impossible_dep(self) -> str | None: 

104 """return the first impossible dependency, if there is one""" 

105 first = None 

106 for d in self.depstates: 106 ↛ 110line 106 didn't jump to line 110 because the loop on line 106 didn't complete

107 if not d.possible: 107 ↛ 106line 107 didn't jump to line 106 because the condition on line 107 was always true

108 assert isinstance(d, ImpossibleDependencyState) # for type checking 

109 return d.desc 

110 return first 

111 

112 @property 

113 def verdict(self) -> PolicyVerdict: 

114 return min({d.verdict for d in self.depstates}) 

115 

116 def invalidate(self, excuse: str, verdict: PolicyVerdict) -> bool: 

117 """invalidate the dependencies on a specific excuse 

118 

119 :param excuse: the excuse which is no longer valid 

120 :param verdict: the PolicyVerdict causing the invalidation 

121 """ 

122 valid_alternative_left = False 

123 for ds in self.depstates: 

124 if ds.dep == excuse: 

125 ds.invalidate(verdict) 

126 elif ds.valid: 

127 valid_alternative_left = True 

128 

129 return valid_alternative_left 

130 

131 

132class Excuse: 

133 """Excuse class 

134 

135 This class represents an update excuse, which is a detailed explanation 

136 of why a package can or cannot be updated in the testing distribution from 

137 a newer package in another distribution (like for example unstable). 

138 

139 The main purpose of the excuses is to be written in an HTML file which 

140 will be published over HTTP. The maintainers will be able to parse it 

141 manually or automatically to find the explanation of why their packages 

142 have been updated or not. 

143 """ 

144 

145 # @var reemail 

146 # Regular expression for removing the email address 

147 reemail = re.compile(r" *<.*?>") 

148 

149 def __init__(self, migrationitem: MigrationItem) -> None: 

150 """Class constructor 

151 

152 This method initializes the excuse with the specified name and 

153 the default values. 

154 """ 

155 self.item = migrationitem 

156 self.ver = ("-", "-") 

157 self.maint: str | None = None 

158 self.daysold: int | None = None 

159 self.mindays: int | None = None 

160 self.section: str | None = None 

161 self._is_valid = False 

162 self.needs_approval = False 

163 self.hints: list["Hint"] = [] 

164 self.forced = False 

165 self._policy_verdict = PolicyVerdict.REJECTED_PERMANENTLY 

166 

167 self.all_deps: list[ExcuseDependency] = [] 

168 self.unsatisfiable_on_archs: list[str] = [] 

169 self.unsat_deps: dict[str, set[str]] = defaultdict(set) 

170 self.newbugs: set[str] = set() 

171 self.oldbugs: set[str] = set() 

172 self.reason: set[str] = set() 

173 self.missing_builds: set[str] = set() 

174 self.missing_builds_ood_arch: set[str] = set() 

175 self.old_binaries: dict[str, set[str]] = defaultdict(set) 

176 self.policy_info: dict[str, dict[str, Any]] = {} 

177 self.verdict_info: dict[PolicyVerdict, list[str]] = defaultdict(list) 

178 self.infoline: list[str] = [] 

179 self.detailed_info: list[str] = [] 

180 self.dep_info_rendered: bool = False 

181 

182 # packages (source and binary) that will migrate to testing if the 

183 # item from this excuse migrates 

184 self.packages: dict[str, set[PackageId]] = defaultdict(set) 

185 

186 # list of ExcuseDependency, with dependencies on packages 

187 self.depends_packages: list[ExcuseDependency] = [] 

188 # contains all PackageIds in any over the sets above 

189 self.depends_packages_flattened: set[PackageId] = set() 

190 

191 self.bounty: dict[str, int] = {} 

192 self.penalty: dict[str, int] = {} 

193 

194 # messenger from AutopkgtestPolicy to BlockPolicy 

195 self.autopkgtest_results: set[str] | None = None 

196 

197 def sortkey(self) -> tuple[int, str]: 

198 if self.daysold is None: 

199 return (-1, self.uvname) 

200 return (self.daysold, self.uvname) 

201 

202 @property 

203 def name(self) -> str: 

204 assert isinstance(self.item, MigrationItem) 

205 return self.item.name 

206 

207 @property 

208 def uvname(self) -> str: 

209 assert isinstance(self.item, MigrationItem) 

210 return self.item.uvname 

211 

212 @property 

213 def source(self) -> str: 

214 assert isinstance(self.item, MigrationItem) 

215 return self.item.package 

216 

217 @property 

218 def is_valid(self) -> bool: 

219 return False if self._policy_verdict.is_rejected else True 

220 

221 @property 

222 def policy_verdict(self) -> PolicyVerdict: 

223 return self._policy_verdict 

224 

225 @policy_verdict.setter 

226 def policy_verdict(self, value: PolicyVerdict) -> None: 

227 if value.is_rejected and self.forced: 227 ↛ 230line 227 didn't jump to line 230 because the condition on line 227 was never true

228 # By virtue of being forced, the item was hinted to 

229 # undo the rejection 

230 value = PolicyVerdict.PASS_HINTED 

231 self._policy_verdict = value 

232 

233 def set_vers(self, tver: str | None, uver: str | None) -> None: 

234 """Set the versions of the item from target and source suite""" 

235 if tver and uver: 

236 self.ver = (tver, uver) 

237 elif tver: 

238 self.ver = (tver, self.ver[1]) 

239 elif uver: 239 ↛ exitline 239 didn't return from function 'set_vers' because the condition on line 239 was always true

240 self.ver = (self.ver[0], uver) 

241 

242 def set_maint(self, maint: str) -> None: 

243 """Set the package maintainer's name""" 

244 self.maint = self.reemail.sub("", maint) 

245 

246 def set_section(self, section: str) -> None: 

247 """Set the section of the package""" 

248 self.section = section 

249 

250 def add_dependency( 

251 self, dep: set[str | DependencyState], spec: DependencySpec 

252 ) -> bool: 

253 """Add a dependency of type deptype 

254 

255 :param dep: set with names of excuses, each of which satisfies the dep 

256 :param spec: DependencySpec 

257 

258 """ 

259 

260 assert dep, "%s: Adding empty list of dependencies" % self.name 

261 

262 deps: list[DependencyState] = [] 

263 try: 

264 # Casting to a sorted list makes excuses more 

265 # deterministic, but fails if the list has more than one 

266 # element *and* at least one DependencyState 

267 dep = sorted(dep) # type: ignore[type-var,assignment] 

268 except TypeError: 

269 pass 

270 for d in dep: 

271 if isinstance(d, DependencyState): 

272 deps.append(d) 

273 else: 

274 deps.append(DependencyState(d)) 

275 ed = ExcuseDependency(spec, deps) 

276 self.all_deps.append(ed) 

277 if not ed.valid: 

278 self.do_invalidate(ed) 

279 return ed.valid 

280 

281 def get_deps(self) -> set[str]: 

282 # the autohinter uses the excuses data to query dependencies between 

283 # excuses. For now, we keep the current behaviour by just returning 

284 # the data that was in the old deps set 

285 """Get the dependencies of type DEPENDS""" 

286 deps: set[str] = set() 

287 for dep in (d for d in self.all_deps if d.deptype is DependencyType.DEPENDS): 

288 # add the first valid dependency 

289 for d in dep.depstates: 289 ↛ 287line 289 didn't jump to line 287 because the loop on line 289 didn't complete

290 if d.valid: 290 ↛ 289line 290 didn't jump to line 289 because the condition on line 290 was always true

291 deps.add(cast(str, d.dep)) 

292 break 

293 return deps 

294 

295 def add_unsatisfiable_on_arch(self, arch: str) -> None: 

296 """Add an arch that has unsatisfiable dependencies""" 

297 if arch not in self.unsatisfiable_on_archs: 

298 self.unsatisfiable_on_archs.append(arch) 

299 

300 def add_unsatisfiable_dep(self, signature: str, arch: str) -> None: 

301 """Add an unsatisfiable dependency""" 

302 self.unsat_deps[arch].add(signature) 

303 

304 def do_invalidate(self, dep: ExcuseDependency) -> None: 

305 """ 

306 param: dep: ExcuseDependency 

307 """ 

308 self.addreason(dep.deptype.get_reason()) 

309 self.policy_verdict = PolicyVerdict.worst_of(self.policy_verdict, dep.verdict) 

310 

311 def invalidate_dependency(self, name: str, verdict: PolicyVerdict) -> bool: 

312 """Invalidate dependency""" 

313 invalidate = False 

314 

315 for dep in self.all_deps: 

316 if not dep.invalidate(name, verdict): 

317 invalidate = True 

318 self.do_invalidate(dep) 

319 

320 return not invalidate 

321 

322 def setdaysold(self, daysold: int, mindays: int) -> None: 

323 """Set the number of days from the upload and the minimum number of days for the update""" 

324 self.daysold = daysold 

325 self.mindays = mindays 

326 

327 def force(self) -> bool: 

328 """Add force hint""" 

329 self.forced = True 

330 if self._policy_verdict.is_rejected: 

331 self._policy_verdict = PolicyVerdict.PASS_HINTED 

332 return True 

333 return False 

334 

335 def addinfo(self, note: str) -> None: 

336 """Add a note in HTML""" 

337 self.infoline.append(note) 

338 

339 def add_verdict_info(self, verdict: PolicyVerdict, note: str) -> None: 

340 """Add a note to info about this verdict level""" 

341 self.verdict_info[verdict].append(note) 

342 

343 def add_detailed_info(self, note: str) -> None: 

344 """Add a note to detailed info""" 

345 self.detailed_info.append(note) 

346 

347 def missing_build_on_arch(self, arch: str) -> None: 

348 """Note that the item is missing a build on a given architecture""" 

349 self.missing_builds.add(arch) 

350 

351 def missing_build_on_ood_arch(self, arch: str) -> None: 

352 """Note that the item is missing a build on a given "out of date" architecture""" 

353 self.missing_builds_ood_arch.add(arch) 

354 

355 def add_old_binary(self, binary: str, from_source_version: str) -> None: 

356 """Denote than an old binary ("cruft") is available from a previous source version""" 

357 self.old_binaries[from_source_version].add(binary) 

358 

359 def add_hint(self, hint: "Hint") -> None: 

360 self.hints.append(hint) 

361 

362 def add_package(self, pkg_id: PackageId) -> None: 

363 self.packages[pkg_id.architecture].add(pkg_id) 

364 

365 def add_package_depends( 

366 self, 

367 spec: DependencySpec, 

368 depends: set[PackageId] | set[BinaryPackageId], 

369 ) -> None: 

370 """Add dependency on a package (source or binary) 

371 

372 :param spec: DependencySpec 

373 :param depends: set of PackageIds (source or binary), each of which can satisfy the dependency 

374 """ 

375 

376 assert depends != cast(set[PackageId], frozenset()), ( 

377 "%s: Adding empty list of package dependencies" % self.name 

378 ) 

379 

380 # we use DependencyState for consistency with excuse dependencies, but 

381 # package dependencies are never invalidated, they are used to add 

382 # excuse dependencies (in invalidate_excuses()), and these are 

383 # (potentially) invalidated 

384 ed = ExcuseDependency(spec, [DependencyState(d) for d in depends]) 

385 self.depends_packages.append(ed) 

386 self.depends_packages_flattened |= depends 

387 

388 def _format_verdict_summary(self) -> str: 

389 verdict = self._policy_verdict 

390 if verdict in VERDICT2DESC: 390 ↛ 392line 390 didn't jump to line 392 because the condition on line 390 was always true

391 return VERDICT2DESC[verdict] 

392 return ( 

393 f"UNKNOWN: Missing description for {verdict.name} - " 

394 "Please file a bug against Britney" 

395 ) 

396 

397 def _render_dep_issues(self, excuses: ExcusesType) -> None: 

398 if self.dep_info_rendered: 

399 return 

400 

401 dep_issues = defaultdict(set) 

402 for d in self.all_deps: 

403 info = "" 

404 if not d.possible: 

405 desc = d.first_impossible_dep 

406 info = f"Impossible {d.deptype}: {self.uvname} -> {desc}" 

407 else: 

408 dep = d.first_dep 

409 duv = excuses[dep].uvname # type: ignore[index] 

410 # Make sure we link to package names 

411 duv_src = duv.split("/")[0] 

412 verdict = excuses[dep].policy_verdict # type: ignore[index] 

413 if not d.valid or verdict in ( 

414 PolicyVerdict.REJECTED_NEEDS_APPROVAL, 

415 PolicyVerdict.REJECTED_CANNOT_DETERMINE_IF_PERMANENT, 

416 PolicyVerdict.REJECTED_PERMANENTLY, 

417 ): 

418 info = ( 

419 f"{d.deptype}: {self.uvname} " 

420 f'<a href="#{duv_src}">{duv}</a> ' 

421 "(not considered)" 

422 ) 

423 if not d.valid: 

424 dep_issues[d.verdict].add( 

425 "Invalidated by %s" % d.deptype.get_description() 

426 ) 

427 else: 

428 info = f'{d.deptype}: {self.uvname} <a href="#{duv_src}">{duv}</a>' 

429 dep_issues[d.verdict].add(info) 

430 

431 seen = set() 

432 for v in sorted(dep_issues.keys(), reverse=True): 

433 for i in sorted(dep_issues[v]): 

434 if i not in seen: 434 ↛ 433line 434 didn't jump to line 433 because the condition on line 434 was always true

435 self.add_verdict_info(v, i) 

436 seen.add(i) 

437 

438 self.dep_info_rendered = True 

439 

440 def html(self, excuses: ExcusesType) -> str: 

441 """Render the excuse in HTML""" 

442 res = '<a id="{0}" name="{0}">{0}</a> ({1} to {2})\n<ul>\n'.format( 

443 self.uvname, 

444 self.ver[0], 

445 self.ver[1], 

446 ) 

447 info = self._text(excuses) 

448 indented = False 

449 for line in info: 

450 stripped_this_line = False 

451 if line.startswith("∙ ∙ "): 

452 line = line[4:] 

453 stripped_this_line = True 

454 if not indented and stripped_this_line: 

455 res += "<ul>\n" 

456 indented = True 

457 elif indented and not stripped_this_line: 

458 res += "</ul>\n" 

459 indented = False 

460 res += "<li>%s\n" % line 

461 if indented: 

462 res += "</ul>\n" 

463 res = res + "</ul>\n" 

464 return res 

465 

466 def setbugs(self, oldbugs: list[str], newbugs: list[str]) -> None: 

467 """ "Set the list of old and new bugs""" 

468 self.newbugs.update(newbugs) 

469 self.oldbugs.update(oldbugs) 

470 

471 def addreason(self, reason: str) -> None: 

472 """ "adding reason""" 

473 self.reason.add(reason) 

474 

475 def hasreason(self, reason: str) -> bool: 

476 return reason in self.reason 

477 

478 def _text(self, excuses: ExcusesType) -> list[str]: 

479 """Render the excuse in text""" 

480 self._render_dep_issues(excuses) 

481 res = [] 

482 res.append( 

483 "Migration status for %s (%s to %s): %s" 

484 % (self.uvname, self.ver[0], self.ver[1], self._format_verdict_summary()) 

485 ) 

486 if not self.is_valid: 

487 res.append("Issues preventing migration:") 

488 for v in sorted(self.verdict_info.keys(), reverse=True): 

489 for x in self.verdict_info[v]: 

490 res.append("∙ ∙ " + x + "") 

491 if self.infoline: 

492 res.append("Additional info (not blocking):") 

493 for x in self.infoline: 

494 res.append("∙ ∙ " + x + "") 

495 return res 

496 

497 def excusedata(self, excuses: ExcusesType) -> dict[str, str]: 

498 """Render the excuse in as key-value data""" 

499 excusedata: dict[str, Any] = {} 

500 excusedata["excuses"] = self._text(excuses) 

501 excusedata["item-name"] = self.uvname 

502 excusedata["source"] = self.source 

503 excusedata["migration-policy-verdict"] = self._policy_verdict.name 

504 excusedata["old-version"] = self.ver[0] 

505 excusedata["new-version"] = self.ver[1] 

506 if self.maint: 

507 excusedata["maintainer"] = self.maint 

508 if self.section and self.section.find("/") > -1: 

509 excusedata["component"] = self.section.split("/")[0] 

510 if self.policy_info: 

511 excusedata["policy_info"] = self.policy_info 

512 if self.missing_builds or self.missing_builds_ood_arch: 

513 excusedata["missing-builds"] = { 

514 "on-architectures": sorted(self.missing_builds), 

515 "on-unimportant-architectures": sorted(self.missing_builds_ood_arch), 

516 } 

517 if any(d for d in self.all_deps if not d.valid and d.possible): 

518 excusedata["invalidated-by-other-package"] = True 

519 if self.all_deps or self.unsat_deps: 

520 dep_data: dict[str, Any] 

521 excusedata["dependencies"] = dep_data = {} 

522 

523 migrate_after = {d.first_dep for d in self.all_deps if d.valid} 

524 blocked_by = { 

525 d.first_dep for d in self.all_deps if not d.valid and d.possible 

526 } 

527 

528 def sorted_uvnames(deps: set[str | PackageId | None]) -> list[str]: 

529 return sorted(excuses[d].uvname for d in deps if d is not None) # type: ignore[index] 

530 

531 if blocked_by: 

532 dep_data["blocked-by"] = sorted_uvnames(blocked_by) 

533 if migrate_after: 

534 dep_data["migrate-after"] = sorted_uvnames(migrate_after) 

535 if self.unsat_deps: 535 ↛ 536line 535 didn't jump to line 536 because the condition on line 535 was never true

536 dep_data["unsatisfiable-dependencies"] = { 

537 key: sorted(value) for key, value in self.unsat_deps.items() 

538 } 

539 if self.needs_approval: 

540 status = "not-approved" 

541 if any(h.type == "unblock" for h in self.hints): 

542 status = "approved" 

543 excusedata["manual-approval-status"] = status 

544 if self.hints: 

545 hint_info = [ 

546 { 

547 "hint-type": h.type, 

548 "hint-from": h.user, 

549 } 

550 for h in self.hints 

551 ] 

552 

553 excusedata["hints"] = hint_info 

554 if self.old_binaries: 

555 excusedata["old-binaries"] = { 

556 key: sorted(value) for key, value in self.old_binaries.items() 

557 } 

558 if self.forced: 

559 excusedata["forced-reason"] = sorted(self.reason) 

560 excusedata["reason"] = [] 

561 else: 

562 excusedata["reason"] = sorted(self.reason) 

563 excusedata["is-candidate"] = self.is_valid 

564 if self.detailed_info: 

565 excusedata["detailed-info"] = self.detailed_info 

566 return excusedata 

567 

568 def add_bounty(self, policy: str, bounty: int) -> None: 

569 """adding bounty""" 

570 self.bounty[policy] = bounty 

571 

572 def add_penalty(self, policy: str, penalty: int) -> None: 

573 """adding penalty""" 

574 self.penalty[policy] = penalty