Files
Copies/copienator_gui/app.py
T
2026-08-22 17:17:30 +02:00

1011 lines
42 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, 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 = {"<Return>": "Entrée", "<Escape>": "É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 = dict(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("<Return>", 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("<<TreeviewSelect>>", 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("<Return>", 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 larrê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("<<ComboboxSelected>>", 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} nexiste 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 dabord un dossier d’évaluation.")
return
if self.runner.running:
messagebox.showwarning("Traitement en cours", "Interrompez le traitement actuel avant den 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 nont 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 larrê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 larrêt", "Forcer larrê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 à sarrêter et fermer lapplication ?"
):
return
try:
self.runner.force_stop()
except OSError:
pass
self._save_current_form()
self.destroy()