This commit is contained in:
2026-08-20 11:39:14 +02:00
parent f7067d8585
commit 7c366a9ca4
11 changed files with 1362 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
"""Interface graphique pour piloter le workflow Copienator."""
__all__ = ["__version__"]
__version__ = "0.1.0"
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+660
View File
@@ -0,0 +1,660 @@
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 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 _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 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 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(
"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 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
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 à sarrêter et fermer lapplication ?"
):
return
try:
self.runner.force_stop()
except OSError:
pass
self._save_current_form()
self.destroy()
+113
View File
@@ -0,0 +1,113 @@
from __future__ import annotations
import os
import queue
import signal
import subprocess
import threading
from pathlib import Path
from typing import Any
class ProcessRunner:
"""Run one subprocess and expose thread-safe events for the Tk loop."""
def __init__(self) -> None:
self.events: queue.Queue[tuple[str, Any]] = queue.Queue()
self.process: subprocess.Popen[bytes] | None = None
self._interrupted = False
self._log_file = None
@property
def running(self) -> bool:
return self.process is not None and self.process.poll() is None
def start(
self,
command: list[str],
cwd: Path,
environment: dict[str, str],
log_path: Path,
) -> None:
if self.running:
raise RuntimeError("Un processus est déjà en cours")
log_path.parent.mkdir(parents=True, exist_ok=True)
self._log_file = log_path.open("wb")
self._interrupted = False
kwargs: dict[str, Any] = {}
if os.name == "nt":
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
else:
kwargs["start_new_session"] = True
self.process = subprocess.Popen(
command,
cwd=cwd,
env=environment,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
bufsize=0,
**kwargs,
)
thread = threading.Thread(target=self._read_process, daemon=True)
thread.start()
def _read_process(self) -> None:
process = self.process
if process is None or process.stdout is None:
return
return_code = -1
try:
while True:
chunk = process.stdout.read(4096)
if not chunk:
break
if self._log_file:
self._log_file.write(chunk)
self._log_file.flush()
self.events.put(("output", chunk.decode("utf-8", errors="replace")))
return_code = process.wait()
except (OSError, ValueError) as exc: # pragma: no cover - defensive reporting
self.events.put(("runner_error", str(exc)))
return_code = process.wait()
finally:
if self._log_file:
self._log_file.close()
self._log_file = None
process.stdout.close()
if process.stdin:
process.stdin.close()
self.events.put(("finished", (return_code, self._interrupted)))
def send_input(self, text: str) -> None:
if not self.running or not self.process or not self.process.stdin:
raise RuntimeError("Aucun processus nattend de saisie")
data = (text + "\n").encode("utf-8")
self.process.stdin.write(data)
self.process.stdin.flush()
self.events.put(("input_echo", text + "\n"))
def interrupt(self) -> None:
if not self.running or not self.process:
return
self._interrupted = True
if os.name == "nt":
self.process.send_signal(signal.CTRL_BREAK_EVENT)
else:
os.killpg(self.process.pid, signal.SIGINT)
def force_stop(self) -> None:
if not self.running or not self.process:
return
self._interrupted = True
if os.name == "nt":
subprocess.run(
["taskkill", "/PID", str(self.process.pid), "/T", "/F"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
else:
os.killpg(self.process.pid, signal.SIGKILL)
+67
View File
@@ -0,0 +1,67 @@
from __future__ import annotations
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
STATE_FILENAME = ".copienator-gui.json"
class StateStore:
def __init__(self) -> None:
self.evaluation: Path | None = None
self.data: dict[str, Any] = self._empty_data()
@staticmethod
def _empty_data() -> dict[str, Any]:
return {"version": 1, "steps": {}, "history": []}
def load(self, evaluation: Path) -> None:
self.evaluation = evaluation
path = evaluation / STATE_FILENAME
try:
loaded = json.loads(path.read_text(encoding="utf-8"))
self.data = loaded if isinstance(loaded, dict) else self._empty_data()
except (OSError, json.JSONDecodeError):
self.data = self._empty_data()
self.data.setdefault("version", 1)
self.data.setdefault("steps", {})
self.data.setdefault("history", [])
def save(self) -> None:
if not self.evaluation:
return
path = self.evaluation / STATE_FILENAME
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(
json.dumps(self.data, indent=2, ensure_ascii=False), encoding="utf-8"
)
temporary.replace(path)
def step(self, step_id: str) -> dict[str, Any]:
return self.data["steps"].setdefault(step_id, {})
def update_step(self, step_id: str, **changes: Any) -> None:
entry = self.step(step_id)
entry.update(changes)
entry["updated_at"] = datetime.now(timezone.utc).isoformat()
self.save()
def add_history(self, entry: dict[str, Any]) -> None:
entry = dict(entry)
entry["timestamp"] = datetime.now(timezone.utc).isoformat()
self.data["history"].append(entry)
self.data["history"] = self.data["history"][-200:]
self.save()
def invalidate_after(self, ordered_ids: list[str], current_id: str) -> None:
try:
index = ordered_ids.index(current_id)
except ValueError:
return
for step_id in ordered_ids[index + 1 :]:
entry = self.step(step_id)
if entry.get("status") in {"success", "detected", "skipped"}:
entry["status"] = "stale"
self.save()
+485
View File
@@ -0,0 +1,485 @@
from __future__ import annotations
import os
import shlex
import sys
from collections.abc import Iterable
from dataclasses import dataclass
from pathlib import Path
EVALUATION = "${evaluation}"
@dataclass(frozen=True)
class ArgumentSpec:
name: str
label: str
kind: str = "text" # text, int, bool, choice, path
flag: str | None = None
default: object = ""
choices: tuple[str, ...] = ()
help: str = ""
positional: bool = False
variants: tuple[str, ...] = ()
@dataclass(frozen=True)
class CommandVariant:
id: str
label: str
program: str | None
kind: str = "python" # python, shell, external, manual
fixed_args: tuple[str, ...] = ()
dangerous: bool = False
@dataclass(frozen=True)
class StepDefinition:
id: str
section: str
title: str
description: str
variants: tuple[CommandVariant, ...]
arguments: tuple[ArgumentSpec, ...] = ()
optional: bool = False
personal: bool = False
requires: tuple[str, ...] = ()
artifacts: tuple[str, ...] = ()
@property
def is_manual(self) -> bool:
return all(variant.kind == "manual" for variant in self.variants)
def arg_target(help_text: str = "Dossier d’évaluation ou fichier à traiter") -> ArgumentSpec:
return ArgumentSpec(
"target",
"Cible",
kind="path",
default=EVALUATION,
positional=True,
help=help_text,
)
def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
python = lambda ident, label, script, **kwargs: CommandVariant(
ident, label, script, "python", **kwargs
)
manual = lambda ident, label="Étape manuelle": CommandVariant(
ident, label, None, "manual"
)
steps = [
StepDefinition(
"inputs",
"Préparation",
"Vérifier les fichiers dentrée",
"Le dossier doit contenir enonce.pdf, enonce.tex et correction.tex. "
"Le fichier names doit être présent dans l’évaluation ou à la racine du projet.",
(manual("check"),),
requires=("enonce.pdf", "enonce.tex", "correction.tex"),
),
StepDefinition(
"statement",
"Prétraitement de l’énoncé",
"Analyser l’énoncé",
"Détecte les questions, leurs groupes, le corrigé et les indications de barème.",
(
python("gemini", "Analyse avec Gemini", "gemini_for_enonce.py"),
python("personal", "Alternative enonce_info.py", "enonce_info.py"),
),
arguments=(
arg_target("Dossier de l’évaluation"),
ArgumentSpec(
"restart",
"Ignorer le cache (--restart)",
kind="bool",
flag="--restart",
variants=("gemini",),
),
),
requires=("enonce.pdf", "enonce.tex", "correction.tex"),
artifacts=("labels", "Text", "Sol", "Persp"),
),
StepDefinition(
"review_persp",
"Prétraitement de l’énoncé",
"Relire les barèmes dans Persp",
"Étape manuelle facultative : vérifier et modifier les instructions de correction.",
(manual("review"),),
optional=True,
requires=("Persp",),
),
StepDefinition(
"rotate",
"Prétraitement des copies",
"Retourner toutes les copies",
"Rotation facultative de 180° lorsque les scans sont à lenvers.",
(CommandVariant("rotate", "Rotation", "rotate_all.sh", "shell"),),
arguments=(arg_target("Dossier de l’évaluation"),),
optional=True,
),
StepDefinition(
"rename",
"Prétraitement des copies",
"Renommer les PDF en CopieXX.pdf",
"Renomme les PDF du dossier d’évaluation dans leur ordre courant.",
(CommandVariant("rename", "Renommage", "rename_to_copie.sh", "shell"),),
arguments=(arg_target("Dossier de l’évaluation"),),
),
StepDefinition(
"page_splitter",
"Prétraitement des copies",
"Séparer et réordonner les pages",
"Ouvre loutil interactif de découpage A3 vers A4. La cible peut être un dossier ou un PDF.",
(python("default", "Séparation des pages", "page_splitter.py"),),
arguments=(arg_target(),),
artifacts=("Copies", "Copies Originales"),
),
StepDefinition(
"cutleft",
"Prétraitement des copies",
"Découper la marge des labels",
"Produit les images de la partie gauche des copies. Une copie précise peut être ciblée.",
(python("default", "Découpe", "cutleft.py"),),
arguments=(
arg_target(),
ArgumentSpec("fullpage", "Toujours utiliser la page entière", "bool", "--fullpage"),
),
requires=("Copies",),
artifacts=("Cutleft",),
),
StepDefinition(
"labels",
"Labels et regroupement",
"Détecter les labels avec Gemini",
"Identifie les labels dans les images produites par la découpe gauche.",
(python("default", "Détection des labels", "gemini_for_labels.py"),),
arguments=(
arg_target(),
ArgumentSpec("overwrite", "Régénérer les résultats", "bool", "--overwrite"),
),
requires=("labels", "Copies", "Cutleft"),
artifacts=("Copies/*.json",),
),
StepDefinition(
"plotting",
"Labels et regroupement",
"Vérifier visuellement les labels",
"Ouvre la fenêtre de vérification. La cible peut être toute l’évaluation ou une copie.",
(python("default", "Vérification", "plotting.py"),),
arguments=(arg_target(),),
requires=("labels", "Cutleft"),
artifacts=("Copies/Copie*.json",),
),
StepDefinition(
"splitting",
"Labels et regroupement",
"Découper les réponses par question",
"Découpe les copies à partir des coordonnées de labels vérifiées.",
(python("default", "Découpage", "splitting_int.py"),),
arguments=(arg_target(),),
requires=("Copies",),
artifacts=("Copies/Copie*/*",),
),
StepDefinition(
"grouping",
"Labels et regroupement",
"Regrouper les réponses",
"Regroupe les réponses portant le même label pour préparer les requêtes.",
(python("default", "Regroupement", "grouping.py"),),
arguments=(arg_target("Dossier de l’évaluation"),),
requires=("Copies",),
artifacts=("Par label",),
),
StepDefinition(
"correction",
"Correction",
"Lancer ou intégrer la correction",
"Choisir une correction immédiate, batch, hybride, une recorrection, ou lintégration dun batch.",
(
python("live", "Correction immédiate", "correction.py"),
python("batch", "Préparer toutes les requêtes batch", "correction.py", fixed_args=("--batch",)),
python("hybrid", "Batch à partir dun label", "correction.py"),
python("refaire", "Recorrection depuis refaire.json", "correction.py", fixed_args=("--refaire",)),
python(
"integrate",
"Intégrer les résultats batch",
"correction.py",
fixed_args=("--deal-with-batched",),
),
python("reset", "Réinitialiser les corrections", "correction.py", fixed_args=("--reset",), dangerous=True),
),
arguments=(
arg_target("Évaluation ou image Group_X.jpg"),
ArgumentSpec("overwrite", "Écraser les corrections existantes", "bool", "--overwrite", variants=("live",)),
ArgumentSpec("limit", "Limite dappels Pro", "int", "--limit", variants=("live",)),
ArgumentSpec("batch_from", "Premier label envoyé en batch", "text", "--batch-from", variants=("hybrid",)),
),
requires=("Par label", "Persp", "labels"),
artifacts=("correction.json", "batch_requests_*.jsonl"),
),
StepDefinition(
"submit_batches",
"Correction",
"Envoyer les batchs",
"Envoie à Gemini les fichiers JSONL produits par le mode batch.",
(python("default", "Envoi", "submit_batches.py"),),
arguments=(arg_target("Dossier de l’évaluation"),),
optional=True,
artifacts=("batch_jobs.json",),
),
StepDefinition(
"batch_status",
"Correction",
"Consulter l’état des batchs",
"Affiche les jobs Gemini en cours. Lidentifiant de téléchargement est facultatif.",
(python("default", "État des batchs", "batch_status.py"),),
arguments=(ArgumentSpec("download", "Télécharger le job", "text", "--download"),),
optional=True,
),
StepDefinition(
"fetch_batches",
"Correction",
"Récupérer les résultats batch",
"Télécharge et rassemble les réponses des jobs terminés.",
(python("default", "Récupération", "fetch_batched_results.py"),),
arguments=(arg_target("Dossier de l’évaluation"),),
optional=True,
),
StepDefinition(
"post_correction",
"Correction",
"Nettoyer la correction",
"Corrige certains problèmes dencodage et prépare le texte pour LaTeX.",
(python("default", "Post-correction", "post-correction.py"),),
arguments=(arg_target("Dossier de l’évaluation"),),
requires=("correction.json",),
),
StepDefinition(
"manual_resolution",
"Correction",
"Résoudre les conflits manuels",
"Étape conditionnelle, uniquement si manual_resolutions.txt contient des conflits à traiter.",
(python("default", "Résolution", "resolve_manual.py"),),
arguments=(arg_target("Dossier de l’évaluation"),),
optional=True,
requires=("manual_resolutions.txt", "correction.json"),
),
StepDefinition(
"annotation",
"Génération des annotations",
"Générer les copies annotées",
"Les trois modes sont exclusifs pour un parcours donné.",
(
python("simple", "Annotations simples (Anot)", "annotating.py"),
python("checks", "Annotations avec cases (Bnot)", "annotating_with_checks.py"),
python("grouped", "Annotations groupées (BGnot)", "annotating_by_label.py"),
),
arguments=(
arg_target("Dossier de l’évaluation"),
ArgumentSpec("overwrite", "Écraser les sorties", "bool", "--overwrite"),
ArgumentSpec("refaire", "Mode refaire", "bool", "--refaire", variants=("checks",)),
),
requires=("correction.json",),
artifacts=("Anot", "Bnot", "BGnot"),
),
StepDefinition(
"export",
"Génération des annotations",
"Exporter vers la tablette",
"Exporte les groupes vers le dossier EXPORT_DIR défini dans config.py.",
(python("default", "Export", "export.py"),),
arguments=(
arg_target("Dossier de l’évaluation"),
ArgumentSpec("refaire", "Mode refaire", "bool", "--refaire"),
),
optional=True,
),
StepDefinition(
"tablet",
"Correction manuscrite",
"Annoter les PDF sur la tablette",
"Étape manuelle : enregistrer chaque résultat sous le nom Concat_annotated.pdf.",
(manual("tablet"),),
),
StepDefinition(
"import",
"Correction manuscrite",
"Importer les annotations manuscrites",
"Copie les PDF présents dans IMPORT_DIR vers l’évaluation.",
(python("default", "Import", "import.py"),),
arguments=(
arg_target("Dossier de l’évaluation"),
ArgumentSpec("refaire", "Mode refaire", "bool", "--refaire"),
),
),
StepDefinition(
"read_annotations",
"Finalisation",
"Lire les annotations manuscrites",
"Le mode doit correspondre au mode choisi lors de la génération des annotations.",
(
python("standard", "Lecture Bnot", "reading_annotations.py"),
python("grouped", "Lecture BGnot", "reading_grouped_annotations.py"),
),
arguments=(
arg_target("Dossier de l’évaluation"),
ArgumentSpec("update_score", "Réappliquer les score.json", "bool", "--update-score"),
ArgumentSpec("refaire", "Mode refaire", "bool", "--refaire", variants=("grouped",)),
),
),
StepDefinition(
"giving_names",
"Finalisation",
"Attribuer les noms et préparer A Rendre",
"Crée le dossier A Rendre à partir du dossier dannotations choisi.",
(python("default", "Attribution des noms", "giving_names.py"),),
arguments=(
arg_target("Dossier de l’évaluation"),
ArgumentSpec(
"annotation_dir",
"Dossier dannotations",
"choice",
default="BGnot",
choices=("BGnot", "Bnot", "Anot"),
positional=True,
),
),
artifacts=("A Rendre",),
),
StepDefinition(
"personal_create",
"Étapes personnelles",
"Créer linterro dans gestion_classe",
"Exécute gestion_classe ne.",
(CommandVariant("default", "gestion_classe ne", "gestion_classe", "external", ("ne",)),),
personal=True,
),
StepDefinition(
"personal_scale",
"Étapes personnelles",
"Renseigner le barème",
"Exécute gestion_classe we.",
(CommandVariant("default", "gestion_classe we", "gestion_classe", "external", ("we",)),),
personal=True,
),
StepDefinition(
"update_ods",
"Étapes personnelles",
"Mettre à jour le fichier ODS",
"Transfère les scores, avec la possibilité de n’écrire que leur somme.",
(python("default", "Mise à jour ODS", "update_ods.py"),),
arguments=(
arg_target("Dossier de l’évaluation"),
ArgumentSpec("sum", "Écrire seulement la somme", "bool", "--sum"),
),
personal=True,
),
StepDefinition(
"personal_read",
"Étapes personnelles",
"Relire avec gestion_classe",
"Exécute gestion_classe re.",
(CommandVariant("default", "gestion_classe re", "gestion_classe", "external", ("re",)),),
personal=True,
),
StepDefinition(
"personal_sent",
"Étapes personnelles",
"Marquer comme envoyé",
"Exécute gestion_classe wsent.",
(CommandVariant("default", "gestion_classe wsent", "gestion_classe", "external", ("wsent",)),),
personal=True,
),
StepDefinition(
"final_score",
"Étapes personnelles",
"Ajouter le score final",
"Génère les fichiers de diffusion avec le score final.",
(python("default", "Score final", "add_final_score.py"),),
arguments=(arg_target("Dossier de l’évaluation"),),
personal=True,
),
StepDefinition(
"personal_deploy",
"Étapes personnelles",
"Déployer et publier les copies",
"Étape manuelle : déployer miqmacs-copies-assets puis mettre à jour les copies depuis ladministration.",
(manual("deploy"),),
personal=True,
),
StepDefinition(
"personal_print",
"Étapes personnelles",
"Imprimer une copie",
"Étape manuelle via le lecteur PDF.",
(manual("print"),),
personal=True,
optional=True,
),
]
return [step for step in steps if show_personal_steps or not step.personal]
def value_for_default(value: object, evaluation_arg: str) -> object:
return evaluation_arg if value == EVALUATION else value
def build_command(
repository: Path,
step: StepDefinition,
variant: CommandVariant,
values: dict[str, object],
evaluation_arg: str,
extra_arguments: str = "",
) -> list[str]:
if variant.kind == "manual" or not variant.program:
return []
program_path = repository / variant.program
if variant.kind == "python":
command = [sys.executable, "-u", str(program_path)]
elif variant.kind == "shell":
command = [str(program_path)]
else:
command = [variant.program]
positionals: list[str] = []
options: list[str] = []
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))
if spec.kind == "bool":
if bool(value) and spec.flag:
options.append(spec.flag)
continue
if value is None or str(value).strip() == "":
continue
rendered = str(value)
if spec.positional:
positionals.append(rendered)
elif spec.flag:
options.extend((spec.flag, rendered))
command.extend(positionals)
command.extend(variant.fixed_args)
command.extend(options)
if extra_arguments.strip():
command.extend(shlex.split(extra_arguments, posix=os.name != "nt"))
return command
def command_display(command: Iterable[str]) -> str:
if os.name == "nt":
return " ".join(f'"{part}"' if " " in part else part for part in command)
return shlex.join(command)
def evaluation_argument(repository: Path, evaluation: Path) -> str:
try:
return str(evaluation.resolve().relative_to(repository.resolve()))
except ValueError:
return str(evaluation.resolve())
+32
View File
@@ -0,0 +1,32 @@
from __future__ import annotations
import argparse
from pathlib import Path
from copienator_gui.app import CopienatorApp
def personal_steps_enabled() -> bool:
try:
from config import SHOW_PERSONAL_STEPS
except (ImportError, AttributeError):
try:
from default_config import SHOW_PERSONAL_STEPS
except (ImportError, AttributeError):
return False
return bool(SHOW_PERSONAL_STEPS)
def main() -> None:
parser = argparse.ArgumentParser(description="Interface graphique du workflow Copienator")
parser.add_argument("evaluation", nargs="?", type=Path, help="Dossier d’évaluation à ouvrir")
args = parser.parse_args()
repository = Path(__file__).resolve().parent
evaluation = args.evaluation.resolve() if args.evaluation else None
app = CopienatorApp(repository, personal_steps_enabled(), evaluation)
app.mainloop()
if __name__ == "__main__":
main()