949 lines
40 KiB
Python
949 lines
40 KiB
Python
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 platform_utils 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"
|
||
|
||
|
||
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 plotting_shortcut_lines() -> list[str]:
|
||
try:
|
||
from config 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 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("<<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))
|
||
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 _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 _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(
|
||
"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 = 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)
|
||
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()
|