From 0fdfeaa9ce5aeb57125e935fdbc082eb1692f471 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 19 Aug 2026 10:52:49 -0400 Subject: [PATCH] [FIX] migration state: open the quality screen in its own process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pressing « k » crashed with « asyncio.run() cannot be called from a running event loop ». `suspend()` hands the terminal over but does NOT stop the loop, and `app.run()` calls `asyncio.run()` underneath. A subprocess has its own loop and paints the terminal it was given. My test asserted that suspend() was CALLED, and that structure was correct — the launch inside it was not. Structure is not behaviour, and the test that would have caught this is the one that presses the key. It exists now, and a guard refuses the nested start with a sentence instead of forty lines of traceback. The screen module also lost its shebang: it has no main, so the line claimed something that was not true, and the permission rule was right to say so. --- FR --- [FIX] état de migration : ouvrir l'écran de qualité dans son processus « k » plantait sur « asyncio.run() cannot be called from a running event loop ». `suspend()` rend le terminal mais n'arrête PAS la boucle, et `app.run()` appelle `asyncio.run()` en dessous. Un sous-processus a sa propre boucle. Mon test vérifiait que suspend() était APPELÉ — et cette structure était juste ; c'est le lancement à l'intérieur qui ne l'était pas. La structure n'est pas le comportement, et le test qui aurait attrapé cela est celui qui presse la touche. Il existe désormais, et un garde-fou refuse le lancement imbriqué par une phrase plutôt que par quarante lignes de trace. Le module d'écran perd aussi son shebang : sans `main`, cette ligne annonçait une intention qui n'existait pas. Assisted-by: Claude Opus 5 --- script/analyse/check_migration_quality_tui.py | 22 +++++++- script/todo/migration_status_tui.py | 35 ++++++------ script/todo/todo_i18n.py | 8 +++ test/test_check_migration_quality.py | 53 ++++++++++++++++++- 4 files changed, 97 insertions(+), 21 deletions(-) diff --git a/script/analyse/check_migration_quality_tui.py b/script/analyse/check_migration_quality_tui.py index 1f570a2..dd8d5a9 100644 --- a/script/analyse/check_migration_quality_tui.py +++ b/script/analyse/check_migration_quality_tui.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 # © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) @@ -221,5 +220,26 @@ def run_tui(lst_snapshot, run_app=True): return False if not run_app: return app + if in_event_loop(): + # Filet de sécurité : `app.run()` appelle `asyncio.run()`, qui + # lève dans une boucle déjà en cours. Le dire vaut mieux que la + # trace de quarante lignes que cela produit — et l'appelant doit + # passer par un sous-processus. + print( + f"ℹ️ {t('Already inside a running screen: open it in its own')}" + f" {t('process instead.')}" + ) + return False app.run() return True + + +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 diff --git a/script/todo/migration_status_tui.py b/script/todo/migration_status_tui.py index 8311fb4..9c54888 100644 --- a/script/todo/migration_status_tui.py +++ b/script/todo/migration_status_tui.py @@ -339,30 +339,27 @@ def build_app(dct, path=None): """Ouvrir le rapport de qualité, sans quitter celui-ci. Les deux écrans répondent à deux questions voisines : « où en - est-on » et « qu'a-t-on gagné ou perdu en chemin ». Les - séparer par une touche plutôt que par deux commandes à retenir - est ce qui les rend utilisables ensemble. + est-on » et « qu'a-t-on gagné ou perdu en chemin ». - `suspend` rend le terminal à l'autre plein écran : deux - applications Textual ne peuvent pas peindre le même écran en - même temps. + En SOUS-PROCESSUS, et ce n'est pas un choix de confort : + `app.run()` appelle `asyncio.run()`, qui refuse de tourner + dans une boucle déjà en cours — et nous sommes justement + dedans. Mesuré, la trace est + « asyncio.run() cannot be called from a running event loop ». + `suspend()` rend le terminal ; le sous-processus a sa propre + boucle et le repeint. """ - from script.analyse import check_migration_quality as quality - from script.analyse.check_migration_quality_tui import ( - run_tui as run_quality, - ) + import subprocess - dct = quality.read_progression() - if not quality.chain(dct): - self.notify(t("No migration in progress.")) - return with self.suspend(): - lst = quality.survey( - dct, echo=lambda texte: print(f"⧖ {texte}", flush=True) + subprocess.call( + [ + sys.executable, + os.path.join( + "script", "analyse", "check_migration_quality.py" + ), + ] ) - if not run_quality(lst): - print(quality.render_text(lst)) - input(f"💬 {t('press to continue')} : ") def action_toggle_log(self): self.show_log = not self.show_log diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 3ddea51..03f72e4 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5747,6 +5747,14 @@ TRANSLATIONS = { "fr": "Qualité", "en": "Quality", }, + "Already inside a running screen: open it in its own": { + "fr": "Déjà dans un écran en cours : l'ouvrir dans son propre", + "en": "Already inside a running screen: open it in its own", + }, + "process instead.": { + "fr": "processus.", + "en": "process instead.", + }, "Clean the database before testing the pages?": { "fr": "Nettoyer la base avant de tester les pages ?", "en": "Clean the database before testing the pages?", diff --git a/test/test_check_migration_quality.py b/test/test_check_migration_quality.py index fd26953..eb264e6 100644 --- a/test/test_check_migration_quality.py +++ b/test/test_check_migration_quality.py @@ -392,7 +392,58 @@ class TestWhereItIsOffered(Base): source = inspect.getsource(stui.build_app) debut = source.index("def action_quality") - self.assertIn("self.suspend()", source[debut : debut + 1200]) + self.assertIn("self.suspend()", source[debut : debut + 1400]) + + def test_it_opens_it_in_its_OWN_process(self): + """Le défaut que « suspend est appelé » ne suffisait pas à attraper. + + `suspend()` rend le terminal mais n'arrête PAS la boucle asyncio. + `app.run()` appelle `asyncio.run()`, qui refuse de tourner dans une + boucle déjà en cours : « asyncio.run() cannot be called from a + running event loop ». Un sous-processus a sa propre boucle. + """ + import inspect + + from script.todo import migration_status_tui as stui + + source = inspect.getsource(stui.build_app) + debut = source.index("def action_quality") + fenetre = source[debut : debut + 1400] + self.assertIn("subprocess.call", fenetre) + self.assertNotIn("run_quality(", fenetre) + + +class TestItRefusesToNestItself(Base): + """Le filet de sécurité, pour qui rappellerait `run_tui` de l'intérieur. + + Une trace de quarante lignes n'apprend rien ; une phrase qui dit + d'ouvrir un processus à part, si. + """ + + def test_no_loop_running_is_the_normal_case(self): + self.assertFalse(qtui.in_event_loop()) + + def test_a_running_loop_is_detected(self): + import asyncio + + async def dedans(): + return qtui.in_event_loop() + + self.assertTrue(asyncio.run(dedans())) + + def test_run_tui_consults_it_before_starting(self): + import inspect + + source = inspect.getsource(qtui.run_tui) + self.assertLess( + source.index("in_event_loop()"), source.index("app.run()") + ) + + def test_the_refusal_says_what_to_do_instead(self): + import inspect + + source = inspect.getsource(qtui.run_tui) + self.assertIn("its own", source) if __name__ == "__main__":