from __future__ import annotations import os import queue import tkinter as tk 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 workspace = self.state_store.workspace assert workspace is not None log_path = workspace.log_path(step.id) 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()