[FIX] migration state: open the quality screen in its own process

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
This commit is contained in:
Mathieu Benoit 2026-08-19 10:52:49 -04:00
parent 4028421cb8
commit 0fdfeaa9ce
4 changed files with 97 additions and 21 deletions

View file

@ -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

View file

@ -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

View file

@ -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?",

View file

@ -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__":