diff --git a/script/analyse/check_module_dependency.py b/script/analyse/check_module_dependency.py new file mode 100755 index 0000000..93f9ff1 --- /dev/null +++ b/script/analyse/check_module_dependency.py @@ -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()) diff --git a/script/analyse/check_module_dependency_tui.py b/script/analyse/check_module_dependency_tui.py new file mode 100644 index 0000000..a5e7f81 --- /dev/null +++ b/script/analyse/check_module_dependency_tui.py @@ -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 diff --git a/script/todo/todo.py b/script/todo/todo.py index 7010111..8b958e0 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -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. diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index e6b1af2..84b46b4 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -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", diff --git a/test/test_check_module_dependency.py b/test/test_check_module_dependency.py new file mode 100644 index 0000000..596c797 --- /dev/null +++ b/test/test_check_module_dependency.py @@ -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()