[ADD] analyse: qui dépend de qui, à l'écran

Le menu Analyse disait quels modules manquent, jamais qui dépend de qui.
« Puis-je retirer celui-ci » se réglait donc à la main : au palier
17 → 18, il a fallu écrire la requête pour savoir si web_responsive
pouvait partir.

L'écran liste les modules et « d » parcourt quatre relations : ce dont il
dépend, ce qui en dépend, tout ce qu'il entraîne, tout ce qui tombe avec
lui. « f » filtre, « / » cherche — une base en porte trois mille. La
réponse qui compte est écrite en toutes lettres : deux dépendants
déclarés dont zéro installé, c'est un retrait sans danger.

--- EN ---

The Analyse menu told which modules were missing, never which depends on
which. « Can I remove this one » was therefore answered by hand: at the
17 → 18 step we had to write the query to learn whether web_responsive
could go.

The screen lists the modules and « d » walks four relations: what it
needs, what needs it, everything it pulls in, everything that falls with
it. « f » filters, « / » searches — a database holds three thousand of
them. The answer that matters is spelled out: two declared dependents of
which zero installed means removal is safe.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-23 03:46:08 -04:00
parent f12e79c3a9
commit 7923e37e4f
5 changed files with 1504 additions and 0 deletions

View file

@ -0,0 +1,377 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Les modules d'une base et ce qui les relie.
`check_module_package` répond « quels modules manquent ». La question
d'à côté reste sans réponse : « celui-ci, puis-je le retirer, et
qu'entraîne-t-il avec lui ». On l'a payé cher au palier 17 → 18 —
`web_responsive` devenu incompatible avec `muk_web_theme` — où il a fallu
écrire la requête à la main pour savoir si quelqu'un en dépendait.
Quatre relations, et non une seule liste :
ce dont il dépend ce qu'il faut installer AVANT lui
ce qui en dépend ce qui casse si on le retire
ce qu'il entraîne la fermeture avale, dépendances des dépendances
ce qui tombe avec lui la fermeture amont
La lecture n'écrit jamais : `check_module_package.run_psql` ouvre la
session en lecture seule côté SERVEUR, pas côté politesse.
"""
import json
import os
import sys
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
)
from script.analyse import check_module_package as package # noqa: E402
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
# Un graphe de modules Odoo est peu profond — `base` est à la racine et la
# plus longue chaîne réelle tient en une dizaine de sauts. La borne existe
# pour qu'un cycle en base, qui NE DEVRAIT pas exister, produise un
# résultat faux et visible plutôt qu'une boucle sans fin.
PROFONDEUR_MAX = 64
FILTRES = ("all", "installed", "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")
def reverse(depend):
"""{module: [ce qui dépend de lui]} à partir de {module: [dépendances]}.
Les modules cités comme dépendance mais absents de la base ont eux
aussi leur entrée : c'est précisément le cas qu'on cherche à voir.
"""
inverse = {}
for module, lst in depend.items():
inverse.setdefault(module, [])
for nom in lst:
inverse.setdefault(nom, []).append(module)
return {nom: sorted(set(lst)) for nom, lst in inverse.items()}
def closure(nom, graphe):
"""Tout ce que `nom` atteint dans `graphe`, lui-même exclu.
En largeur et non en profondeur : la récursion sur un graphe cyclique
déborde la pile avant qu'on ait compris pourquoi.
"""
vus, bord, profondeur = set(), [nom], 0
while bord and profondeur < PROFONDEUR_MAX:
suivant = []
for courant in bord:
for voisin in graphe.get(courant, []):
if voisin not in vus and voisin != nom:
vus.add(voisin)
suivant.append(voisin)
bord = suivant
profondeur += 1
return sorted(vus)
def present(etat):
return etat in ETATS_PRESENTS
def broken(recensement, depend):
"""[(module, dépendance, raison)] — ce qui est installé sur du vide.
Un module installé dont une dépendance ne l'est pas est un état
qu'Odoo ne produit jamais lui-même ; il vient d'une migration, d'une
désinstallation forcée ou d'un addons-path incomplet. Le charger
échoue, ou pire, réussit à moitié.
"""
lst = []
for module, infos in sorted(recensement.items()):
if not present(infos[0]):
continue
for nom in sorted(set(depend.get(module, []))):
if nom not in recensement:
lst.append((module, nom, "unknown"))
elif not present(recensement[nom][0]):
lst.append((module, nom, recensement[nom][0]))
return lst
def survey(database):
"""Tout ce que l'écran et le rapport ont besoin de savoir.
Une seule lecture de la base pour les deux : deux assemblages
finiraient par montrer deux états contradictoires du même système.
"""
recensement = package.census(database)
if recensement is None:
return {"database": database, "unavailable": True}
depend = package.dependencies(database)
# Un module sans dépendance déclarée doit exister dans le graphe :
# sinon `closure` et l'écran le traitent comme inconnu.
depend = {nom: sorted(set(depend.get(nom, []))) for nom in recensement}
return {
"database": database,
"version": package.db_version(database),
"modules": recensement,
"depends": depend,
"dependents": reverse(depend),
"broken": broken(recensement, depend),
}
def verdict(rapport, nom):
infos = rapport["modules"].get(nom)
if infos is None:
return "unknown"
return package.ETAT_VERS_VERDICT.get(infos[0], "uninstallable")
def icon(rapport, nom):
return package.ICONE.get(verdict(rapport, nom), "·")
def counts(rapport, nom):
"""(dépendances, dépendants, dépendants EN PLACE).
Le troisième chiffre est celui qui décide d'un retrait. Sur une base
réelle, `web_responsive` a deux dépendants déclarés et zéro installé :
ne montrer que « 2 » ferait renoncer à un retrait sans danger.
"""
aval = rapport["dependents"].get(nom, [])
return (
len(rapport["depends"].get(nom, [])),
len(aval),
sum(1 for autre in aval if present_of(rapport, autre)),
)
def modules_cassants(rapport):
"""Les modules qui apparaissent à gauche d'une dépendance cassée."""
return {module for module, _dep, _raison in rapport.get("broken", [])}
def rows(rapport, filtre="all"):
"""La liste de gauche : un module par ligne, déjà triée.
Tri par nom. Trier par nombre de dépendants mettrait `base` en tête à
chaque fois — vrai, et sans intérêt : on cherche un module qu'on
nomme, pas le plus populaire.
"""
cassants = modules_cassants(rapport)
lst = []
for nom in sorted(rapport["modules"]):
etat = rapport["modules"][nom][0]
if filtre == "installed" and not present(etat):
continue
if filtre == "absent" and present(etat):
continue
if filtre == "broken" and nom not in cassants:
continue
amont, aval, vivants = counts(rapport, nom)
lst.append(
{
"name": nom,
"label": f"{icon(rapport, nom)} {nom}",
"detail": f"{amont}↓ {vivants}/{aval}↑",
"broken": nom in cassants,
}
)
return lst
DETAILS = ("depends", "dependents", "pulls", "falls")
TITRE_DETAIL = {
"depends": "What it needs, directly",
"dependents": "What needs it, directly",
"pulls": "Everything it pulls in",
"falls": "Everything that falls with it",
}
def listing(rapport, nom, mode):
"""Les noms à montrer pour ce mode, dans l'ordre d'affichage."""
if mode == "depends":
return rapport["depends"].get(nom, [])
if mode == "dependents":
return rapport["dependents"].get(nom, [])
if mode == "pulls":
return closure(nom, rapport["depends"])
if mode == "falls":
return closure(nom, rapport["dependents"])
return []
def paint(texte, genre, colour):
from script.todo import migration_status as status
return status.paint(texte, genre, colour)
def pane_text(rapport, nom, mode=None, colour=False, limit=0):
"""Le panneau de droite pour le module choisi."""
if not nom:
return t("Nothing to show yet.")
infos = rapport["modules"].get(nom)
if infos is None:
return f"❌ {nom} : {t('unknown to this database')}"
etat, resume, application, auteur = infos
lignes = [
paint(f"{icon(rapport, nom)} {nom}", "step", colour),
f" {resume or ''}".rstrip(),
"",
f" {t('state'):<22} {etat}",
f" {t('author'):<22} {auteur or '—'}",
f" {t('application'):<22} {t('yes') if application else t('no')}",
]
amont, aval, vivants = counts(rapport, nom)
lignes += [
f" {t('depends on'):<22} {amont}",
f" {t('needed by'):<22} {aval}"
f" · {vivants} {t('of them installed')}",
]
if present(etat) and aval and not vivants:
# La réponse à « puis-je le retirer », écrite plutôt que déduite
# de deux chiffres qu'il faudrait comparer soi-même.
lignes.append(
" " + paint(t("nothing installed depends on it"), "ok", colour)
)
lignes.append("")
if mode in DETAILS:
lst = listing(rapport, nom, mode)
lignes.append(paint(f" {t(TITRE_DETAIL[mode])}", "step", colour))
if not lst:
lignes.append(f" {t('nothing')}")
for autre in lst[: limit or None]:
marque = "" if present_of(rapport, autre) else f" ← {t('absent')}"
genre = "ok" if present_of(rapport, autre) else "warn"
lignes.append(
f" {icon(rapport, autre)} "
+ paint(f"{autre}{marque}", genre, colour)
)
if limit and len(lst) > limit:
lignes.append(f" … {len(lst) - limit} {t('more')}")
else:
lignes.append(f" {t('press d to walk the dependencies')}")
casses = [
(dep, raison)
for module, dep, raison in rapport.get("broken", [])
if module == nom
]
if casses:
lignes.append("")
lignes.append(
paint(
f" ❌ {t('installed on missing dependencies')}",
"fail",
colour,
)
)
for dep, raison in casses:
lignes.append(f" {dep} ({raison})")
return "\n".join(lignes)
def present_of(rapport, nom):
infos = rapport["modules"].get(nom)
return bool(infos) and present(infos[0])
def head_text(rapport):
installes = sum(
1 for infos in rapport["modules"].values() if present(infos[0])
)
total = len(rapport["modules"])
casses = len(rapport.get("broken", []))
texte = (
f"📦 {rapport['database']}"
f" ({t('Odoo')} {rapport.get('version') or '?'})"
f" · {installes}/{total} {t('modules installed')}"
)
if casses:
texte += f" · ❌ {casses} {t('broken dependency(ies)')}"
return texte
def render_text(rapport, limit=0, cap=0):
"""Le rapport en clair, pour un terminal qui n'ouvre pas d'écran.
`cap` borne le NOMBRE DE MODULES, `limit` la longueur de chaque liste
de dépendances. Une base porte trois mille modules : sans borne, le
repli déverse six mille lignes dans un menu, ce qui n'est pas un
repli mais une seconde panne.
"""
if rapport.get("unavailable"):
return [f"❌ {t('Cannot read the database: ')}{rapport['database']}"]
lignes = [head_text(rapport), ""]
lst_row = rows(rapport)
for row in lst_row[: cap or None]:
nom = row["name"]
amont = rapport["depends"].get(nom, [])
lignes.append(f"{row['label']:<44} {row['detail']}")
if amont:
lignes.append(f" → {', '.join(amont[: limit or None])}")
if cap and len(lst_row) > cap:
lignes.append(f" … {len(lst_row) - cap} {t('more')}")
if rapport.get("broken"):
lignes.append("")
lignes.append(f"❌ {t('installed on missing dependencies')}")
for module, dep, raison in rapport["broken"]:
lignes.append(f" {module} → {dep} ({raison})")
return lignes
def main(argv=None):
import argparse
parser = argparse.ArgumentParser(
description=(
"List every module of a database and how they depend on one"
" another. Read-only."
)
)
parser.add_argument("-d", "--database", required=True)
parser.add_argument(
"--limit", type=int, default=0, help="cap long lists (0 = no cap)"
)
parser.add_argument("--json", action="store_true", help="machine output")
parser.add_argument(
"--no-tui", action="store_true", help="never open the screen"
)
config = parser.parse_args(argv)
rapport = survey(config.database)
if rapport.get("unavailable"):
print(f"❌ {t('Cannot read the database: ')}{config.database}")
return 2
if config.json:
print(
json.dumps(rapport, indent=2, sort_keys=True, ensure_ascii=False)
)
return 1 if rapport["broken"] else 0
if not config.no_tui:
try:
from script.analyse.check_module_dependency_tui import run_tui
except Exception:
run_tui = None
if run_tui and run_tui(rapport):
return 1 if rapport["broken"] else 0
print("\n".join(render_text(rapport, limit=config.limit)))
return 1 if rapport["broken"] else 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,324 @@
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Les modules d'une base et leurs dépendances, en plein écran.
Une base porte trois à six cents modules. Le rapport texte les liste tous
et la question qu'on se pose — « celui-ci, qui en dépend » — se trouve
quelque part au milieu de plusieurs milliers de lignes.
Les données viennent de `check_module_dependency`, comme le rapport
texte : un seul assemblage, donc une seule vérité sur la même base.
"""
import os
import sys
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
)
from script.analyse import check_module_dependency as dependency # noqa: E402
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
CSS = """
Screen { layout: vertical; }
#head { height: 3; padding: 0 1; background: $panel; color: $text; }
#body { height: 1fr; }
#left { width: 46; border-right: solid $accent; }
#pane { width: 1fr; padding: 0 1; }
#find { display: none; }
#find.visible { display: block; }
"""
def filter_label(filtre):
return {
"all": t("all modules"),
"installed": t("installed only"),
"absent": t("not installed"),
"broken": t("broken dependencies"),
}.get(filtre, filtre)
def mode_label(mode):
"""Le nom du mode courant, pour le sous-titre.
Un panneau qui change sans dire pourquoi se lit comme un écran cassé.
"""
if mode is None:
return t("summary")
return t(dependency.TITRE_DETAIL.get(mode, mode))
def subtitle(mode, filtre, motif):
morceaux = [mode_label(mode), filter_label(filtre)]
if motif:
morceaux.append(f"« {motif} »")
return " · ".join(morceaux)
def matching(lst_row, motif):
"""Les lignes dont le nom contient `motif`, sans égard à la casse."""
if not motif:
return lst_row
bas = motif.lower()
return [row for row in lst_row if bas in row["name"].lower()]
def next_mode(mode):
"""Le mode suivant : résumé → les quatre relations → résumé."""
suite = (None,) + dependency.DETAILS
courant = mode if mode in suite else None
return suite[(suite.index(courant) + 1) % len(suite)]
def next_filter(filtre):
"""Le filtre suivant, en boucle."""
suite = dependency.FILTRES
index = suite.index(filtre) if filtre in suite else 0
return suite[(index + 1) % len(suite)]
def current_name(lst_row, index):
"""Le module sous le curseur, ou None si la liste est vide.
`index` peut dépasser : la table garde son curseur d'avant quand la
liste raccourcit, et lire hors bornes ferait tomber l'écran sur une
IndexError au moment précis où l'on filtre.
"""
if not lst_row or index is None or not 0 <= index < len(lst_row):
return None
return lst_row[index]["name"]
def cursor_for(lst_row, garde):
"""Où replacer le curseur pour retrouver `garde`. None si parti.
Changer de filtre ramenait le curseur en tête, donc on perdait le
module qu'on lisait — c'est-à-dire la raison même du filtre.
"""
if not garde:
return None
for index, row in enumerate(lst_row):
if row["name"] == garde:
return index
return None
def populate(table, lst_row, garde=None):
"""Remplir la table, puis y remettre le curseur sur `garde`.
Prend la table plutôt que l'App : rien ici ne dépend de Textual sauf
trois appels de méthode, donc un faux objet suffit à l'éprouver.
"""
table.clear()
for row in lst_row:
table.add_row(row["label"][:40], row["detail"])
place = cursor_for(lst_row, garde)
if place is not None:
table.move_cursor(row=place)
return place
def hide_find(champ, table):
"""Refermer la recherche et rendre le clavier à la liste.
Le `focus()` explicite n'est pas une précaution : sans lui, Textual
donne le clavier au premier widget focalisable, et c'est le champ de
recherche — « display: none » ne le retire pas de ce choix-là. On
ouvrait donc l'écran en tapant dans une boîte invisible, et « d » ne
faisait rien. (La TABULATION, elle, saute bien un widget caché :
mesuré, d'où l'absence de `can_focus` ici — une ligne qu'on ne peut
pas faire échouer n'a rien à faire dans le fichier.)
Ne vide PAS le champ : l'appelant décide s'il efface le motif, et
c'est cet effacement qui redéclenche le filtrage.
"""
champ.remove_class("visible")
table.focus()
def build_app(rapport):
"""Textual est importé ICI : le module reste testable sans lui."""
from rich.text import Text
from textual import on
from textual.app import App, ComposeResult
from textual.containers import Horizontal, VerticalScroll
from textual.widgets import DataTable, Footer, Header, Input, Static
class DependencyApp(App):
CSS = globals()["CSS"]
BINDINGS = [
("q", "quit", t("Quit")),
("escape", "leave", t("Quit")),
("d", "cycle_detail", t("Dependencies")),
("f", "cycle_filter", t("Filter")),
("slash", "find", t("Search")),
]
def __init__(self, rapport):
super().__init__()
self.rapport = rapport
self.mode = None
self.filtre = "all"
self.motif = ""
self.lst_row = []
def compose(self) -> ComposeResult:
yield Header()
yield Static("", id="head")
with Horizontal(id="body"):
yield DataTable(id="left", cursor_type="row")
with VerticalScroll(id="pane"):
yield Static("", id="content")
yield Input(placeholder=t("module name…"), id="find")
yield Footer()
def on_mount(self):
self.title = t("Modules and their dependencies")
table = self.query_one("#left", DataTable)
table.add_columns(t("module"), "↓↑")
hide_find(self.query_one("#find", Input), table)
self.query_one("#head", Static).update(
dependency.head_text(self.rapport)
)
self._fill()
def _fill(self):
"""Reconstruire la liste, en gardant le module sous le curseur.
Sans cela, changer de filtre ramène le curseur en tête et l'on
perd le module qu'on était en train de lire — c'est-à-dire la
raison pour laquelle on a filtré.
"""
garde = self._current_name()
self.lst_row = matching(
dependency.rows(self.rapport, self.filtre), self.motif
)
populate(self.query_one("#left", DataTable), self.lst_row, garde)
self._show()
def _current_name(self):
table = self.query_one("#left", DataTable)
return current_name(self.lst_row, table.cursor_row)
def _show(self):
self.sub_title = subtitle(self.mode, self.filtre, self.motif)
self.query_one("#content", Static).update(
Text.from_ansi(
dependency.pane_text(
self.rapport,
self._current_name(),
mode=self.mode,
colour=True,
)
)
)
def action_cycle_detail(self):
"""Parcourir les quatre relations, puis revenir au résumé.
« ce dont il dépend » et « ce qui en dépend » répondent à deux
questions opposées, et les fermetures disent l'ampleur réelle :
retirer un module en entraîne parfois trente.
"""
self.mode = next_mode(self.mode)
self._show()
def action_cycle_filter(self):
self.filtre = next_filter(self.filtre)
self._fill()
def action_find(self):
champ = self.query_one("#find", Input)
champ.add_class("visible")
champ.focus()
def action_leave(self):
"""Échap referme la recherche ; il ne quitte que sinon.
Quitter parce qu'on renonce à une recherche serait une
surprise coûteuse : on a parfois filtré trois mille modules
pour arriver là.
"""
champ = self.query_one("#find", Input)
if not champ.has_class("visible"):
self.exit()
return
# Vider le champ suffit à tout refaire : l'événement
# `Input.Changed` remet le motif à zéro et reconstruit la
# liste. Le refaire ici à la main donnerait deux chemins pour
# le même état, dont un seul serait jamais éprouvé.
champ.value = ""
hide_find(champ, self.query_one("#left", DataTable))
@on(Input.Changed, "#find")
def _find_changed(self, event):
self.motif = event.value.strip()
self._fill()
@on(Input.Submitted, "#find")
def _find_submitted(self, event):
# Le champ reste VISIBLE : il porte le motif en cours, et le
# cacher laisserait une liste filtrée sans dire par quoi.
self.query_one("#left", DataTable).focus()
@on(DataTable.RowHighlighted, "#left")
def _row_changed(self, event):
self._show()
return DependencyApp(rapport)
def in_event_loop():
"""Une boucle asyncio tourne-t-elle déjà dans CE processus ?"""
import asyncio
try:
asyncio.get_running_loop()
except RuntimeError:
return False
return True
def run_tui(rapport, run_app=True):
"""Ouvrir l'écran. False si l'on n'a pas pu — et alors on DIT pourquoi."""
if not rapport or rapport.get("unavailable"):
return False
if not rapport.get("modules"):
return False
if not sys.stdout.isatty():
print(f"ℹ️ {t('Not a terminal: showing the text report instead.')}")
return False
try:
from script.todo import textual_setup
except Exception:
textual_setup = None
if textual_setup and not textual_setup.ensure():
return False
try:
app = build_app(rapport)
except ImportError:
print(
f"ℹ️ {t('Textual is missing from this interpreter:')}"
f" {sys.executable}"
)
return False
if not run_app:
return app
if in_event_loop():
print(
f"ℹ️ {t('Already inside a running screen: open it in its own')}"
f" {t('process instead.')}"
)
return False
app.run()
return True

View file

@ -10450,6 +10450,7 @@ class TODO:
"Modules missing from the default package"
)
},
{"prompt_description": t("Dependencies between modules")},
{"section": t("Files")},
{
"prompt_description": t(
@ -10475,6 +10476,8 @@ class TODO:
elif status == "5":
self.execute_analyse_module_package()
elif status == "6":
self.execute_analyse_module_dependency()
elif status == "7":
self.execute_analyse_filestore()
else:
print(t("Command not found !"))
@ -10523,6 +10526,55 @@ class TODO:
handler,
)
def execute_analyse_module_dependency(self):
"""Qui dépend de qui, pour savoir ce qu'on peut retirer.
L'écran est ouvert par l'outil lui-même, qui retombe sur son
rapport texte s'il ne peut pas — terminal absent, Textual absent.
Pas d'option « sauvegarde .zip » : les dépendances vivent dans
`ir_module_module_dependency`, qu'un zip n'expose pas sans
restauration.
"""
from script.analyse import check_module_dependency as dependency
database = self._analyse_select_database()
if not database:
return
print(f"⧖ {t('Reading the modules and their dependencies…')}")
try:
rapport = dependency.survey(database)
except Exception as exc:
print(f"❌ {t('Analysis failed: ')}{exc}")
return
if rapport.get("unavailable"):
print(f"❌ {t('Cannot read the database: ')}{database}")
return
try:
from script.analyse.check_module_dependency_tui import run_tui
except Exception:
run_tui = None
if not (run_tui and run_tui(rapport)):
# Borné : une base porte trois mille modules, et déverser six
# mille lignes dans le menu n'est pas un repli.
print("\n".join(dependency.render_text(rapport, limit=8, cap=40)))
def handler(rank):
if rank == 1:
print("\n".join(dependency.render_text(rapport, limit=0)))
else:
self._analyse_export_json(
rapport, os.path.basename(database), "module_dependency"
)
self._analyse_follow_up(
[
{"prompt_description": t("Show every entry")},
{"prompt_description": t("Export as JSON")},
],
handler,
)
def execute_analyse_filestore(self):
"""Ce qui manque au filestore, et ce qu'on peut encore récupérer.

View file

@ -6214,6 +6214,126 @@ TRANSLATIONS = {
"fr": "📦 Modules absents du package par défaut",
"en": "📦 Modules missing from the default package",
},
"Dependencies between modules": {
"fr": "🔗 Dépendances entre les modules",
"en": "🔗 Dependencies between modules",
},
"modules installed": {
"fr": "modules installés",
"en": "modules installed",
},
"of them installed": {
"fr": "installé(s)",
"en": "of them installed",
},
"nothing installed depends on it": {
"fr": "rien d'installé n'en dépend",
"en": "nothing installed depends on it",
},
"Reading the modules and their dependencies…": {
"fr": "Lecture des modules et de leurs dépendances…",
"en": "Reading the modules and their dependencies…",
},
"Modules and their dependencies": {
"fr": "Modules et leurs dépendances",
"en": "Modules and their dependencies",
},
"unknown to this database": {
"fr": "inconnu de cette base",
"en": "unknown to this database",
},
"state": {
"fr": "état",
"en": "state",
},
"author": {
"fr": "auteur",
"en": "author",
},
"application": {
"fr": "application",
"en": "application",
},
"yes": {
"fr": "oui",
"en": "yes",
},
"depends on": {
"fr": "dépend de",
"en": "depends on",
},
"needed by": {
"fr": "requis par",
"en": "needed by",
},
"absent": {
"fr": "absent",
"en": "absent",
},
"press d to walk the dependencies": {
"fr": "appuyez sur d pour parcourir les dépendances",
"en": "press d to walk the dependencies",
},
"installed on missing dependencies": {
"fr": "installé sur des dépendances absentes",
"en": "installed on missing dependencies",
},
"broken dependency(ies)": {
"fr": "dépendance(s) cassée(s)",
"en": "broken dependency(ies)",
},
"What it needs, directly": {
"fr": "Ce dont il a besoin, directement",
"en": "What it needs, directly",
},
"What needs it, directly": {
"fr": "Ce qui en a besoin, directement",
"en": "What needs it, directly",
},
"Everything it pulls in": {
"fr": "Tout ce qu'il entraîne",
"en": "Everything it pulls in",
},
"Everything that falls with it": {
"fr": "Tout ce qui tombe avec lui",
"en": "Everything that falls with it",
},
"all modules": {
"fr": "tous les modules",
"en": "all modules",
},
"installed only": {
"fr": "installés seulement",
"en": "installed only",
},
"not installed": {
"fr": "non installés",
"en": "not installed",
},
"broken dependencies": {
"fr": "dépendances cassées",
"en": "broken dependencies",
},
"Dependencies": {
"fr": "Dépendances",
"en": "Dependencies",
},
"Filter": {
"fr": "Filtre",
"en": "Filter",
},
"Search": {
"fr": "Chercher",
"en": "Search",
},
"module": {
"fr": "module",
"en": "module",
},
"module name…": {
"fr": "nom de module…",
"en": "module name…",
},
"Show every entry": {
"fr": "📜 Tout afficher",
"en": "📜 Show every entry",

View file

@ -0,0 +1,631 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Qui dépend de qui, et ce qu'on peut retirer sans casser.
La question posée au palier 17 → 18 — « puis-je retirer web_responsive »
— a demandé une requête écrite à la main. L'outil y répond, et la réponse
qui compte n'est pas « deux modules en dépendent » mais « aucun de ceux
qui en dépendent n'est installé ».
"""
import os
import sys
import unittest
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
)
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):
"""Un rapport minimal. `modules` : {nom: état}."""
recensement = {
nom: (etat, f"Résumé {nom}", False, "TechnoLibre")
for nom, etat in modules.items()
}
depend = (
{nom: sorted(depend.get(nom, [])) for nom in recensement}
if depend
else {nom: [] for nom in recensement}
)
return {
"database": "essai",
"version": "18.0",
"modules": recensement,
"depends": depend,
"dependents": dep.reverse(depend),
"broken": dep.broken(recensement, depend),
}
class TestReverse(unittest.TestCase):
def test_it_inverts_the_arrows(self):
self.assertEqual(
dep.reverse({"zebre": ["alpha"], "alpha": []}),
{"alpha": ["zebre"], "zebre": []},
)
def test_a_module_cited_but_absent_still_gets_an_entry(self):
# C'est le cas qu'on cherche : quelqu'un dépend d'un module que la
# base ne connaît pas. L'omettre le rendrait invisible.
self.assertEqual(dep.reverse({"a": ["fantome"]})["fantome"], ["a"])
def test_a_duplicate_dependency_is_counted_once(self):
self.assertEqual(dep.reverse({"a": ["b", "b"]})["b"], ["a"])
class TestClosure(unittest.TestCase):
def test_it_walks_through(self):
graphe = {"zebre": ["milieu"], "milieu": ["alpha"], "alpha": []}
self.assertEqual(dep.closure("zebre", graphe), ["alpha", "milieu"])
def test_it_excludes_itself(self):
self.assertNotIn("a", dep.closure("a", {"a": ["b"], "b": ["a"]}))
def test_a_cycle_terminates(self):
# Un cycle ne devrait pas exister en base ; s'il y en a un, on veut
# un résultat, pas une boucle sans fin.
self.assertEqual(
dep.closure("a", {"a": ["b"], "b": ["c"], "c": ["a"]}),
["b", "c"],
)
def test_a_chain_longer_than_the_bound_is_cut_not_hung(self):
chaine = {
f"n{i}": [f"n{i + 1}"] for i in range(dep.PROFONDEUR_MAX + 8)
}
atteints = dep.closure("n0", chaine)
self.assertEqual(len(atteints), dep.PROFONDEUR_MAX)
def test_an_unknown_module_reaches_nothing(self):
self.assertEqual(dep.closure("absent", {"a": ["b"]}), [])
class TestBroken(unittest.TestCase):
def test_installed_on_an_uninstalled_dependency(self):
rapport = base(
{"chef": "installed", "socle": "uninstalled"},
{"chef": ["socle"]},
)
self.assertEqual(rapport["broken"], [("chef", "socle", "uninstalled")])
def test_installed_on_a_module_the_base_never_heard_of(self):
rapport = base({"chef": "installed"}, {"chef": ["fantome"]})
self.assertEqual(rapport["broken"], [("chef", "fantome", "unknown")])
def test_an_uninstalled_module_may_depend_on_anything(self):
# Il ne tourne pas : ses dépendances ne cassent rien.
rapport = base(
{"chef": "uninstalled", "socle": "uninstalled"},
{"chef": ["socle"]},
)
self.assertEqual(rapport["broken"], [])
def test_to_upgrade_counts_as_in_place(self):
# Le module est chargé, il sera seulement rejoué.
rapport = base(
{"chef": "installed", "socle": "to upgrade"},
{"chef": ["socle"]},
)
self.assertEqual(rapport["broken"], [])
def test_to_install_is_not_in_place_yet(self):
rapport = base(
{"chef": "installed", "socle": "to install"},
{"chef": ["socle"]},
)
self.assertEqual(rapport["broken"], [("chef", "socle", "to install")])
class TestCounts(unittest.TestCase):
def rapport(self):
return base(
{
"web_responsive": "installed",
"erplibre_base": "uninstalled",
"web_responsive_company": "uninstalled",
"mail": "installed",
},
{
"web_responsive": ["mail"],
"erplibre_base": ["web_responsive"],
"web_responsive_company": ["web_responsive"],
},
)
def test_the_third_number_counts_only_what_is_in_place(self):
# Le cas réel : deux dépendants déclarés, aucun installé. Ne
# montrer que « 2 » ferait renoncer à un retrait sans danger.
self.assertEqual(
dep.counts(self.rapport(), "web_responsive"), (1, 2, 0)
)
def test_an_installed_dependent_is_counted(self):
rapport = self.rapport()
rapport["modules"]["erplibre_base"] = ("installed", "", False, "")
self.assertEqual(dep.counts(rapport, "web_responsive")[2], 1)
def test_a_module_nobody_needs(self):
self.assertEqual(
dep.counts(self.rapport(), "erplibre_base"), (1, 0, 0)
)
class TestRows(unittest.TestCase):
def rapport(self):
return base(
{
"zebre": "installed",
"alpha": "uninstalled",
"milieu": "installed",
},
{"zebre": ["alpha"]},
)
def test_they_come_out_sorted_by_name(self):
# Les noms sont posés dans un ordre qui CONTREDIT l'alphabet :
# sans tri, la liste sortirait zebre, alpha, milieu.
self.assertEqual(
[row["name"] for row in dep.rows(self.rapport())],
["alpha", "milieu", "zebre"],
)
def test_the_installed_filter_keeps_only_those(self):
self.assertEqual(
[r["name"] for r in dep.rows(self.rapport(), "installed")],
["milieu", "zebre"],
)
def test_the_absent_filter_is_the_complement(self):
self.assertEqual(
[r["name"] for r in dep.rows(self.rapport(), "absent")], ["alpha"]
)
def test_the_broken_filter_keeps_the_guilty_only(self):
# zebre est installé et dépend d'alpha qui ne l'est pas.
self.assertEqual(
[r["name"] for r in dep.rows(self.rapport(), "broken")], ["zebre"]
)
def test_the_detail_column_shows_both_counts(self):
row = [r for r in dep.rows(self.rapport()) if r["name"] == "alpha"][0]
self.assertEqual(row["detail"], "0↓ 1/1↑")
def test_every_filter_is_understood(self):
# Un filtre inconnu ne doit pas vider la liste en silence.
for filtre in dep.FILTRES:
dep.rows(self.rapport(), filtre)
class TestListing(unittest.TestCase):
def rapport(self):
return base(
{"haut": "installed", "milieu": "installed", "bas": "installed"},
{"haut": ["milieu"], "milieu": ["bas"]},
)
def test_depends_is_direct_only(self):
self.assertEqual(
dep.listing(self.rapport(), "haut", "depends"), ["milieu"]
)
def test_pulls_goes_all_the_way_down(self):
self.assertEqual(
dep.listing(self.rapport(), "haut", "pulls"), ["bas", "milieu"]
)
def test_dependents_is_direct_only(self):
self.assertEqual(
dep.listing(self.rapport(), "bas", "dependents"), ["milieu"]
)
def test_falls_goes_all_the_way_up(self):
self.assertEqual(
dep.listing(self.rapport(), "bas", "falls"), ["haut", "milieu"]
)
def test_every_mode_has_a_title(self):
for mode in dep.DETAILS:
self.assertIn(mode, dep.TITRE_DETAIL)
class TestPaneText(unittest.TestCase):
def rapport(self):
return base(
{"web_responsive": "installed", "compagnon": "uninstalled"},
{"compagnon": ["web_responsive"]},
)
def test_it_answers_the_removal_question_in_words(self):
# Deux chiffres à comparer soi-même, ce n'est pas une réponse.
texte = dep.pane_text(self.rapport(), "web_responsive")
self.assertIn("rien d'installé n'en dépend", texte)
def test_it_stays_silent_when_something_does_depend_on_it(self):
rapport = self.rapport()
rapport["modules"]["compagnon"] = ("installed", "", False, "")
self.assertNotIn(
"rien d'installé n'en dépend",
dep.pane_text(rapport, "web_responsive"),
)
def test_an_absent_dependent_is_marked_as_such(self):
texte = dep.pane_text(
self.rapport(), "web_responsive", mode="dependents"
)
self.assertIn("compagnon", texte)
self.assertIn("absent", texte)
def test_an_unknown_module_says_so(self):
self.assertIn("inconnu", dep.pane_text(self.rapport(), "jamais_vu"))
def test_nothing_selected_is_not_a_crash(self):
self.assertTrue(dep.pane_text(self.rapport(), None))
def test_an_empty_listing_says_nothing_rather_than_showing_blank(self):
texte = dep.pane_text(self.rapport(), "web_responsive", mode="depends")
self.assertIn("rien", texte.lower())
def test_the_limit_announces_what_it_cut(self):
rapport = base(
dict(
{"chef": "installed"},
**{f"d{i}": "installed" for i in range(9)},
),
{"chef": [f"d{i}" for i in range(9)]},
)
texte = dep.pane_text(rapport, "chef", mode="depends", limit=4)
self.assertIn("5", texte)
class TestRenderText(unittest.TestCase):
def rapport(self):
return base({f"m{i:02d}": "installed" for i in range(30)})
def test_without_a_cap_everything_is_there(self):
lignes = dep.render_text(self.rapport())
self.assertEqual(sum(1 for x in lignes if x.startswith("✅")), 30)
def test_the_cap_bounds_the_module_list(self):
# Sur une vraie base c'est 3035 modules et 6046 lignes : sans
# borne, le repli du menu est une seconde panne.
lignes = dep.render_text(self.rapport(), cap=5)
self.assertEqual(sum(1 for x in lignes if x.startswith("✅")), 5)
def test_the_cap_says_how_many_it_hid(self):
self.assertTrue(
any("25" in x for x in dep.render_text(self.rapport(), cap=5))
)
def test_an_unreadable_database_says_so(self):
lignes = dep.render_text({"database": "x", "unavailable": True})
self.assertEqual(len(lignes), 1)
self.assertIn("x", lignes[0])
class TestSurvey(unittest.TestCase):
def setUp(self):
from script.analyse import check_module_package as package
self.package = package
self.vrai = (package.census, package.dependencies, package.db_version)
def tearDown(self):
(
self.package.census,
self.package.dependencies,
self.package.db_version,
) = self.vrai
def poser(self, recensement, depend):
self.package.census = lambda d: recensement
self.package.dependencies = lambda d: depend
self.package.db_version = lambda d: "18.0"
def test_a_module_without_dependency_still_exists_in_the_graph(self):
# Sans cela, `closure` et l'écran le traitent comme inconnu.
self.poser({"seul": ("installed", "", False, "")}, {})
self.assertEqual(dep.survey("x")["depends"], {"seul": []})
def test_an_unreadable_base_is_not_an_empty_one(self):
self.poser(None, {})
self.assertTrue(dep.survey("x").get("unavailable"))
def test_an_empty_base_is_readable(self):
self.poser({}, {})
self.assertFalse(dep.survey("x").get("unavailable"))
def test_it_reads_the_base_once_for_both_views(self):
appels = []
self.poser({"a": ("installed", "", False, "")}, {})
vrai = self.package.census
self.package.census = lambda d: (appels.append(d), vrai(d))[1]
dep.survey("x")
self.assertEqual(appels, ["x"])
class TestTuiPureParts(unittest.TestCase):
def test_the_mode_cycles_through_all_of_them_and_returns(self):
vus, mode = [], None
for _ in range(len(dep.DETAILS) + 1):
mode = tui.next_mode(mode)
vus.append(mode)
self.assertEqual(vus, list(dep.DETAILS) + [None])
def test_an_unknown_mode_restarts_the_cycle(self):
self.assertEqual(tui.next_mode("n'importe quoi"), dep.DETAILS[0])
def test_the_filter_cycles(self):
self.assertEqual(tui.next_filter(dep.FILTRES[-1]), dep.FILTRES[0])
def test_the_cursor_out_of_range_is_not_a_crash(self):
# La table garde son curseur quand la liste raccourcit.
self.assertIsNone(tui.current_name([{"name": "a"}], 7))
self.assertIsNone(tui.current_name([], 0))
self.assertIsNone(tui.current_name([{"name": "a"}], None))
def test_the_cursor_follows_the_module_across_a_filter(self):
lst = [{"name": "alpha"}, {"name": "zebre"}]
self.assertEqual(tui.cursor_for(lst, "zebre"), 1)
def test_a_module_filtered_away_loses_the_cursor(self):
self.assertIsNone(tui.cursor_for([{"name": "alpha"}], "zebre"))
def test_the_search_ignores_the_case(self):
lst = [{"name": "Web_Responsive"}, {"name": "mail"}]
self.assertEqual(len(tui.matching(lst, "web")), 1)
def test_an_empty_search_keeps_everything(self):
lst = [{"name": "a"}, {"name": "b"}]
self.assertIs(tui.matching(lst, ""), lst)
def test_the_subtitle_names_the_three_states(self):
texte = tui.subtitle("depends", "installed", "web")
self.assertIn("directement", texte)
self.assertIn("installés", texte)
self.assertIn("web", texte)
class FausseTable:
"""Le contrat de DataTable, réduit à ce que `populate` en utilise."""
def __init__(self):
self.lignes = []
self.curseur = None
def clear(self):
self.lignes = []
def add_row(self, *cellules):
self.lignes.append(cellules)
def move_cursor(self, row=None):
self.curseur = row
class TestPopulate(unittest.TestCase):
def test_it_fills_and_keeps_the_cursor_on_the_module(self):
table = FausseTable()
lst = [
{"name": "alpha", "label": "✅ alpha", "detail": "0↓ 0/0↑"},
{"name": "zebre", "label": "✅ zebre", "detail": "1↓ 0/0↑"},
]
self.assertEqual(tui.populate(table, lst, "zebre"), 1)
self.assertEqual(len(table.lignes), 2)
self.assertEqual(table.curseur, 1)
def test_it_clears_before_refilling(self):
table = FausseTable()
table.add_row("vieux")
tui.populate(table, [{"name": "a", "label": "a", "detail": ""}])
self.assertEqual(len(table.lignes), 1)
def test_a_vanished_module_leaves_the_cursor_alone(self):
table = FausseTable()
tui.populate(
table, [{"name": "a", "label": "a", "detail": ""}], "parti"
)
self.assertIsNone(table.curseur)
class FauxChamp:
"""Le contrat d'Input, réduit à ce que `hide_find` en utilise."""
def __init__(self):
self.value = "web"
self.classes = {"visible"}
def remove_class(self, nom):
self.classes.discard(nom)
def has_class(self, nom):
return nom in self.classes
class FausseTableFocus(FausseTable):
def __init__(self):
super().__init__()
self.focalisee = False
def focus(self):
self.focalisee = True
class TestHideFind(unittest.TestCase):
def test_it_hands_the_keyboard_back_to_the_list(self):
# Le geste qui compte : sans lui, Textual laisse le clavier au
# champ de recherche et plus aucune touche n'agit.
champ, table = FauxChamp(), FausseTableFocus()
tui.hide_find(champ, table)
self.assertTrue(table.focalisee)
def test_it_hides_the_field(self):
champ, table = FauxChamp(), FausseTableFocus()
tui.hide_find(champ, table)
self.assertFalse(champ.has_class("visible"))
def test_it_leaves_the_text_alone(self):
# Effacer ici ferait deux chemins pour le même état : l'appelant
# vide le champ, et c'est CE geste qui redéclenche le filtrage.
champ, table = FauxChamp(), FausseTableFocus()
tui.hide_find(champ, table)
self.assertEqual(champ.value, "web")
class TestTheScreenRefusesWhenItCannot(unittest.TestCase):
def test_an_unreadable_report_opens_nothing(self):
self.assertFalse(tui.run_tui({"unavailable": True}))
def test_an_empty_base_opens_nothing(self):
self.assertFalse(tui.run_tui({"modules": {}}))
def test_nothing_at_all_opens_nothing(self):
self.assertFalse(tui.run_tui(None))
def textual_present():
try:
import textual # noqa: F401
except Exception:
return False
return True
@unittest.skipUnless(textual_present(), "textual absent")
class TestTheScreenActuallyDrives(unittest.TestCase):
"""Conduire l'écran, touche par touche.
Les fonctions pures étaient toutes vertes et l'écran, lui, ne
répondait pas : le champ de recherche caché prenait le focus au
démarrage — « display: none » ne retire pas un widget du parcours du
clavier — donc « d » tapait dans une boîte invisible. Seul un test
qui PRESSE les touches pouvait le voir.
"""
def rapport(self):
return base(
{
"web_responsive": "installed",
"mail": "installed",
"compagnon": "uninstalled",
"casse": "installed",
"zebre": "installed",
},
{
"web_responsive": ["mail"],
"compagnon": ["web_responsive"],
"casse": ["fantome"],
"zebre": ["web_responsive"],
},
)
def conduire(self, scenario):
import asyncio
app = tui.build_app(self.rapport())
async def piloter():
async with app.run_test() as pilote:
await scenario(app, pilote)
asyncio.run(piloter())
return app
def test_the_keyboard_starts_on_the_list_not_in_the_search(self):
vus = []
async def scenario(app, pilote):
vus.append(type(app.focused).__name__)
self.conduire(scenario)
self.assertEqual(vus, ["DataTable"])
def test_pressing_d_walks_every_relation_and_comes_back(self):
vus = []
async def scenario(app, pilote):
for _ in range(len(dep.DETAILS) + 1):
await pilote.press("d")
vus.append(app.mode)
self.conduire(scenario)
self.assertEqual(vus, list(dep.DETAILS) + [None])
def test_pressing_f_narrows_the_list(self):
vus = {}
async def scenario(app, pilote):
table = app.query_one("#left")
for _ in range(len(dep.FILTRES)):
await pilote.press("f")
vus[app.filtre] = table.row_count
self.conduire(scenario)
self.assertEqual(vus["all"], 5)
self.assertEqual(vus["installed"], 4)
self.assertEqual(vus["absent"], 1)
self.assertEqual(vus["broken"], 1)
def test_the_search_filters_then_gives_the_keyboard_back(self):
vus = {}
async def scenario(app, pilote):
await pilote.press("slash")
vus["focus_ouvert"] = type(app.focused).__name__
for lettre in "web":
await pilote.press(lettre)
await pilote.pause()
vus["lignes"] = app.query_one("#left").row_count
await pilote.press("enter")
vus["focus_rendu"] = type(app.focused).__name__
await pilote.press("d")
vus["mode"] = app.mode
self.conduire(scenario)
self.assertEqual(vus["focus_ouvert"], "Input")
self.assertEqual(vus["lignes"], 1)
self.assertEqual(vus["focus_rendu"], "DataTable")
# « d » doit remarcher une fois la recherche finie : sans cela on
# ne peut plus rien faire après avoir cherché.
self.assertEqual(vus["mode"], dep.DETAILS[0])
def test_escape_closes_the_search_before_it_closes_the_screen(self):
vus = {}
async def scenario(app, pilote):
await pilote.press("slash")
for lettre in "web":
await pilote.press(lettre)
await pilote.pause()
await pilote.press("escape")
await pilote.pause()
vus["motif"] = app.motif
vus["lignes"] = app.query_one("#left").row_count
vus["vivant"] = app.is_running
# La tabulation ne doit pas non plus retomber dans le champ
# refermé : « display: none » ne l'en retire pas, seul
# can_focus le fait.
await pilote.press("tab")
vus["apres_tab"] = type(app.focused).__name__
await pilote.press("escape")
await pilote.pause()
vus["apres_second_echap"] = app.is_running
self.conduire(scenario)
self.assertNotEqual(vus["apres_tab"], "Input")
# …et le second échap, lui, ferme bien l'écran.
self.assertFalse(vus["apres_second_echap"])
# Renoncer à une recherche ne doit pas fermer l'écran : on a
# parfois filtré trois mille modules pour arriver là.
self.assertTrue(vus["vivant"])
self.assertEqual(vus["motif"], "")
self.assertEqual(vus["lignes"], 5)
if __name__ == "__main__":
unittest.main()