from __future__ import annotations import os import queue import re import tkinter as tk from datetime import datetime from pathlib import Path from tkinter import filedialog, messagebox, ttk from typing import Any from platform_utils import WindowsLabelError, validate_windows_labels from .diagnostics import collect_diagnostics from .runner import ProcessRunner from .state import StateStore from .workflow import ( CommandVariant, StepDefinition, build_command, build_workflow, command_display, evaluation_argument, value_for_default, ) STATUS_LABELS = { "ready": "Prête", "running": "En cours", "success": "Réussie", "failed": "Échouée", "interrupted": "Interrompue", "skipped": "Ignorée", "stale": "À revalider", "detected": "Détectée", } class CopienatorApp(tk.Tk): def __init__( self, repository: Path, show_personal_steps: bool, initial_evaluation: Path | None = None, ) -> None: super().__init__() self.repository = repository.resolve() self.show_personal_steps = show_personal_steps self.steps = build_workflow(show_personal_steps) self.step_by_id = {step.id: step for step in self.steps} self.state_store = StateStore() self.runner = ProcessRunner() self.active_step_id: str | None = None self.current_step: StepDefinition | None = None self.arg_vars: dict[str, tk.Variable] = {} self._rendering = False self.title("Copienator — assistant de correction") self.geometry("1180x820") self.minsize(900, 640) self.protocol("WM_DELETE_WINDOW", self._on_close) self.evaluation_var = tk.StringVar() self.api_key_var = tk.StringVar(value=os.environ.get("GEMINI_API_KEY", "")) self.proxy_var = tk.StringVar(value=os.environ.get("HTTPS_PROXY", "")) self.variant_var = tk.StringVar() self.extra_var = tk.StringVar() self.command_var = tk.StringVar(value="Sélectionnez une étape.") self.info_var = tk.StringVar(value="Choisissez un dossier d’évaluation.") self._build_ui(show_personal_steps) self.extra_var.trace_add("write", lambda *_args: self._update_command_preview()) self.after(60, self._poll_runner) if initial_evaluation and initial_evaluation.is_dir(): self.evaluation_var.set(str(initial_evaluation.resolve())) self._load_evaluation() else: self._populate_tree() @property def evaluation(self) -> Path | None: value = self.evaluation_var.get().strip() return Path(value).expanduser().resolve() if value else None def _build_ui(self, show_personal_steps: bool) -> None: self.columnconfigure(0, weight=1) self.rowconfigure(1, weight=3) self.rowconfigure(2, weight=2) top = ttk.Frame(self, padding=(10, 8)) top.grid(row=0, column=0, sticky="ew") top.columnconfigure(1, weight=1) ttk.Label(top, text="Évaluation").grid(row=0, column=0, sticky="w", padx=(0, 8)) path_entry = ttk.Entry(top, textvariable=self.evaluation_var) path_entry.grid(row=0, column=1, sticky="ew") path_entry.bind("", lambda _event: self._load_evaluation()) ttk.Button(top, text="Parcourir…", command=self._browse_evaluation).grid(row=0, column=2, padx=6) ttk.Button(top, text="Charger", command=self._load_evaluation).grid(row=0, column=3) ttk.Button(top, text="Diagnostic…", command=self._show_diagnostics).grid(row=0, column=4, padx=(6, 0)) ttk.Label(top, text="Clé Gemini").grid(row=1, column=0, sticky="w", padx=(0, 8), pady=(7, 0)) ttk.Entry(top, textvariable=self.api_key_var, show="•", width=32).grid( row=1, column=1, sticky="w", pady=(7, 0) ) environment = ttk.Frame(top) environment.grid(row=1, column=2, columnspan=2, sticky="e", pady=(7, 0)) ttk.Label(environment, text="HTTPS_PROXY").pack(side="left", padx=(0, 6)) ttk.Entry(environment, textvariable=self.proxy_var, width=28).pack(side="left") profile = "standard + personnel" if show_personal_steps else "standard" ttk.Label(environment, text=f"Profil : {profile}").pack(side="left", padx=(12, 0)) main_pane = ttk.Panedwindow(self, orient="horizontal") main_pane.grid(row=1, column=0, sticky="nsew", padx=10) navigation = ttk.Frame(main_pane, padding=(0, 5, 6, 5)) self.tree = ttk.Treeview(navigation, columns=("status",), show="tree headings", selectmode="browse") self.tree.heading("#0", text="Étape") self.tree.heading("status", text="État") self.tree.column("#0", minwidth=230, width=280) self.tree.column("status", minwidth=90, width=105, anchor="center") tree_scroll = ttk.Scrollbar(navigation, orient="vertical", command=self.tree.yview) self.tree.configure(yscrollcommand=tree_scroll.set) self.tree.pack(side="left", fill="both", expand=True) tree_scroll.pack(side="right", fill="y") self.tree.bind("<>", self._on_tree_select) main_pane.add(navigation, weight=1) self.detail = ttk.Frame(main_pane, padding=(12, 6, 4, 4)) self.detail.columnconfigure(0, weight=1) self.detail.rowconfigure(2, weight=1) self.title_label = ttk.Label(self.detail, text="Sélectionnez une étape", font=("TkDefaultFont", 15, "bold")) self.title_label.grid(row=0, column=0, sticky="w") self.description_label = ttk.Label(self.detail, text="", wraplength=680, justify="left") self.description_label.grid(row=1, column=0, sticky="ew", pady=(5, 8)) self.form = ttk.Frame(self.detail) self.form.grid(row=2, column=0, sticky="nsew") self.form.columnconfigure(1, weight=1) command_box = ttk.LabelFrame(self.detail, text="Commande", padding=6) command_box.grid(row=3, column=0, sticky="ew", pady=(8, 5)) command_box.columnconfigure(0, weight=1) ttk.Label(command_box, textvariable=self.command_var, wraplength=690, justify="left").grid( row=0, column=0, sticky="ew" ) buttons = ttk.Frame(self.detail) buttons.grid(row=4, column=0, sticky="ew", pady=(5, 0)) self.previous_button = ttk.Button(buttons, text="← Précédente", command=lambda: self._move_selection(-1)) self.previous_button.pack(side="left") self.next_button = ttk.Button(buttons, text="Suivante →", command=lambda: self._move_selection(1)) self.next_button.pack(side="left", padx=6) self.skip_button = ttk.Button(buttons, text="Marquer ignorée", command=self._skip_step) self.skip_button.pack(side="right") self.run_button = ttk.Button(buttons, text="Exécuter", command=self._run_current_step) self.run_button.pack(side="right", padx=6) main_pane.add(self.detail, weight=3) console_frame = ttk.LabelFrame(self, text="Sortie en temps réel", padding=(7, 5)) console_frame.grid(row=2, column=0, sticky="nsew", padx=10, pady=(6, 8)) console_frame.columnconfigure(0, weight=1) console_frame.rowconfigure(0, weight=1) self.console = tk.Text( console_frame, height=14, wrap="word", background="#171717", foreground="#efefef", insertbackground="white", state="disabled", ) console_scroll = ttk.Scrollbar(console_frame, orient="vertical", command=self.console.yview) self.console.configure(yscrollcommand=console_scroll.set) self.console.grid(row=0, column=0, sticky="nsew") console_scroll.grid(row=0, column=1, sticky="ns") console_actions = ttk.Frame(console_frame) console_actions.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(5, 0)) console_actions.columnconfigure(1, weight=1) ttk.Label(console_actions, text="Réponse au script").grid(row=0, column=0, padx=(0, 6)) self.stdin_var = tk.StringVar() self.stdin_entry = ttk.Entry(console_actions, textvariable=self.stdin_var) self.stdin_entry.grid(row=0, column=1, sticky="ew") self.stdin_entry.bind("", lambda _event: self._send_input()) self.send_button = ttk.Button(console_actions, text="Envoyer", command=self._send_input, state="disabled") self.send_button.grid(row=0, column=2, padx=5) self.interrupt_button = ttk.Button( console_actions, text="Interrompre", command=self._interrupt, state="disabled" ) self.interrupt_button.grid(row=0, column=3, padx=5) self.force_button = ttk.Button( console_actions, text="Forcer l’arrêt", command=self._force_stop, state="disabled" ) self.force_button.grid(row=0, column=4) ttk.Button(console_actions, text="Effacer la console", command=self._clear_console).grid( row=0, column=5, padx=(8, 0) ) status = ttk.Label(self, textvariable=self.info_var, anchor="w", relief="sunken", padding=(6, 3)) status.grid(row=3, column=0, sticky="ew") def _show_diagnostics(self) -> None: checks = collect_diagnostics( self.show_personal_steps, self.api_key_var.get(), self.evaluation ) dialog = tk.Toplevel(self) dialog.title("Diagnostic Copienator") dialog.geometry("760x480") dialog.transient(self) dialog.columnconfigure(0, weight=1) dialog.rowconfigure(1, weight=1) required_missing = sum(1 for check in checks if check.required and not check.ok) summary = ( "Tous les prérequis obligatoires sont disponibles." if required_missing == 0 else f"{required_missing} prérequis obligatoire(s) manquant(s)." ) ttk.Label(dialog, text=summary, padding=10, font=("TkDefaultFont", 11, "bold")).grid( row=0, column=0, sticky="w" ) tree = ttk.Treeview(dialog, columns=("state", "need", "detail"), show="headings") tree.heading("state", text="État") tree.heading("need", text="Niveau") tree.heading("detail", text="Composant et détail") tree.column("state", width=85, anchor="center") tree.column("need", width=100, anchor="center") tree.column("detail", width=530) for check in checks: tree.insert( "", "end", values=( "OK" if check.ok else "Manquant", "Obligatoire" if check.required else "Optionnel", f"{check.name} — {check.detail}", ), ) tree.grid(row=1, column=0, sticky="nsew", padx=10) ttk.Button(dialog, text="Fermer", command=dialog.destroy).grid(row=2, column=0, pady=10) def _browse_evaluation(self) -> None: selected = filedialog.askdirectory(initialdir=self.evaluation_var.get() or self.repository) if selected: self.evaluation_var.set(selected) self._load_evaluation() def _load_evaluation(self) -> None: evaluation = self.evaluation if not evaluation or not evaluation.is_dir(): messagebox.showerror("Dossier invalide", "Choisissez un dossier d’évaluation existant.") return self._save_current_form() self.state_store.load(evaluation) for step in self.steps: if self.state_store.step(step.id).get("status") == "running": self.state_store.update_step(step.id, status="interrupted") self._populate_tree() missing = [name for name in ("enonce.pdf", "enonce.tex", "correction.tex") if not (evaluation / name).exists()] names_found = (evaluation / "names").exists() or (self.repository / "names").exists() details = [] if missing: details.append("manquants : " + ", ".join(missing)) if not names_found: details.append("fichier names introuvable") if not self.api_key_var.get(): details.append("clé Gemini non renseignée") self.info_var.set( f"Évaluation chargée : {evaluation}" + (" — " + " ; ".join(details) if details else " — prérequis principaux détectés") ) first = self.steps[0].id if self.steps else None if first: self.tree.selection_set(first) self.tree.see(first) def _populate_tree(self) -> None: selected = self.current_step.id if self.current_step else None self.tree.delete(*self.tree.get_children()) section_items: dict[str, str] = {} for step in self.steps: if step.section not in section_items: section_id = f"section:{len(section_items)}" section_items[step.section] = section_id self.tree.insert("", "end", iid=section_id, text=step.section, values=("",), open=True) status = self._step_status(step) suffix = " (facultative)" if step.optional else "" self.tree.insert( section_items[step.section], "end", iid=step.id, text=step.title + suffix, values=(STATUS_LABELS.get(status, status),), ) if selected and self.tree.exists(selected): self.tree.selection_set(selected) def _step_status(self, step: StepDefinition) -> str: if self.state_store.evaluation: saved = self.state_store.step(step.id).get("status") if saved: return str(saved) if self._artifacts_exist(step): return "detected" if step.id == "inputs" and not self._missing_requirements(step): return "detected" return "ready" def _artifacts_exist(self, step: StepDefinition) -> bool: evaluation = self.evaluation if not evaluation or not step.artifacts: return False for pattern in step.artifacts: if any(char in pattern for char in "*?["): if next(evaluation.glob(pattern), None) is not None: return True elif (evaluation / pattern).exists(): return True return False def _missing_requirements(self, step: StepDefinition) -> list[str]: evaluation = self.evaluation if not evaluation: return list(step.requires) missing = [] for pattern in step.requires: if any(char in pattern for char in "*?["): exists = next(evaluation.glob(pattern), None) is not None else: exists = (evaluation / pattern).exists() if not exists: missing.append(pattern) return missing def _on_tree_select(self, _event: tk.Event[Any] | None = None) -> None: selection = self.tree.selection() if not selection or selection[0].startswith("section:"): return step = self.step_by_id.get(selection[0]) if not step: return if self.current_step and self.current_step.id != step.id: self._save_current_form() self.current_step = step self._render_step() def _render_step(self) -> None: step = self.current_step if not step: return self._rendering = True for child in self.form.winfo_children(): child.destroy() self.arg_vars.clear() entry = self.state_store.step(step.id) if self.state_store.evaluation else {} saved_variant = entry.get("variant", step.variants[0].id) if saved_variant not in {variant.id for variant in step.variants}: saved_variant = step.variants[0].id self.variant_var.set(saved_variant) self.extra_var.set(str(entry.get("extra", ""))) self.title_label.configure(text=step.title) missing = self._missing_requirements(step) description = step.description if missing: description += "\nPrérequis non détectés : " + ", ".join(missing) self.description_label.configure(text=description) row = 0 if len(step.variants) > 1: ttk.Label(self.form, text="Mode").grid(row=row, column=0, sticky="w", pady=4, padx=(0, 8)) labels = [variant.label for variant in step.variants] combo = ttk.Combobox(self.form, values=labels, state="readonly") variant_index = [variant.id for variant in step.variants].index(saved_variant) combo.current(variant_index) combo.grid(row=row, column=1, sticky="ew", pady=4) combo.bind("<>", lambda _event: self._select_variant(combo.current())) row += 1 values = entry.get("values", {}) if isinstance(entry.get("values", {}), dict) else {} variant = self._current_variant() evaluation_arg = self._evaluation_arg() for spec in step.arguments: if spec.variants and variant.id not in spec.variants: continue value = values.get(spec.name, value_for_default(spec.default, evaluation_arg)) ttk.Label(self.form, text=spec.label).grid(row=row, column=0, sticky="nw", pady=4, padx=(0, 8)) if spec.kind == "bool": variable: tk.Variable = tk.BooleanVar(value=bool(value)) widget = ttk.Checkbutton(self.form, variable=variable) widget.grid(row=row, column=1, sticky="w", pady=4) elif spec.kind == "choice": variable = tk.StringVar(value=str(value)) widget = ttk.Combobox(self.form, textvariable=variable, values=spec.choices, state="readonly") widget.grid(row=row, column=1, sticky="ew", pady=4) else: variable = tk.StringVar(value=str(value)) field = ttk.Frame(self.form) field.grid(row=row, column=1, sticky="ew", pady=4) field.columnconfigure(0, weight=1) widget = ttk.Entry(field, textvariable=variable) widget.grid(row=0, column=0, sticky="ew") if spec.kind == "path": ttk.Button(field, text="Fichier…", command=lambda var=variable: self._browse_target_file(var)).grid( row=0, column=1, padx=(5, 0) ) ttk.Button(field, text="Dossier…", command=lambda var=variable: self._browse_target_dir(var)).grid( row=0, column=2, padx=(5, 0) ) self.arg_vars[spec.name] = variable variable.trace_add("write", lambda *_args: self._update_command_preview()) if spec.help: ttk.Label(self.form, text=spec.help, foreground="#666666", wraplength=540).grid( row=row + 1, column=1, sticky="w" ) row += 1 row += 1 if not step.is_manual: ttk.Label(self.form, text="Arguments supplémentaires").grid( row=row, column=0, sticky="w", pady=(10, 4), padx=(0, 8) ) ttk.Entry(self.form, textvariable=self.extra_var).grid(row=row, column=1, sticky="ew", pady=(10, 4)) self.run_button.configure(text="Marquer terminée" if step.is_manual else "Exécuter") self.skip_button.configure(state="normal" if step.optional else "disabled") self._rendering = False self._update_command_preview() self._update_controls() def _select_variant(self, index: int) -> None: if not self.current_step or index < 0: return self._save_current_form() self.variant_var.set(self.current_step.variants[index].id) if self.state_store.evaluation: self.state_store.update_step(self.current_step.id, variant=self.variant_var.get()) self._render_step() def _current_variant(self) -> CommandVariant: assert self.current_step is not None selected = self.variant_var.get() return next((variant for variant in self.current_step.variants if variant.id == selected), self.current_step.variants[0]) def _values(self) -> dict[str, object]: return {name: variable.get() for name, variable in self.arg_vars.items()} def _save_current_form(self) -> None: if self._rendering or not self.current_step or not self.state_store.evaluation: return saved_values = self.state_store.step(self.current_step.id).get("values", {}) merged_values = dict(saved_values) if isinstance(saved_values, dict) else {} merged_values.update(self._values()) self.state_store.update_step( self.current_step.id, variant=self.variant_var.get() or self.current_step.variants[0].id, values=merged_values, extra=self.extra_var.get(), ) def _evaluation_arg(self) -> str: evaluation = self.evaluation return evaluation_argument(self.repository, evaluation) if evaluation else "<évaluation>" def _make_command(self) -> list[str]: if not self.current_step: return [] return build_command( self.repository, self.current_step, self._current_variant(), self._values(), self._evaluation_arg(), self.extra_var.get(), ) def _update_command_preview(self) -> None: if self._rendering or not self.current_step: return if self.current_step.is_manual: self.command_var.set("Étape manuelle — aucune commande ne sera exécutée.") return try: self.command_var.set(command_display(self._make_command())) except ValueError as exc: self.command_var.set(f"Arguments invalides : {exc}") def _browse_target_file(self, variable: tk.Variable) -> None: selected = filedialog.askopenfilename(initialdir=self.evaluation or self.repository) if selected: variable.set(selected) def _browse_target_dir(self, variable: tk.Variable) -> None: selected = filedialog.askdirectory(initialdir=self.evaluation or self.repository) if selected: variable.set(selected) def _validate_arguments(self) -> bool: if not self.current_step: return False variant = self._current_variant() for spec in self.current_step.arguments: if spec.variants and variant.id not in spec.variants: continue value = self.arg_vars.get(spec.name) if spec.kind == "int" and value and str(value.get()).strip(): try: int(str(value.get())) except ValueError: messagebox.showerror("Argument invalide", f"« {spec.label} » doit être un entier.") return False try: self._make_command() except ValueError as exc: messagebox.showerror("Arguments invalides", str(exc)) return False return True def _run_current_step(self) -> None: step = self.current_step evaluation = self.evaluation if not step or not evaluation or not self.state_store.evaluation: messagebox.showerror("Évaluation absente", "Chargez d’abord un dossier d’évaluation.") return if self.runner.running: messagebox.showwarning("Traitement en cours", "Interrompez le traitement actuel avant d’en lancer un autre.") return if step.is_manual: self._mark_step("success") return if os.name == "nt" and step.id != "statement": labels_path = evaluation / "labels" if labels_path.is_file(): labels = [ line.strip() for line in labels_path.read_text(encoding="utf-8").splitlines() if line.strip() ] try: validate_windows_labels(labels) except WindowsLabelError as exc: messagebox.showerror("Labels incompatibles avec Windows", str(exc)) return if not self._validate_arguments(): return missing = self._missing_requirements(step) if missing and not messagebox.askyesno( "Prérequis non détectés", "Les éléments suivants n’ont pas été trouvés :\n\n" + "\n".join(missing) + "\n\nLancer tout de même la commande ?", ): return variant = self._current_variant() command = self._make_command() if variant.dangerous and not messagebox.askyesno( "Confirmation requise", "Cette commande réinitialise ou supprime des résultats de correction. Continuer ?", icon="warning", ): return self._save_current_form() ordered_ids = [item.id for item in self.steps] self.state_store.invalidate_after(ordered_ids, step.id) self.state_store.update_step(step.id, status="running", command=command_display(command)) self.active_step_id = step.id timestamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S") safe_step = re.sub(r"[^A-Za-z0-9_.-]+", "_", step.id) log_path = evaluation / ".copienator" / "logs" / f"{timestamp}-{safe_step}.log" environment = os.environ.copy() environment["PYTHONUNBUFFERED"] = "1" environment["PYTHONIOENCODING"] = "utf-8" if self.api_key_var.get().strip(): environment["GEMINI_API_KEY"] = self.api_key_var.get().strip() if self.proxy_var.get().strip(): environment["HTTPS_PROXY"] = self.proxy_var.get().strip() self._append_console(f"\n$ {command_display(command)}\n") try: self.runner.start(command, self.repository, environment, log_path) except (OSError, RuntimeError) as exc: self.state_store.update_step(step.id, status="failed") self.state_store.add_history( {"step": step.id, "command": command_display(command), "status": "failed", "error": str(exc)} ) self.active_step_id = None self._append_console(f"Impossible de lancer la commande : {exc}\n") messagebox.showerror("Échec du lancement", str(exc)) self._populate_tree() self._update_controls() def _mark_step(self, status: str) -> None: if not self.current_step or not self.state_store.evaluation: return self._save_current_form() self.state_store.invalidate_after([item.id for item in self.steps], self.current_step.id) self.state_store.update_step(self.current_step.id, status=status) self.state_store.add_history({"step": self.current_step.id, "status": status, "manual": True}) self._populate_tree() self.info_var.set(f"{self.current_step.title} : {STATUS_LABELS.get(status, status).lower()}.") def _skip_step(self) -> None: if self.current_step and self.current_step.optional: self._mark_step("skipped") def _poll_runner(self) -> None: while True: try: event, payload = self.runner.events.get_nowait() except queue.Empty: break if event == "output": self._append_console(str(payload)) elif event == "input_echo": self._append_console(f"> {payload}") elif event == "runner_error": self._append_console(f"\nErreur du lanceur : {payload}\n") elif event == "finished": return_code, interrupted = payload self._finish_process(int(return_code), bool(interrupted)) self.after(60, self._poll_runner) def _finish_process(self, return_code: int, interrupted: bool) -> None: step_id = self.active_step_id if not step_id: return if interrupted: status = "interrupted" else: status = "success" if return_code == 0 else "failed" self.state_store.update_step(step_id, status=status, return_code=return_code) self.state_store.add_history( { "step": step_id, "command": self.state_store.step(step_id).get("command", ""), "status": status, "return_code": return_code, } ) title = self.step_by_id[step_id].title self._append_console(f"\n[Terminé — code {return_code} — {STATUS_LABELS[status]}]\n") self.info_var.set(f"{title} : {STATUS_LABELS[status].lower()} (code {return_code}).") self.active_step_id = None self._populate_tree() self._update_controls() def _send_input(self) -> None: text = self.stdin_var.get() try: self.runner.send_input(text) except RuntimeError as exc: messagebox.showinfo("Aucune saisie attendue", str(exc)) return self.stdin_var.set("") def _interrupt(self) -> None: if self.runner.running and messagebox.askyesno( "Interrompre", "Envoyer une interruption au script en cours ?" ): try: self.runner.interrupt() self.info_var.set("Interruption demandée. Utilisez « Forcer l’arrêt » si le script ne répond pas.") except OSError as exc: messagebox.showerror("Interruption impossible", str(exc)) def _force_stop(self) -> None: if self.runner.running and messagebox.askyesno( "Forcer l’arrêt", "Forcer l’arrêt du script et de ses processus enfants ?", icon="warning" ): try: self.runner.force_stop() except OSError as exc: messagebox.showerror("Arrêt impossible", str(exc)) def _append_console(self, text: str) -> None: self.console.configure(state="normal") self.console.insert("end", text) self.console.see("end") self.console.configure(state="disabled") def _clear_console(self) -> None: self.console.configure(state="normal") self.console.delete("1.0", "end") self.console.configure(state="disabled") def _update_controls(self) -> None: running = self.runner.running self.run_button.configure(state="disabled" if running or not self.current_step else "normal") self.interrupt_button.configure(state="normal" if running else "disabled") self.force_button.configure(state="normal" if running else "disabled") self.send_button.configure(state="normal" if running else "disabled") self.stdin_entry.configure(state="normal" if running else "disabled") def _move_selection(self, delta: int) -> None: if not self.current_step: return ids = [step.id for step in self.steps] try: index = ids.index(self.current_step.id) except ValueError: return target = max(0, min(len(ids) - 1, index + delta)) self.tree.selection_set(ids[target]) self.tree.see(ids[target]) def _on_close(self) -> None: if self.runner.running: if not messagebox.askyesno( "Traitement en cours", "Un script est encore actif. Le forcer à s’arrêter et fermer l’application ?" ): return try: self.runner.force_stop() except OSError: pass self._save_current_form() self.destroy()