661 lines
29 KiB
Python
661 lines
29 KiB
Python
from __future__ import annotations
|
||
|
||
import os
|
||
import queue
|
||
import re
|
||
import tkinter as tk
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from tkinter import filedialog, messagebox, ttk
|
||
from typing import Any
|
||
|
||
from .runner import ProcessRunner
|
||
from .state import StateStore
|
||
from .workflow import (
|
||
CommandVariant,
|
||
StepDefinition,
|
||
build_command,
|
||
build_workflow,
|
||
command_display,
|
||
evaluation_argument,
|
||
value_for_default,
|
||
)
|
||
|
||
STATUS_LABELS = {
|
||
"ready": "Prête",
|
||
"running": "En cours",
|
||
"success": "Réussie",
|
||
"failed": "Échouée",
|
||
"interrupted": "Interrompue",
|
||
"skipped": "Ignorée",
|
||
"stale": "À revalider",
|
||
"detected": "Détectée",
|
||
}
|
||
|
||
|
||
class CopienatorApp(tk.Tk):
|
||
def __init__(
|
||
self,
|
||
repository: Path,
|
||
show_personal_steps: bool,
|
||
initial_evaluation: Path | None = None,
|
||
) -> None:
|
||
super().__init__()
|
||
self.repository = repository.resolve()
|
||
self.steps = build_workflow(show_personal_steps)
|
||
self.step_by_id = {step.id: step for step in self.steps}
|
||
self.state_store = StateStore()
|
||
self.runner = ProcessRunner()
|
||
self.active_step_id: str | None = None
|
||
self.current_step: StepDefinition | None = None
|
||
self.arg_vars: dict[str, tk.Variable] = {}
|
||
self._rendering = False
|
||
|
||
self.title("Copienator — assistant de correction")
|
||
self.geometry("1180x820")
|
||
self.minsize(900, 640)
|
||
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
||
|
||
self.evaluation_var = tk.StringVar()
|
||
self.api_key_var = tk.StringVar(value=os.environ.get("GEMINI_API_KEY", ""))
|
||
self.proxy_var = tk.StringVar(value=os.environ.get("HTTPS_PROXY", ""))
|
||
self.variant_var = tk.StringVar()
|
||
self.extra_var = tk.StringVar()
|
||
self.command_var = tk.StringVar(value="Sélectionnez une étape.")
|
||
self.info_var = tk.StringVar(value="Choisissez un dossier d’évaluation.")
|
||
|
||
self._build_ui(show_personal_steps)
|
||
self.extra_var.trace_add("write", lambda *_args: self._update_command_preview())
|
||
self.after(60, self._poll_runner)
|
||
|
||
if initial_evaluation and initial_evaluation.is_dir():
|
||
self.evaluation_var.set(str(initial_evaluation.resolve()))
|
||
self._load_evaluation()
|
||
else:
|
||
self._populate_tree()
|
||
|
||
@property
|
||
def evaluation(self) -> Path | None:
|
||
value = self.evaluation_var.get().strip()
|
||
return Path(value).expanduser().resolve() if value else None
|
||
|
||
def _build_ui(self, show_personal_steps: bool) -> None:
|
||
self.columnconfigure(0, weight=1)
|
||
self.rowconfigure(1, weight=3)
|
||
self.rowconfigure(2, weight=2)
|
||
|
||
top = ttk.Frame(self, padding=(10, 8))
|
||
top.grid(row=0, column=0, sticky="ew")
|
||
top.columnconfigure(1, weight=1)
|
||
ttk.Label(top, text="Évaluation").grid(row=0, column=0, sticky="w", padx=(0, 8))
|
||
path_entry = ttk.Entry(top, textvariable=self.evaluation_var)
|
||
path_entry.grid(row=0, column=1, sticky="ew")
|
||
path_entry.bind("<Return>", lambda _event: self._load_evaluation())
|
||
ttk.Button(top, text="Parcourir…", command=self._browse_evaluation).grid(row=0, column=2, padx=6)
|
||
ttk.Button(top, text="Charger", command=self._load_evaluation).grid(row=0, column=3)
|
||
|
||
ttk.Label(top, text="Clé Gemini").grid(row=1, column=0, sticky="w", padx=(0, 8), pady=(7, 0))
|
||
ttk.Entry(top, textvariable=self.api_key_var, show="•", width=32).grid(
|
||
row=1, column=1, sticky="w", pady=(7, 0)
|
||
)
|
||
environment = ttk.Frame(top)
|
||
environment.grid(row=1, column=2, columnspan=2, sticky="e", pady=(7, 0))
|
||
ttk.Label(environment, text="HTTPS_PROXY").pack(side="left", padx=(0, 6))
|
||
ttk.Entry(environment, textvariable=self.proxy_var, width=28).pack(side="left")
|
||
profile = "standard + personnel" if show_personal_steps else "standard"
|
||
ttk.Label(environment, text=f"Profil : {profile}").pack(side="left", padx=(12, 0))
|
||
|
||
main_pane = ttk.Panedwindow(self, orient="horizontal")
|
||
main_pane.grid(row=1, column=0, sticky="nsew", padx=10)
|
||
|
||
navigation = ttk.Frame(main_pane, padding=(0, 5, 6, 5))
|
||
self.tree = ttk.Treeview(navigation, columns=("status",), show="tree headings", selectmode="browse")
|
||
self.tree.heading("#0", text="Étape")
|
||
self.tree.heading("status", text="État")
|
||
self.tree.column("#0", minwidth=230, width=280)
|
||
self.tree.column("status", minwidth=90, width=105, anchor="center")
|
||
tree_scroll = ttk.Scrollbar(navigation, orient="vertical", command=self.tree.yview)
|
||
self.tree.configure(yscrollcommand=tree_scroll.set)
|
||
self.tree.pack(side="left", fill="both", expand=True)
|
||
tree_scroll.pack(side="right", fill="y")
|
||
self.tree.bind("<<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 _browse_evaluation(self) -> None:
|
||
selected = filedialog.askdirectory(initialdir=self.evaluation_var.get() or self.repository)
|
||
if selected:
|
||
self.evaluation_var.set(selected)
|
||
self._load_evaluation()
|
||
|
||
def _load_evaluation(self) -> None:
|
||
evaluation = self.evaluation
|
||
if not evaluation or not evaluation.is_dir():
|
||
messagebox.showerror("Dossier invalide", "Choisissez un dossier d’évaluation existant.")
|
||
return
|
||
self._save_current_form()
|
||
self.state_store.load(evaluation)
|
||
for step in self.steps:
|
||
if self.state_store.step(step.id).get("status") == "running":
|
||
self.state_store.update_step(step.id, status="interrupted")
|
||
self._populate_tree()
|
||
missing = [name for name in ("enonce.pdf", "enonce.tex", "correction.tex") if not (evaluation / name).exists()]
|
||
names_found = (evaluation / "names").exists() or (self.repository / "names").exists()
|
||
details = []
|
||
if missing:
|
||
details.append("manquants : " + ", ".join(missing))
|
||
if not names_found:
|
||
details.append("fichier names introuvable")
|
||
if not self.api_key_var.get():
|
||
details.append("clé Gemini non renseignée")
|
||
self.info_var.set(
|
||
f"Évaluation chargée : {evaluation}"
|
||
+ (" — " + " ; ".join(details) if details else " — prérequis principaux détectés")
|
||
)
|
||
first = self.steps[0].id if self.steps else None
|
||
if first:
|
||
self.tree.selection_set(first)
|
||
self.tree.see(first)
|
||
|
||
def _populate_tree(self) -> None:
|
||
selected = self.current_step.id if self.current_step else None
|
||
self.tree.delete(*self.tree.get_children())
|
||
section_items: dict[str, str] = {}
|
||
for step in self.steps:
|
||
if step.section not in section_items:
|
||
section_id = f"section:{len(section_items)}"
|
||
section_items[step.section] = section_id
|
||
self.tree.insert("", "end", iid=section_id, text=step.section, values=("",), open=True)
|
||
status = self._step_status(step)
|
||
suffix = " (facultative)" if step.optional else ""
|
||
self.tree.insert(
|
||
section_items[step.section],
|
||
"end",
|
||
iid=step.id,
|
||
text=step.title + suffix,
|
||
values=(STATUS_LABELS.get(status, status),),
|
||
)
|
||
if selected and self.tree.exists(selected):
|
||
self.tree.selection_set(selected)
|
||
|
||
def _step_status(self, step: StepDefinition) -> str:
|
||
if self.state_store.evaluation:
|
||
saved = self.state_store.step(step.id).get("status")
|
||
if saved:
|
||
return str(saved)
|
||
if self._artifacts_exist(step):
|
||
return "detected"
|
||
if step.id == "inputs" and not self._missing_requirements(step):
|
||
return "detected"
|
||
return "ready"
|
||
|
||
def _artifacts_exist(self, step: StepDefinition) -> bool:
|
||
evaluation = self.evaluation
|
||
if not evaluation or not step.artifacts:
|
||
return False
|
||
for pattern in step.artifacts:
|
||
if any(char in pattern for char in "*?["):
|
||
if next(evaluation.glob(pattern), None) is not None:
|
||
return True
|
||
elif (evaluation / pattern).exists():
|
||
return True
|
||
return False
|
||
|
||
def _missing_requirements(self, step: StepDefinition) -> list[str]:
|
||
evaluation = self.evaluation
|
||
if not evaluation:
|
||
return list(step.requires)
|
||
missing = []
|
||
for pattern in step.requires:
|
||
if any(char in pattern for char in "*?["):
|
||
exists = next(evaluation.glob(pattern), None) is not None
|
||
else:
|
||
exists = (evaluation / pattern).exists()
|
||
if not exists:
|
||
missing.append(pattern)
|
||
return missing
|
||
|
||
def _on_tree_select(self, _event: tk.Event[Any] | None = None) -> None:
|
||
selection = self.tree.selection()
|
||
if not selection or selection[0].startswith("section:"):
|
||
return
|
||
step = self.step_by_id.get(selection[0])
|
||
if not step:
|
||
return
|
||
if self.current_step and self.current_step.id != step.id:
|
||
self._save_current_form()
|
||
self.current_step = step
|
||
self._render_step()
|
||
|
||
def _render_step(self) -> None:
|
||
step = self.current_step
|
||
if not step:
|
||
return
|
||
self._rendering = True
|
||
for child in self.form.winfo_children():
|
||
child.destroy()
|
||
self.arg_vars.clear()
|
||
entry = self.state_store.step(step.id) if self.state_store.evaluation else {}
|
||
saved_variant = entry.get("variant", step.variants[0].id)
|
||
if saved_variant not in {variant.id for variant in step.variants}:
|
||
saved_variant = step.variants[0].id
|
||
self.variant_var.set(saved_variant)
|
||
self.extra_var.set(str(entry.get("extra", "")))
|
||
self.title_label.configure(text=step.title)
|
||
missing = self._missing_requirements(step)
|
||
description = step.description
|
||
if missing:
|
||
description += "\nPrérequis non détectés : " + ", ".join(missing)
|
||
self.description_label.configure(text=description)
|
||
|
||
row = 0
|
||
if len(step.variants) > 1:
|
||
ttk.Label(self.form, text="Mode").grid(row=row, column=0, sticky="w", pady=4, padx=(0, 8))
|
||
labels = [variant.label for variant in step.variants]
|
||
combo = ttk.Combobox(self.form, values=labels, state="readonly")
|
||
variant_index = [variant.id for variant in step.variants].index(saved_variant)
|
||
combo.current(variant_index)
|
||
combo.grid(row=row, column=1, sticky="ew", pady=4)
|
||
combo.bind("<<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 _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 not self._validate_arguments():
|
||
return
|
||
missing = self._missing_requirements(step)
|
||
if missing and not messagebox.askyesno(
|
||
"Prérequis non détectés",
|
||
"Les éléments suivants n’ont pas été trouvés :\n\n"
|
||
+ "\n".join(missing)
|
||
+ "\n\nLancer tout de même la commande ?",
|
||
):
|
||
return
|
||
variant = self._current_variant()
|
||
command = self._make_command()
|
||
if variant.dangerous and not messagebox.askyesno(
|
||
"Confirmation requise",
|
||
"Cette commande réinitialise ou supprime des résultats de correction. Continuer ?",
|
||
icon="warning",
|
||
):
|
||
return
|
||
|
||
self._save_current_form()
|
||
ordered_ids = [item.id for item in self.steps]
|
||
self.state_store.invalidate_after(ordered_ids, step.id)
|
||
self.state_store.update_step(step.id, status="running", command=command_display(command))
|
||
self.active_step_id = step.id
|
||
timestamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S")
|
||
safe_step = re.sub(r"[^A-Za-z0-9_.-]+", "_", step.id)
|
||
log_path = evaluation / ".copienator" / "logs" / f"{timestamp}-{safe_step}.log"
|
||
|
||
environment = os.environ.copy()
|
||
environment["PYTHONUNBUFFERED"] = "1"
|
||
environment["PYTHONIOENCODING"] = "utf-8"
|
||
if self.api_key_var.get().strip():
|
||
environment["GEMINI_API_KEY"] = self.api_key_var.get().strip()
|
||
if self.proxy_var.get().strip():
|
||
environment["HTTPS_PROXY"] = self.proxy_var.get().strip()
|
||
|
||
self._append_console(f"\n$ {command_display(command)}\n")
|
||
try:
|
||
self.runner.start(command, self.repository, environment, log_path)
|
||
except (OSError, RuntimeError) as exc:
|
||
self.state_store.update_step(step.id, status="failed")
|
||
self.state_store.add_history(
|
||
{"step": step.id, "command": command_display(command), "status": "failed", "error": str(exc)}
|
||
)
|
||
self.active_step_id = None
|
||
self._append_console(f"Impossible de lancer la commande : {exc}\n")
|
||
messagebox.showerror("Échec du lancement", str(exc))
|
||
self._populate_tree()
|
||
self._update_controls()
|
||
|
||
def _mark_step(self, status: str) -> None:
|
||
if not self.current_step or not self.state_store.evaluation:
|
||
return
|
||
self._save_current_form()
|
||
self.state_store.invalidate_after([item.id for item in self.steps], self.current_step.id)
|
||
self.state_store.update_step(self.current_step.id, status=status)
|
||
self.state_store.add_history({"step": self.current_step.id, "status": status, "manual": True})
|
||
self._populate_tree()
|
||
self.info_var.set(f"{self.current_step.title} : {STATUS_LABELS.get(status, status).lower()}.")
|
||
|
||
def _skip_step(self) -> None:
|
||
if self.current_step and self.current_step.optional:
|
||
self._mark_step("skipped")
|
||
|
||
def _poll_runner(self) -> None:
|
||
while True:
|
||
try:
|
||
event, payload = self.runner.events.get_nowait()
|
||
except queue.Empty:
|
||
break
|
||
if event == "output":
|
||
self._append_console(str(payload))
|
||
elif event == "input_echo":
|
||
self._append_console(f"> {payload}")
|
||
elif event == "runner_error":
|
||
self._append_console(f"\nErreur du lanceur : {payload}\n")
|
||
elif event == "finished":
|
||
return_code, interrupted = payload
|
||
self._finish_process(int(return_code), bool(interrupted))
|
||
self.after(60, self._poll_runner)
|
||
|
||
def _finish_process(self, return_code: int, interrupted: bool) -> None:
|
||
step_id = self.active_step_id
|
||
if not step_id:
|
||
return
|
||
if interrupted:
|
||
status = "interrupted"
|
||
else:
|
||
status = "success" if return_code == 0 else "failed"
|
||
self.state_store.update_step(step_id, status=status, return_code=return_code)
|
||
self.state_store.add_history(
|
||
{
|
||
"step": step_id,
|
||
"command": self.state_store.step(step_id).get("command", ""),
|
||
"status": status,
|
||
"return_code": return_code,
|
||
}
|
||
)
|
||
title = self.step_by_id[step_id].title
|
||
self._append_console(f"\n[Terminé — code {return_code} — {STATUS_LABELS[status]}]\n")
|
||
self.info_var.set(f"{title} : {STATUS_LABELS[status].lower()} (code {return_code}).")
|
||
self.active_step_id = None
|
||
self._populate_tree()
|
||
self._update_controls()
|
||
|
||
def _send_input(self) -> None:
|
||
text = self.stdin_var.get()
|
||
try:
|
||
self.runner.send_input(text)
|
||
except RuntimeError as exc:
|
||
messagebox.showinfo("Aucune saisie attendue", str(exc))
|
||
return
|
||
self.stdin_var.set("")
|
||
|
||
def _interrupt(self) -> None:
|
||
if self.runner.running and messagebox.askyesno(
|
||
"Interrompre", "Envoyer une interruption au script en cours ?"
|
||
):
|
||
try:
|
||
self.runner.interrupt()
|
||
self.info_var.set("Interruption demandée. Utilisez « Forcer l’arrêt » si le script ne répond pas.")
|
||
except OSError as exc:
|
||
messagebox.showerror("Interruption impossible", str(exc))
|
||
|
||
def _force_stop(self) -> None:
|
||
if self.runner.running and messagebox.askyesno(
|
||
"Forcer l’arrêt", "Forcer l’arrêt du script et de ses processus enfants ?", icon="warning"
|
||
):
|
||
try:
|
||
self.runner.force_stop()
|
||
except OSError as exc:
|
||
messagebox.showerror("Arrêt impossible", str(exc))
|
||
|
||
def _append_console(self, text: str) -> None:
|
||
self.console.configure(state="normal")
|
||
self.console.insert("end", text)
|
||
self.console.see("end")
|
||
self.console.configure(state="disabled")
|
||
|
||
def _clear_console(self) -> None:
|
||
self.console.configure(state="normal")
|
||
self.console.delete("1.0", "end")
|
||
self.console.configure(state="disabled")
|
||
|
||
def _update_controls(self) -> None:
|
||
running = self.runner.running
|
||
self.run_button.configure(state="disabled" if running or not self.current_step else "normal")
|
||
self.interrupt_button.configure(state="normal" if running else "disabled")
|
||
self.force_button.configure(state="normal" if running else "disabled")
|
||
self.send_button.configure(state="normal" if running else "disabled")
|
||
self.stdin_entry.configure(state="normal" if running else "disabled")
|
||
|
||
def _move_selection(self, delta: int) -> None:
|
||
if not self.current_step:
|
||
return
|
||
ids = [step.id for step in self.steps]
|
||
try:
|
||
index = ids.index(self.current_step.id)
|
||
except ValueError:
|
||
return
|
||
target = max(0, min(len(ids) - 1, index + delta))
|
||
self.tree.selection_set(ids[target])
|
||
self.tree.see(ids[target])
|
||
|
||
def _on_close(self) -> None:
|
||
if self.runner.running:
|
||
if not messagebox.askyesno(
|
||
"Traitement en cours", "Un script est encore actif. Le forcer à s’arrêter et fermer l’application ?"
|
||
):
|
||
return
|
||
try:
|
||
self.runner.force_stop()
|
||
except OSError:
|
||
pass
|
||
self._save_current_form()
|
||
self.destroy()
|