[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:
parent
f12e79c3a9
commit
7923e37e4f
5 changed files with 1504 additions and 0 deletions
377
script/analyse/check_module_dependency.py
Executable file
377
script/analyse/check_module_dependency.py
Executable 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())
|
||||
324
script/analyse/check_module_dependency_tui.py
Normal file
324
script/analyse/check_module_dependency_tui.py
Normal 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
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
631
test/test_check_module_dependency.py
Normal file
631
test/test_check_module_dependency.py
Normal 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()
|
||||
Loading…
Reference in a new issue