Coverage for britney2/excuse.py: 97%
321 statements
« prev ^ index » next coverage.py v7.6.0, created at 2026-06-17 09:00 +0000
« 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>
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.
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.
15import re
16from collections import defaultdict
17from dataclasses import dataclass
18from typing import TYPE_CHECKING, Any, Union, cast
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
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
32ExcusesType = Union[dict[PackageId, "Excuse"], dict[str, "Excuse"]]
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}
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"""
67 spec: DependencySpec
68 # list of DependencyState, each of which can satisfy the dependency
69 depstates: list[DependencyState]
71 @property
72 def deptype(self) -> DependencyType:
73 return self.spec.deptype
75 @property
76 def valid(self) -> bool:
77 return any(d for d in self.depstates if d.valid)
79 @property
80 def deps(self) -> set[str | PackageId | None]:
81 return {d.dep for d in self.depstates}
83 @property
84 def possible(self) -> bool:
85 return any(d for d in self.depstates if d.possible)
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
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
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
112 @property
113 def verdict(self) -> PolicyVerdict:
114 return min({d.verdict for d in self.depstates})
116 def invalidate(self, excuse: str, verdict: PolicyVerdict) -> bool:
117 """invalidate the dependencies on a specific excuse
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
129 return valid_alternative_left
132class Excuse:
133 """Excuse class
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).
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 """
145 # @var reemail
146 # Regular expression for removing the email address
147 reemail = re.compile(r" *<.*?>")
149 def __init__(self, migrationitem: MigrationItem) -> None:
150 """Class constructor
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
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
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)
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()
191 self.bounty: dict[str, int] = {}
192 self.penalty: dict[str, int] = {}
194 # messenger from AutopkgtestPolicy to BlockPolicy
195 self.autopkgtest_results: set[str] | None = None
197 def sortkey(self) -> tuple[int, str]:
198 if self.daysold is None:
199 return (-1, self.uvname)
200 return (self.daysold, self.uvname)
202 @property
203 def name(self) -> str:
204 assert isinstance(self.item, MigrationItem)
205 return self.item.name
207 @property
208 def uvname(self) -> str:
209 assert isinstance(self.item, MigrationItem)
210 return self.item.uvname
212 @property
213 def source(self) -> str:
214 assert isinstance(self.item, MigrationItem)
215 return self.item.package
217 @property
218 def is_valid(self) -> bool:
219 return False if self._policy_verdict.is_rejected else True
221 @property
222 def policy_verdict(self) -> PolicyVerdict:
223 return self._policy_verdict
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
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)
242 def set_maint(self, maint: str) -> None:
243 """Set the package maintainer's name"""
244 self.maint = self.reemail.sub("", maint)
246 def set_section(self, section: str) -> None:
247 """Set the section of the package"""
248 self.section = section
250 def add_dependency(
251 self, dep: set[str | DependencyState], spec: DependencySpec
252 ) -> bool:
253 """Add a dependency of type deptype
255 :param dep: set with names of excuses, each of which satisfies the dep
256 :param spec: DependencySpec
258 """
260 assert dep, "%s: Adding empty list of dependencies" % self.name
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
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
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)
300 def add_unsatisfiable_dep(self, signature: str, arch: str) -> None:
301 """Add an unsatisfiable dependency"""
302 self.unsat_deps[arch].add(signature)
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)
311 def invalidate_dependency(self, name: str, verdict: PolicyVerdict) -> bool:
312 """Invalidate dependency"""
313 invalidate = False
315 for dep in self.all_deps:
316 if not dep.invalidate(name, verdict):
317 invalidate = True
318 self.do_invalidate(dep)
320 return not invalidate
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
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
335 def addinfo(self, note: str) -> None:
336 """Add a note in HTML"""
337 self.infoline.append(note)
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)
343 def add_detailed_info(self, note: str) -> None:
344 """Add a note to detailed info"""
345 self.detailed_info.append(note)
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)
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)
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)
359 def add_hint(self, hint: "Hint") -> None:
360 self.hints.append(hint)
362 def add_package(self, pkg_id: PackageId) -> None:
363 self.packages[pkg_id.architecture].add(pkg_id)
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)
372 :param spec: DependencySpec
373 :param depends: set of PackageIds (source or binary), each of which can satisfy the dependency
374 """
376 assert depends != cast(set[PackageId], frozenset()), (
377 "%s: Adding empty list of package dependencies" % self.name
378 )
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
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 )
397 def _render_dep_issues(self, excuses: ExcusesType) -> None:
398 if self.dep_info_rendered:
399 return
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)
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)
438 self.dep_info_rendered = True
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
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)
471 def addreason(self, reason: str) -> None:
472 """ "adding reason"""
473 self.reason.add(reason)
475 def hasreason(self, reason: str) -> bool:
476 return reason in self.reason
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
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 = {}
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 }
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]
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 ]
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
568 def add_bounty(self, policy: str, bounty: int) -> None:
569 """adding bounty"""
570 self.bounty[policy] = bounty
572 def add_penalty(self, policy: str, penalty: int) -> None:
573 """adding penalty"""
574 self.penalty[policy] = penalty