[ADD] analyse: i, u et t — installés, en cours, applications

Trois filtres sous la main plutôt qu'un cycle à parcourir. « u » est le
seul qui montrait quelque chose d'invisible jusqu'ici : les états de
passage — to install, to upgrade, to remove — qu'Odoo traverse et ne
devrait pas garder. Mesuré sur test_neutralize_upgrade_13 en pleine
migration : 22 modules figés en « to remove ».

L'en-tête les annonce, le rapport texte les nomme avec leur état, et
« non installés » ne les ramasse plus : un module en « to remove » EST
encore installé, il s'en va. La même touche fait l'aller et le retour.

--- EN ---

Three filters under the hand rather than a cycle to walk. « u » is the
only one showing something invisible until now: the transient states —
to install, to upgrade, to remove — that Odoo passes through and should
not keep. Measured on test_neutralize_upgrade_13 mid-migration:
22 modules frozen in « to remove ».

The header announces them, the text report names them with their state,
and « not installed » no longer collects them: a module in « to remove »
IS still installed, it is on its way out. The same key goes and returns.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-23 04:03:18 -04:00
parent 3c10ca2eb2
commit bcd7f9ae88
4 changed files with 291 additions and 6 deletions

View file

@ -45,12 +45,26 @@ except Exception: # pragma: no cover - repli si i18n indisponible
# résultat faux et visible plutôt qu'une boucle sans fin.
PROFONDEUR_MAX = 64
FILTRES = ("all", "installed", "absent", "broken")
FILTRES = (
"all",
"installed",
"pending",
"application",
"absent",
"broken",
)
# Ce qui compte comme « en place ». « to upgrade » l'est : le module est
# chargé, il sera seulement rejoué. « to install » ne l'est pas encore.
ETATS_PRESENTS = ("installed", "to upgrade")
# Les états qu'Odoo traverse et ne devrait pas garder. Un module qui y
# reste est une opération inachevée : mesuré sur test_neutralize_upgrade_13
# en pleine migration, 22 modules figés en « to remove ». Ils ne sont plus
# tout à fait installés et pas encore partis, donc aucun autre filtre ne
# les montre pour ce qu'ils sont.
ETATS_EN_COURS = ("to install", "to upgrade", "to remove")
def reverse(depend):
"""{module: [ce qui dépend de lui]} à partir de {module: [dépendances]}.
@ -89,6 +103,11 @@ def present(etat):
return etat in ETATS_PRESENTS
def pending(etat):
"""L'état est-il un passage, plutôt qu'une destination ?"""
return etat in ETATS_EN_COURS
def broken(recensement, depend):
"""[(module, dépendance, raison)] — ce qui est installé sur du vide.
@ -176,7 +195,17 @@ def rows(rapport, filtre="all"):
etat = rapport["modules"][nom][0]
if filtre == "installed" and not present(etat):
continue
if filtre == "absent" and present(etat):
# « non installés » exclut aussi les états de passage : un module
# en « to remove » EST encore installé, il s'en va. Le ranger avec
# ce qu'on pourrait installer était trompeur ; il a sa touche.
if filtre == "absent" and (present(etat) or pending(etat)):
continue
if filtre == "pending" and not pending(etat):
continue
# `application` est le drapeau « App » d'Odoo : ces modules-là
# sont les racines, ceux qu'on installe pour de vrai — les autres
# arrivent derrière eux.
if filtre == "application" and not rapport["modules"][nom][2]:
continue
if filtre == "broken" and nom not in cassants:
continue
@ -296,11 +325,19 @@ def head_text(rapport):
)
total = len(rapport["modules"])
casses = len(rapport.get("broken", []))
en_cours = sum(
1 for infos in rapport["modules"].values() if pending(infos[0])
)
texte = (
f"📦 {rapport['database']}"
f" ({t('Odoo')} {rapport.get('version') or '?'})"
f" · {installes}/{total} {t('modules installed')}"
)
if en_cours:
# Annoncé dans l'en-tête et non seulement derrière une touche :
# une opération inachevée explique des symptômes qu'on chercherait
# ailleurs pendant une heure.
texte += f" · ⏳ {en_cours} {t('in progress')}"
if casses:
texte += f" · ❌ {casses} {t('broken dependency(ies)')}"
return texte
@ -326,6 +363,18 @@ def render_text(rapport, limit=0, cap=0):
lignes.append(f" → {', '.join(amont[: limit or None])}")
if cap and len(lst_row) > cap:
lignes.append(f" … {len(lst_row) - cap} {t('more')}")
lst_en_cours = [
nom
for nom, infos in sorted(rapport["modules"].items())
if pending(infos[0])
]
if lst_en_cours:
lignes.append("")
lignes.append(f"⏳ {t('left in a transient state')}")
for nom in lst_en_cours[: limit or None]:
lignes.append(f" {nom} ({rapport['modules'][nom][0]})")
if limit and len(lst_en_cours) > limit:
lignes.append(f" … {len(lst_en_cours) - limit} {t('more')}")
if rapport.get("broken"):
lignes.append("")
lignes.append(f"❌ {t('installed on missing dependencies')}")

