[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:
parent
4028421cb8
commit
0fdfeaa9ce
4 changed files with 97 additions and 21 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?",
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
Loading…
Reference in a new issue