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 copienator import ExitCode from copienator.platform import ( WindowsLabelError, add_platform_executable_paths, open_path, 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", "partial": "Partielle", "failed": "Échouée", "interrupted": "Interrompue", "skipped": "Ignorée", "stale": "À revalider", "detected": "Détectée", } DEFAULT_HTTPS_PROXY = "http://10.0.0.1:3128" ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot") ANNOTATION_VARIANT_DIRECTORIES = { "simple": "Anot", "checks": "Bnot", "grouped": "BGnot", } def copy_pdf_paths(evaluation: Path) -> list[Path]: """List the most relevant version of each scanned copy.""" locations = (evaluation / "Copies", evaluation, evaluation / "Copies Originales") for location in locations: if not location.is_dir(): continue copies = sorted( ( path for path in location.glob("*.pdf") if path.name.casefold() not in {"enonce.pdf", "énoncé.pdf"} ), key=lambda path: path.name.casefold(), ) if copies: return copies return [] def detected_annotation_directories(evaluation: Path) -> tuple[str, ...]: return tuple( name for name in ANNOTATION_DIRECTORIES if (evaluation / name).is_dir() ) def plotting_shortcut_lines() -> list[str]: try: from copienator.configuration import PLOTTING_KB except (ImportError, AttributeError): from default_config import PLOTTING_KB labels = ( ("OK", "valider et passer à la suivante"), ("previous", "revenir à la précédente"), ("edit", "éditer le fichier JSON"), ("open pdf", "ouvrir la copie traitée"), ("open original pdf", "ouvrir la copie originale"), ("open eval", "ouvrir l’énoncé"), ) display_names = {"": "Entrée", "": "Échap"} lines = [ f"{display_names.get(PLOTTING_KB[action], PLOTTING_KB[action])} : {description}" for action, description in labels ] lines.append("Échap : fermer la fenêtre") return lines def build_runner_environment( base: dict[str, str], api_key: str, proxy: str, use_proxy: bool ) -> dict[str, str]: environment = add_platform_executable_paths(base) environment["PYTHONUNBUFFERED"] = "1" environment["PYTHONIOENCODING"] = "utf-8" if api_key.strip(): environment["GEMINI_API_KEY"] = api_key.strip() environment.pop("HTTPS_PROXY", None) environment.pop("https_proxy", None) if use_proxy and proxy.strip(): environment["HTTPS_PROXY"] = proxy.strip() return environment def process_status(return_code: int, interrupted: bool = False) -> str: if interrupted or return_code == ExitCode.INTERRUPTED: return "interrupted" if return_code == ExitCode.SUCCESS: return "success" if return_code == ExitCode.PARTIAL: return "partial" return "failed" def has_manual_conflicts(path: Path) -> bool: """Return whether a manual-resolution file contains an instruction.""" try: lines = path.read_text(encoding="utf-8").splitlines() except FileNotFoundError: return False except (OSError, UnicodeError): # If the file cannot be inspected, keep the step visible rather than # silently claiming that there is nothing to resolve. return True return any(line.strip() and not line.lstrip().startswith("###") for line in lines) 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.copy_paths: dict[str, Path] = {} 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=DEFAULT_HTTPS_PROXY) self.use_proxy_var = tk.BooleanVar(value=False) self.copy_var = tk.StringVar() 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(2, weight=1) ttk.Button(top, text="Charger", command=self._load_evaluation).grid( row=0, column=0, padx=(0, 8) ) ttk.Label(top, text="Évaluation").grid(row=0, column=1, sticky="w", padx=(0, 8)) path_entry = ttk.Entry(top, textvariable=self.evaluation_var) path_entry.grid(row=0, column=2, sticky="ew") path_entry.bind("", lambda _event: self._load_evaluation()) ttk.Button(top, text="Parcourir…", command=self._browse_evaluation).grid( row=0, column=3, padx=6 ) 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=3, sticky="e", pady=(7, 0)) ttk.Checkbutton( environment, text="Utiliser le proxy HTTPS", variable=self.use_proxy_var, command=self._toggle_proxy, ).pack(side="left", padx=(0, 6)) self.proxy_entry = ttk.Entry(environment, textvariable=self.proxy_var, width=28, state="disabled") self.proxy_entry.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 _toggle_proxy(self) -> None: self.proxy_entry.configure(state="normal" if self.use_proxy_var.get() else "disabled") 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() self._handle_first_visit(step) def _handle_first_visit(self, step: StepDefinition) -> None: if not self.state_store.evaluation: return entry = self.state_store.step(step.id) if entry.get("visited"): return # Persist this before scheduling an action so selection callbacks cannot # trigger the same automatic behavior twice. self.state_store.update_step(step.id, visited=True) if step.skip_for_live_correction: correction = self.state_store.step("correction") if correction.get("variant", "live") == "live": self.after_idle( lambda step_id=step.id: self._automatic_skip( step_id, "correction immédiate sélectionnée" ) ) return if step.skip_without_manual_conflicts: evaluation = self.evaluation conflicts = evaluation / "manual_resolutions.txt" if evaluation else None if conflicts is None or not has_manual_conflicts(conflicts): self.after_idle( lambda step_id=step.id: self._automatic_skip( step_id, "aucun conflit manuel détecté" ) ) return if step.auto_start_first_visit and not entry.get("status") and not self._artifacts_exist(step): self.after_idle(lambda step_id=step.id: self._automatic_start(step_id)) def _automatic_start(self, step_id: str) -> None: if self.runner.running or not self.current_step or self.current_step.id != step_id: return self.info_var.set(f"{self.current_step.title} : démarrage automatique.") self._run_current_step() def _automatic_skip(self, step_id: str, reason: str) -> None: if self.runner.running or not self.current_step or self.current_step.id != step_id: return self._mark_step("skipped", automatic=True, reason=reason) self._move_selection_from(step_id, 1) 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 = self._render_context_controls(step, 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)) choices = spec.choices if spec.name == "annotation_dir" and step.id in {"export", "import"}: choices, value = self._annotation_directory_choices(step.id) 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=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 _render_context_controls(self, step: StepDefinition, row: int) -> int: if step.id == "review_persp": ttk.Button( self.form, text="Ouvrir le dossier Persp", command=self._open_persp, ).grid(row=row, column=0, columnspan=2, sticky="w", pady=(0, 8)) row += 1 if step.section == "Prétraitement des copies": evaluation = self.evaluation paths = copy_pdf_paths(evaluation) if evaluation else [] self.copy_paths = {path.name: path for path in paths} names = list(self.copy_paths) copies = ttk.LabelFrame(self.form, text="Copies détectées", padding=7) copies.grid(row=row, column=0, columnspan=2, sticky="ew", pady=(0, 8)) copies.columnconfigure(0, weight=1) summary = ( f"{len(names)} copie(s) : {', '.join(names)}" if names else "Aucune copie PDF détectée." ) ttk.Label(copies, text=summary, wraplength=570, justify="left").grid( row=0, column=0, columnspan=2, sticky="ew" ) self.copy_var.set(names[0] if names else "") selector = ttk.Combobox( copies, textvariable=self.copy_var, values=names, state="readonly" if names else "disabled", ) selector.grid(row=1, column=0, sticky="ew", pady=(7, 0)) ttk.Button( copies, text="Afficher la copie", command=self._open_selected_copy, state="normal" if names else "disabled", ).grid(row=1, column=1, padx=(6, 0), pady=(7, 0)) row += 1 if step.id == "plotting": shortcuts = ttk.LabelFrame(self.form, text="Raccourcis clavier", padding=7) shortcuts.grid(row=row, column=0, columnspan=2, sticky="ew", pady=(0, 8)) ttk.Label( shortcuts, text="\n".join(plotting_shortcut_lines()), justify="left", ).grid(row=0, column=0, sticky="w") row += 1 return row def _annotation_directory_choices(self, step_id: str) -> tuple[tuple[str, ...], str]: evaluation = self.evaluation detected = detected_annotation_directories(evaluation) if evaluation else () choices = detected or ANNOTATION_DIRECTORIES if step_id == "export": annotation = self.state_store.step("annotation") variant = annotation.get("last_run_variant", annotation.get("variant", "grouped")) preferred = ANNOTATION_VARIANT_DIRECTORIES.get(str(variant), "BGnot") else: export = self.state_store.step("export") last_values = export.get("last_run_values", export.get("values", {})) preferred = ( str(last_values.get("annotation_dir", "BGnot")) if isinstance(last_values, dict) else "BGnot" ) return choices, preferred if preferred in choices else choices[0] def _open_persp(self) -> None: evaluation = self.evaluation self._open_desktop_path(evaluation / "Persp" if evaluation else None, "dossier Persp") def _open_selected_copy(self) -> None: self._open_desktop_path(self.copy_paths.get(self.copy_var.get()), "copie") def _open_desktop_path(self, path: Path | None, label: str) -> None: if path is None or not path.exists(): messagebox.showerror("Élément introuvable", f"Le {label} n’existe pas encore.") return try: open_path(path) except (OSError, RuntimeError) as exc: messagebox.showerror("Ouverture impossible", str(exc)) 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( "Nettoyage irréversible" if step.id == "clean" else "Confirmation requise", variant.danger_warning or "Cette commande réinitialise ou supprime des résultats de correction. Continuer ?", icon="warning", ): return self._save_current_form() run_values = self._values() 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), last_run_variant=variant.id, last_run_values=run_values, ) self.active_step_id = step.id workspace = self.state_store.workspace assert workspace is not None log_path = workspace.log_path(step.id) environment = build_runner_environment( os.environ, self.api_key_var.get(), self.proxy_var.get(), self.use_proxy_var.get(), ) 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, *, automatic: bool = False, reason: str | None = None ) -> 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) history: dict[str, object] = { "step": self.current_step.id, "status": status, "manual": not automatic, } if reason: history["reason"] = reason self.state_store.add_history(history) self._populate_tree() detail = f" ({reason})" if reason else "" self.info_var.set( f"{self.current_step.title} : {STATUS_LABELS.get(status, status).lower()}{detail}." ) 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 status = process_status(return_code, interrupted) if step_id == "clean" and status == "success": self._append_console( f"\n[Terminé — code {return_code} — {STATUS_LABELS[status]}]\n" ) self.info_var.set( "Nettoyage terminé : la progression précédente a été supprimée." ) self.active_step_id = None # Do not recreate .copienator-gui.json after clean removed it. self.state_store = StateStore() self._populate_tree() self._update_controls() messagebox.showinfo( "Nettoyage terminé", "Les fichiers intermédiaires et la progression du GUI ont été " "supprimés. Les copies traitées, les fichiers textuels de l’énoncé, " "correction.json, les journaux et les livrables de A Rendre ont " "été conservés.", ) return 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() if status == "success": self.after_idle(lambda completed_id=step_id: self._move_selection_from(completed_id, 1)) 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 self._move_selection_from(self.current_step.id, delta) def _move_selection_from(self, step_id: str, delta: int) -> None: ids = [step.id for step in self.steps] try: index = ids.index(step_id) except ValueError: return target = max(0, min(len(ids) - 1, index + delta)) if target == index: return 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()