View file

@ -43,6 +43,8 @@ def filter_label(filtre):
return {
"all": t("all modules"),
"installed": t("installed only"),
"pending": t("in progress"),
"application": t("apps only"),
"absent": t("not installed"),
"broken": t("broken dependencies"),
}.get(filtre, filtre)
@ -80,6 +82,16 @@ def next_mode(mode):
return suite[(suite.index(courant) + 1) % len(suite)]
def toggle_filter(courant, cible):
"""Aller à `cible` — ou en revenir si l'on y est déjà.
Une touche qui ne fait qu'ALLER piège : arrivé sur « en cours », il
faudrait se souvenir de quelle autre touche ramène à la liste
entière. La même touche fait donc l'aller et le retour.
"""
return "all" if courant == cible else cible
def next_filter(filtre):
"""Le filtre suivant, en boucle."""
suite = dependency.FILTRES
@ -161,6 +173,9 @@ def build_app(rapport):
("escape", "leave", t("Quit")),
("d", "cycle_detail", t("Dependencies")),
("f", "cycle_filter", t("Filter")),
("i", "set_filter('installed')", t("Installed")),
("u", "set_filter('pending')", t("In progress")),
("t", "set_filter('application')", t("Apps")),
("slash", "find", t("Search")),
]
@ -237,6 +252,11 @@ def build_app(rapport):
self.filtre = next_filter(self.filtre)
self._fill()
def action_set_filter(self, cible):
"""Sauter droit à un filtre. `f` reste là pour les parcourir."""
self.filtre = toggle_filter(self.filtre, cible)
self._fill()
def action_find(self):
champ = self.query_one("#find", Input)
champ.add_class("visible")

View file

