[FIX] tui qemu: the form crashed on the resource totals

"TypeError: unsupported operand type(s) for +: 'int' and 'NoSelection'"
as soon as the custom resource profile was picked.

Textual 8 turned Select.BLANK into a deprecated alias worth False, the
sentinel having become Select.NULL. The guard compared against False and
filtered nothing; NoSelection, lacking __bool__ and thus truthy, sailed
through "or default" into the plan totals.

The sentinel is now resolved at runtime, and both summed fields are
coerced to a positive int inside the pure function -- where the invariant
belongs, whatever Textual version is installed.

--- FR ---

« TypeError: unsupported operand type(s) for +: 'int' and 'NoSelection' »
dès qu'on choisissait le profil personnalisé.

Textual 8 a ramené Select.BLANK à un alias déprécié valant False, le
sentinelle étant devenu Select.NULL. La garde comparait donc à False et
ne filtrait plus rien ; NoSelection, dépourvu de __bool__ et tenu pour
vrai, traversait « ou valeur par défaut » jusqu'aux sommes du plan.

Le sentinelle est résolu à l'exécution, et les deux champs additionnés
sont ramenés à un entier positif dans la fonction pure — c'est là que
l'invariant appartient, quelle que soit la version de Textual.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-11 06:57:23 -04:00
parent 3fbde848f8
commit 36fc4e319a

View file

@ -63,6 +63,19 @@ def disk_gb(value) -> int:
return int(parsed[:-1]) if parsed else 0
def positive_int(value, fallback):
"""Entier strictement positif, sinon `fallback`.
Ces valeurs viennent de widgets : une liste déroulante sans choix rend un
sentinelle, une saisie libre rend du texte, éventuellement vide. Aucun des
deux ne doit atteindre les totaux, qui les additionnent."""
try:
number = int(value)
except (TypeError, ValueError):
return fallback
return number if number > 0 else fallback
def apply_profile(entries, profile, base_vcpus, host_cpu, custom=None):
"""Applique le profil de ressources aux entrées choisies.
@ -74,9 +87,9 @@ def apply_profile(entries, profile, base_vcpus, host_cpu, custom=None):
for e in entries:
if profile == "custom":
cus = custom or {}
ram = cus.get("ram") or e["ram"]
disk = cus.get("disk") or e["disk"]
vcpus = cus.get("vcpus") or base_vcpus
ram = positive_int(cus.get("ram"), e["ram"])
disk = parse_disk(cus.get("disk")) or e["disk"]
vcpus = positive_int(cus.get("vcpus"), base_vcpus)
else:
mult = int(profile)
ram = e["ram"] * mult
@ -213,6 +226,12 @@ def run_deploy_form(ctx, run_app: bool = True):
Static,
)
# Textual 8 a ramené Select.BLANK à un alias déprécié valant False ; le
# sentinelle « rien de choisi » est Select.NULL. Comparer à BLANK ne
# filtrait donc plus rien, et NoSelection — dépourvu de __bool__, donc
# tenu pour vrai — passait pour une valeur jusque dans les totaux.
SELECT_NULL = getattr(Select, "NULL", Select.BLANK)
catalog = ctx["catalog"]
arches = ctx["arches"]
domains = set(ctx.get("domains") or [])
@ -403,7 +422,7 @@ def run_deploy_form(ctx, run_app: bool = True):
)
yield Select(
[(lbl, i) for i, (lbl, _c) in enumerate(profiles)],
value=0 if profiles else Select.BLANK,
value=0 if profiles else SELECT_NULL,
allow_blank=not profiles,
id="f_profile_install",
)
@ -580,7 +599,7 @@ def run_deploy_form(ctx, run_app: bool = True):
mapping = {"f_vcpus": "vcpus", "f_ram": "ram", "f_disk": "disk"}
field = mapping.get(event.select.id)
if field:
if event.value is not Select.BLANK:
if event.value is not SELECT_NULL:
self.custom[field] = event.value
self._recompute()