@ -6346,6 +6346,30 @@ TRANSLATIONS = {
"fr": "dépendances cassées",
"en": "broken dependencies",
},
"in progress": {
"fr": "en cours",
"en": "in progress",
},
"apps only": {
"fr": "applications seulement",
"en": "apps only",
},
"left in a transient state": {
"fr": "laissés dans un état de passage",
"en": "left in a transient state",
},
"Installed": {
"fr": "Installés",
"en": "Installed",
},
"In progress": {
"fr": "En cours",
"en": "In progress",
},
"Apps": {
"fr": "Applications",
"en": "Apps",
},
"Dependencies": {
"fr": "Dépendances",
"en": "Dependencies",

View file

@ -22,10 +22,10 @@ from script.analyse import check_module_dependency as dep # noqa: E402
from script.analyse import check_module_dependency_tui as tui # noqa: E402
def base(modules, depend=None):
def base(modules, depend=None, apps=()):
"""Un rapport minimal. `modules` : {nom: état}."""
recensement = {
nom: (etat, f"Résumé {nom}", False, "TechnoLibre")
nom: (etat, f"Résumé {nom}", nom in apps, "TechnoLibre")
for nom, etat in modules.items()
}
depend = (
@ -202,6 +202,135 @@ class TestRows(unittest.TestCase):
dep.rows(self.rapport(), filtre)
class TestTheTransientStates(unittest.TestCase):
"""Un module qui reste en chemin est une opération inachevée.
Mesuré sur test_neutralize_upgrade_13, en pleine migration :
22 modules figés en « to remove ». Aucun autre filtre ne les montrait
pour ce qu'ils sont.
"""
def test_the_three_transient_states_are_pending(self):
for etat in ("to install", "to upgrade", "to remove"):
self.assertTrue(dep.pending(etat), etat)
def test_a_settled_state_is_not(self):
for etat in ("installed", "uninstalled", "uninstallable"):
self.assertFalse(dep.pending(etat), etat)
def rapport(self):
return base(
{
"zebre": "installed",
"alpha": "to remove",
"milieu": "to install",
"bravo": "uninstalled",
"charlie": "to upgrade",
}
)
def test_the_pending_filter_shows_exactly_those(self):
self.assertEqual(
[r["name"] for r in dep.rows(self.rapport(), "pending")],
["alpha", "charlie", "milieu"],
)
def test_a_module_on_its_way_out_is_not_listed_as_absent(self):
# « to remove » EST encore installé ; le ranger avec ce qu'on
# pourrait installer envoyait chercher au mauvais endroit.
self.assertEqual(
[r["name"] for r in dep.rows(self.rapport(), "absent")], ["bravo"]
)
def test_to_upgrade_is_both_installed_and_pending(self):
# Il est chargé ET il attend : les deux sont vrais, et les taire
# ferait disparaître un module d'une des deux vues.
noms = [r["name"] for r in dep.rows(self.rapport(), "installed")]
self.assertIn("charlie", noms)
self.assertIn(
"charlie", [r["name"] for r in dep.rows(self.rapport(), "pending")]
)
def test_the_header_announces_them(self):
self.assertIn("3 en cours", dep.head_text(self.rapport()))
def test_the_header_stays_quiet_when_there_are_none(self):
self.assertNotIn("en cours", dep.head_text(base({"a": "installed"})))
def test_the_text_report_lists_them_with_their_state(self):
texte = "\n".join(dep.render_text(self.rapport()))
self.assertIn("alpha (to remove)", texte)
self.assertIn("milieu (to install)", texte)
def bloc_en_cours(self, lignes):
"""Les lignes qui suivent le titre « état de passage »."""
debut = next(i for i, x in enumerate(lignes) if "état de passage" in x)
suite = []
for ligne in lignes[debut + 1 :]:
if not ligne.startswith(" "):
break
suite.append(ligne)
return suite
def test_the_text_report_bounds_that_list_too(self):
# Trois en cours, borne à deux : deux nommés, et le troisième
# ANNONCÉ. Compter les lignes, pas chercher un chiffre au hasard.
suite = self.bloc_en_cours(dep.render_text(self.rapport(), limit=2))
nommes = [x for x in suite if x.strip().endswith(")")]
self.assertEqual(len(nommes), 2)
self.assertEqual(suite[-1].strip(), "… 1 de plus")
def test_without_a_limit_they_are_all_named(self):
suite = self.bloc_en_cours(dep.render_text(self.rapport()))
self.assertEqual(len(suite), 3)
self.assertFalse(any("de plus" in x for x in suite))
class TestTheApplicationFilter(unittest.TestCase):
def rapport(self):
return base(
{
"zebre": "installed",
"alpha": "installed",
"milieu": "uninstalled",
},
apps=("zebre", "milieu"),
)
def test_it_keeps_the_apps_whatever_their_state(self):
# Un module applicatif non installé reste applicatif : filtrer
# sur « App » et sur « installé » sont deux questions.
self.assertEqual(
[r["name"] for r in dep.rows(self.rapport(), "application")],
["milieu", "zebre"],
)
def test_a_plain_module_is_left_out(self):
self.assertNotIn(
"alpha",
[r["name"] for r in dep.rows(self.rapport(), "application")],
)
def test_the_pane_says_whether_it_is_one(self):
self.assertIn("oui", dep.pane_text(self.rapport(), "zebre"))
self.assertIn("non", dep.pane_text(self.rapport(), "alpha"))
class TestToggleFilter(unittest.TestCase):
def test_the_same_key_goes_and_comes_back(self):
# Sans le retour, il faudrait se souvenir de quelle AUTRE touche
# ramène à la liste entière.
self.assertEqual(tui.toggle_filter("all", "pending"), "pending")
self.assertEqual(tui.toggle_filter("pending", "pending"), "all")
def test_another_key_replaces_rather_than_returns(self):
self.assertEqual(tui.toggle_filter("installed", "pending"), "pending")
def test_every_direct_filter_is_a_known_one(self):
for cible in ("installed", "pending", "application"):
self.assertIn(cible, dep.FILTRES)
class TestListing(unittest.TestCase):
def rapport(self):
return base(
@ -524,10 +653,23 @@ class TestTheScreenActuallyDrives(unittest.TestCase):
},
)
def conduire(self, scenario):
def rapport_etats(self):
"""Un jeu qui porte les trois catégories des touches i / u / t."""
return base(
{
"sale": "installed",
"mail": "installed",
"muk_theme": "to remove",
"stock": "to install",
"vieux": "uninstalled",
},
apps=("sale", "stock"),
)
def conduire(self, scenario, rapport=None):
import asyncio
app = tui.build_app(self.rapport())
app = tui.build_app(rapport or self.rapport())
async def piloter():
async with app.run_test() as pilote:
@ -594,6 +736,56 @@ class TestTheScreenActuallyDrives(unittest.TestCase):
# ne peut plus rien faire après avoir cherché.
self.assertEqual(vus["mode"], dep.DETAILS[0])
def test_i_u_and_t_each_jump_straight_to_their_filter(self):
vus = {}
async def scenario(app, pilote):
for touche in ("i", "u", "t"):
await pilote.press(touche)
vus[touche] = (
app.filtre,
sorted(row["name"] for row in app.lst_row),
)
await pilote.press(touche)
vus[touche + "_retour"] = app.filtre
self.conduire(scenario, self.rapport_etats())
self.assertEqual(vus["i"], ("installed", ["mail", "sale"]))
self.assertEqual(vus["u"], ("pending", ["muk_theme", "stock"]))
self.assertEqual(vus["t"], ("application", ["sale", "stock"]))
for touche in ("i", "u", "t"):
self.assertEqual(vus[touche + "_retour"], "all")
def test_a_second_key_replaces_the_filter_rather_than_stacking(self):
vus = {}
async def scenario(app, pilote):
await pilote.press("i")
await pilote.press("u")
vus["filtre"] = app.filtre
vus["lignes"] = app.query_one("#left").row_count
self.conduire(scenario, self.rapport_etats())
self.assertEqual(vus["filtre"], "pending")
self.assertEqual(vus["lignes"], 2)
def test_those_letters_are_typed_not_obeyed_while_searching(self):
# « i », « u » et « t » sont des lettres avant d'être des
# raccourcis : dans le champ de recherche, elles s'écrivent.
vus = {}
async def scenario(app, pilote):
await pilote.press("slash")
for lettre in "it":
await pilote.press(lettre)
await pilote.pause()
vus["motif"] = app.motif
vus["filtre"] = app.filtre
self.conduire(scenario, self.rapport_etats())
self.assertEqual(vus["motif"], "it")
self.assertEqual(vus["filtre"], "all")
def test_escape_closes_the_search_before_it_closes_the_screen(self):
vus = {}