Compare commits

...
7 Commits
Author SHA1 Message Date
sebastien 7bc588e9a1 Manual score modification step 2026-10-06 11:56:04 +02:00
sebastien 261a50a7d2 smarter groups 2026-10-06 11:44:37 +02:00
sebastien ec1645a952 not much 2026-10-06 11:27:53 +02:00
sebastien 22c04f6e15 Clear import directory, possibly 2026-10-06 11:26:03 +02:00
sebastien 52a8589261 Don't mark batched steps «À revalider». 2026-10-06 11:16:39 +02:00
sebastien c155e3eebc Manual conflict : utilities 2026-10-06 11:11:03 +02:00
sebastien f43b713590 Configuration option for number of concurrent requests 2026-10-06 11:00:16 +02:00
19 changed files with 799 additions and 57 deletions

No files matched your search

+20
View File
@@ -11,6 +11,10 @@ def _path(root):
def origins_for(root, copy_id, label, pdf):
entry = read_json(_path(root), default={}).get(str(copy_id), {}).get(label, {})
return _valid_origins(entry, pdf)
def _valid_origins(entry, pdf):
if not entry or not Path(pdf).is_file():
return []
if entry.get("sha256") != sha256(Path(pdf).read_bytes()).hexdigest():
@@ -18,6 +22,22 @@ def origins_for(root, copy_id, label, pdf):
return entry.get("labels", [])
def populated_labels(root, copy_id, source_label):
"""Find current answer PDFs populated from this source's recorded content."""
entries = read_json(_path(root), default={}).get(str(copy_id), {})
copies = Path(root) / "Copies" / f"Copie{copy_id}"
populated = []
for label, entry in entries.items():
if label == source_label or source_label not in entry.get("labels", []):
continue
pdf = copies / f"{label}.pdf"
if not pdf.is_file():
pdf = copies / f"{label}_new.pdf"
if source_label in _valid_origins(entry, pdf):
populated.append(label)
return sorted(populated)
def save_origins(root, copy_id, label, pdf, origins):
entry = {"labels": list(dict.fromkeys(origins)),
"sha256": sha256(Path(pdf).read_bytes()).hexdigest()}
+9 -2
View File
@@ -30,6 +30,7 @@ from copienator import (
workspace_from_args,
)
from copienator.annotation_data import load_annotation_data
from copienator.commands.import_annotations import clear_import_directory
from copienator.answer_info import build_answer_info
from copienator.filesystem import staged_directory
from copienator.feedback_boxes import valid_feedback_box
@@ -525,6 +526,8 @@ def process_correction(root_dir, data, all_labels, overwrite=False):
def build_parser() -> argparse.ArgumentParser:
parser = evaluation_parser("Generate simple annotated copies.")
parser.add_argument("--clear-import", action="store_true",
help="Empty IMPORT_DIR after successful annotation generation")
parser.add_argument(
"--overwrite",
action="store_true",
@@ -533,7 +536,8 @@ def build_parser() -> argparse.ArgumentParser:
return parser
def run(workspace: EvaluationWorkspace, *, overwrite: bool = False) -> ExitCode:
def run(workspace: EvaluationWorkspace, *, overwrite: bool = False,
clear_import: bool = False) -> ExitCode:
workspace.require_files("labels", "correction.json")
workspace.require_directories("Copies", "Par label")
labels = utils.read_all_labels(workspace.root)
@@ -551,6 +555,8 @@ def run(workspace: EvaluationWorkspace, *, overwrite: bool = False) -> ExitCode:
)
if loaded.warnings and result == ExitCode.SUCCESS:
return ExitCode.PARTIAL
if clear_import and result == ExitCode.SUCCESS:
clear_import_directory(workspace)
return result
@@ -559,7 +565,8 @@ def main(argv: Sequence[str] | None = None) -> int:
return execute(
parser,
argv,
lambda args: run(workspace_from_args(args), overwrite=args.overwrite),
lambda args: run(workspace_from_args(args), overwrite=args.overwrite,
clear_import=args.clear_import),
)
+22 -19
View File
@@ -24,6 +24,7 @@ from copienator import (
)
from copienator.annotation_data import load_annotation_data
from copienator.commands import annotating, annotating_with_checks
from copienator.commands.import_annotations import clear_import_directory
from copienator.filesystem import staged_directory
from copienator.utils import natural_key
@@ -274,15 +275,7 @@ def _existing_group_state(
def split_batches(rendered):
batches = []
current = []
current_height = 0
def flush() -> None:
nonlocal current, current_height
if current:
batches.append(current)
current = []
current_height = 0
heights = []
index = 0
while index < len(rendered):
@@ -301,15 +294,17 @@ def split_batches(rendered):
f"maximum group height is {MAX_HEIGHT_PX} px."
)
# Consider the student's complete block before adding its first image.
# Otherwise later images from that same student could overflow the batch.
if current and current_height + student_height > MAX_HEIGHT_PX:
flush()
current.extend(student_items)
current_height += student_height
# Reuse space in earlier groups, keeping each student's block intact.
for batch_index, height in enumerate(heights):
if height + student_height <= MAX_HEIGHT_PX:
batches[batch_index].extend(student_items)
heights[batch_index] += student_height
break
else:
batches.append(list(student_items))
heights.append(student_height)
index = end
flush()
return batches
@@ -347,7 +342,8 @@ def _generate_groups(
def run(
workspace: EvaluationWorkspace, *, overwrite: bool = False, refaire: bool = False
workspace: EvaluationWorkspace, *, overwrite: bool = False, refaire: bool = False,
clear_import: bool = False,
) -> ExitCode:
workspace.require_files("labels", "correction.json")
workspace.require_directories("Copies", "Par label")
@@ -408,11 +404,17 @@ def run(
return ExitCode.PARTIAL
if generated == 0:
print("No new grouped annotations were required.")
return ExitCode.PARTIAL if loaded.warnings else ExitCode.SUCCESS
if loaded.warnings:
return ExitCode.PARTIAL
if clear_import:
clear_import_directory(workspace)
return ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
parser = evaluation_parser("Generate annotated PDFs grouped by labels.")
parser.add_argument("--clear-import", action="store_true",
help="Empty IMPORT_DIR after successful annotation generation")
parser.add_argument(
"--overwrite", action="store_true", help="Replace annotation outputs safely"
)
@@ -430,7 +432,8 @@ def main(argv: Sequence[str] | None = None) -> int:
parser,
argv,
lambda args: run(
workspace_from_args(args), overwrite=args.overwrite, refaire=args.refaire
workspace_from_args(args), overwrite=args.overwrite, refaire=args.refaire,
clear_import=args.clear_import,
),
)
@@ -27,6 +27,7 @@ from copienator import (
)
from copienator.annotation_data import load_annotation_data
from copienator.commands import annotating
from copienator.commands.import_annotations import clear_import_directory
from copienator.filesystem import staged_directory
from copienator.utils import natural_key
@@ -269,6 +270,7 @@ def run(
*,
overwrite: bool = False,
refaire: bool = False,
clear_import: bool = False,
) -> ExitCode:
workspace.require_files("labels", "correction.json")
workspace.require_directories("Copies", "Par label")
@@ -319,6 +321,8 @@ def run(
except IncompleteRedo:
print("Warning: incomplete redo generation; previous BRnot was preserved.")
return ExitCode.PARTIAL
if clear_import:
clear_import_directory(workspace)
return ExitCode.SUCCESS
statuses: list[str] = []
@@ -338,11 +342,15 @@ def run(
statuses.append(future.result())
if loaded.warnings or "partial" in statuses:
return ExitCode.PARTIAL
if clear_import:
clear_import_directory(workspace)
return ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
parser = target_parser("Generate annotated PDFs with checkboxes.")
parser.add_argument("--clear-import", action="store_true",
help="Empty IMPORT_DIR after successful annotation generation")
parser.add_argument(
"--overwrite", action="store_true", help="Replace existing outputs"
)
@@ -364,6 +372,7 @@ def main(argv: Sequence[str] | None = None) -> int:
target,
overwrite=args.overwrite,
refaire=args.refaire,
clear_import=args.clear_import,
)
return execute(parser, argv, handler)
+17 -3
View File
@@ -736,8 +736,17 @@ def resolve_delayed_moves():
return new_tasks
def request_workers(args: argparse.Namespace) -> int:
workers = getattr(args, "workers", NB_THREADS)
if workers < 1:
raise CliError("Le nombre de requêtes simultanées doit être positif.",
ExitCode.INVALID_ARGUMENTS)
return workers
def run_configured(args: argparse.Namespace) -> ExitCode:
global client, tasks_to_process
workers = request_workers(args)
if client is None:
client = genai.Client(api_key=api_key)
if args.refaire:
@@ -950,15 +959,15 @@ def run_configured(args: argparse.Namespace) -> ExitCode:
made_progress = True
while tasks_to_process or made_progress:
if tasks_to_process:
print(f"Starting processing on {len(tasks_to_process)} tasks with {NB_THREADS} threads...")
with concurrent.futures.ThreadPoolExecutor(max_workers=NB_THREADS) as executor:
print(f"Starting processing on {len(tasks_to_process)} tasks with {workers} threads...")
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
waiting_tasks = list(tasks_to_process)
futures = {}
def submit_available_tasks() -> None:
while (
waiting_tasks
and len(futures) < NB_THREADS
and len(futures) < workers
and not stop_requested.is_set()
):
task = waiting_tasks.pop(0)
@@ -1074,6 +1083,7 @@ def run(
*,
api_client=None,
) -> ExitCode:
request_workers(args)
if args.reset:
workspace.require_directories("Copies")
reset_workspace(workspace)
@@ -1116,6 +1126,10 @@ def build_parser() -> argparse.ArgumentParser:
)
parser.add_argument("--overwrite", action="store_true", help="Redo requests")
parser.add_argument("--limit", type=int, help="Maximum Gemini Pro calls")
parser.add_argument(
"--workers", type=int, default=NB_THREADS,
help="Maximum simultaneous Gemini correction requests (positive integer; default: 12)",
)
parser.add_argument(
"--refaire",
action="store_true",
+10 -1
View File
@@ -534,6 +534,9 @@ def run(
sleep: Sleep = time.sleep,
max_workers: int = 12,
) -> ExitCode:
if max_workers < 1:
raise CliError("Le nombre de requêtes simultanées doit être positif.",
ExitCode.INVALID_ARGUMENTS)
workspace.require_files("labels")
images, warnings = selected_images(workspace, targets)
for warning in warnings:
@@ -628,6 +631,12 @@ def build_parser() -> argparse.ArgumentParser:
action="store_true",
help="Regenerate JSON outputs that already exist",
)
parser.add_argument(
"--workers",
type=int,
default=12,
help="Maximum simultaneous Gemini requests (positive integer; default: 12)",
)
return parser
@@ -655,7 +664,7 @@ def main(argv: Sequence[str] | None = None) -> int:
ExitCode.INVALID_ARGUMENTS,
)
targets.append(resolved)
return run(workspace, targets, overwrite=args.overwrite)
return run(workspace, targets, overwrite=args.overwrite, max_workers=args.workers)
return execute(parser, argv, handle)
+26 -1
View File
@@ -5,15 +5,40 @@ from collections.abc import Sequence
from pathlib import Path
from copienator import (
CliError,
EvaluationWorkspace,
ExitCode,
evaluation_parser,
execute,
workspace_from_args,
)
from copienator.configuration import IMPORT_DIR
from copienator import configuration
ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot")
IMPORT_DIR = getattr(configuration, "IMPORT_DIR", None)
def clear_import_directory(workspace: EvaluationWorkspace) -> None:
"""Empty the configured import folder while preserving the folder itself."""
if IMPORT_DIR is None or not str(IMPORT_DIR).strip():
print("Aucun dossier d’import configuré ; aucun fichier supprimé.")
return
directory = Path(IMPORT_DIR).expanduser().resolve()
if workspace.root.resolve().is_relative_to(directory) or Path.cwd().resolve().is_relative_to(directory):
raise CliError(f"Le dossier d’import ne peut pas contenir l’évaluation ou le projet : {directory}",
ExitCode.INVALID_ARGUMENTS)
if not directory.exists():
print(f"Dossier d’import absent ; aucun fichier supprimé : {directory}")
return
if not directory.is_dir():
raise CliError(f"Le dossier d’import n’est pas un dossier : {directory}",
ExitCode.INVALID_ARGUMENTS)
for child in directory.iterdir():
if child.is_dir() and not child.is_symlink():
shutil.rmtree(child)
else:
child.unlink()
print(f"Dossier d’import vidé : {directory}", flush=True)
def sync_annotated(
+48 -10
View File
@@ -26,6 +26,7 @@ from copienator.rubrics import present_rubric_labels
from .diagnostics import collect_diagnostics
from .batch_monitor import BatchMonitor
from .giving_names import GivingNamesPanel, find_name_issues
from .scores import ScoreEditorPanel
from .notifications import notify_desktop
from .syncthing import SyncthingPanel
from .manual_resolution import ManualResolutionPanel
@@ -279,6 +280,7 @@ class CopienatorApp(tk.Tk):
self._rendering = False
self.refaire_panel: RefaireSelection | None = None
self.giving_names_panel: GivingNamesPanel | None = None
self.score_editor_panel: ScoreEditorPanel | None = None
self.complete_giving_names_button: ttk.Button | None = None
self.label_groups_button: ttk.Button | None = None
self.pending_refaire_commands: list[list[str]] = []
@@ -809,7 +811,7 @@ class CopienatorApp(tk.Tk):
if not step:
return
self._rendering = True
if step.id == "personal_read":
if step.id in {"personal_read", "edit_scores"}:
self.command_box.grid_remove()
else:
self.command_box.grid()
@@ -822,6 +824,7 @@ class CopienatorApp(tk.Tk):
"plotting",
"manual_resolution",
"giving_names",
"edit_scores",
"statement_persp",
}
self.console.configure(height=8 if expanded_form else 14)
@@ -835,6 +838,7 @@ class CopienatorApp(tk.Tk):
self.arg_vars.clear()
self.refaire_panel = None
self.giving_names_panel = None
self.score_editor_panel = None
self.complete_giving_names_button = None
self.label_groups_button = None
entry = self.state_store.step(step.id) if self.state_store.evaluation else {}
@@ -874,7 +878,7 @@ class CopienatorApp(tk.Tk):
variant = self._current_variant()
evaluation_arg = self._evaluation_arg()
for spec in step.arguments:
if step.section == REFAIRE_SECTION:
if step.section == REFAIRE_SECTION and spec.name != "workers":
continue
if spec.variants and variant.id not in spec.variants:
continue
@@ -1050,6 +1054,12 @@ class CopienatorApp(tk.Tk):
)
row += 1
return row
if step.id == "edit_scores" and self.evaluation:
self.score_editor_panel = ScoreEditorPanel(
self.form, self.evaluation, self._score_changed
)
self.score_editor_panel.grid(row=row, column=0, columnspan=2, sticky="ew", pady=8)
row += 1
if step.id == "giving_names" and self.evaluation:
self.giving_names_panel = GivingNamesPanel(
self.form, self.evaluation
@@ -1318,7 +1328,11 @@ class CopienatorApp(tk.Tk):
if self.current_step and self.current_step.section == REFAIRE_SECTION:
if self.current_step.id == "refaire_selection" and self.refaire_panel:
return self.refaire_panel.values()
return {"target": self._evaluation_arg(), "annotation_dir": self.state_store.step("refaire_selection").get("values", {}).get("annotation_dir", "BGnot")}
return {
"target": self._evaluation_arg(),
"annotation_dir": self.state_store.step("refaire_selection").get("values", {}).get("annotation_dir", "BGnot"),
**{name: variable.get() for name, variable in self.arg_vars.items()},
}
return {name: variable.get() for name, variable in self.arg_vars.items()}
def _save_current_form(self) -> None:
@@ -1587,12 +1601,7 @@ class CopienatorApp(tk.Tk):
self._save_current_form()
run_values = self._values()
ordered_ids = self._progression_ids(step.id)
if step.id == "correction" and variant.id == "integrate":
# Integration consumes the retrieved batches; it does not invalidate them.
ordered_ids = [step_id for step_id in ordered_ids
if not self.step_by_id[step_id].skip_for_live_correction]
self.state_store.invalidate_after(ordered_ids, step.id)
self._invalidate_after_step(step, variant.id)
self.state_store.update_step(
step.id,
status="running",
@@ -1642,13 +1651,26 @@ class CopienatorApp(tk.Tk):
self._populate_tree()
self._update_controls()
def _invalidate_after_step(self, step: StepDefinition, variant_id: str) -> None:
# Checking remote job status does not change any downstream input.
if step.id == "batch_status":
return
ordered_ids = self._progression_ids(step.id)
if step.id == "correction" and variant_id in {"live", "refaire", "integrate"}:
# These modes reuse correction inputs without generating new batches.
ordered_ids = [step_id for step_id in ordered_ids
if not self.step_by_id[step_id].skip_for_live_correction]
self.state_store.invalidate_after(ordered_ids, step.id)
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 or self.active_step_id:
return
self._save_current_form()
self.state_store.invalidate_after(self._progression_ids(self.current_step.id), self.current_step.id)
# Score edits already invalidate downstream steps when Set saves a change.
if self.current_step.id != "edit_scores":
self._invalidate_after_step(self.current_step, self._current_variant().id)
self.state_store.update_step(self.current_step.id, status=status)
history: dict[str, object] = {
"step": self.current_step.id,
@@ -1677,6 +1699,20 @@ class CopienatorApp(tk.Tk):
self._populate_tree()
self.info_var.set("Renommage validé — aucun fichier modifié.")
def _score_changed(self, copy_id: str, label: str, previous: str, current: str) -> None:
step = self.step_by_id["edit_scores"]
self._invalidate_after_step(step, "default")
self.state_store.update_step(step.id, status="detected")
self.state_store.add_history({
"step": step.id, "copy_id": copy_id, "label": label,
"previous_score": previous, "score": current, "manual": True,
})
# Refresh statuses without rebuilding the form between score edits.
for item in self.steps:
if self.tree.exists(item.id):
status = self._step_status(item)
self.tree.item(item.id, values=(STATUS_LABELS.get(status, status),))
def _complete_giving_names(self) -> None:
if (
not self.current_step
@@ -2081,6 +2117,8 @@ class CopienatorApp(tk.Tk):
self.complete_giving_names_button.configure(
state="disabled" if running else "normal"
)
if self.score_editor_panel is not None:
self.score_editor_panel.set_busy(running)
def _move_selection(self, delta: int) -> None:
if not self.current_step:
+1 -1
View File
@@ -44,7 +44,7 @@ def find_name_issues(evaluation: Path) -> list[NameIssue]:
reasons = []
if name.casefold() == "unknown":
reasons.append("nom inconnu")
if counts[name.casefold()] > 1:
elif counts[name.casefold()] > 1:
reasons.append(f"nom attribué à {counts[name.casefold()]} copies")
if reasons:
issues.append(NameIssue(folder, name, copy_id, tuple(reasons)))
+38 -2
View File
@@ -5,6 +5,7 @@ from pathlib import Path
from tkinter import messagebox, ttk
from copienator import CliError
from copienator.answer_origins import populated_labels
from copienator.commands.resolve_manual import get_actual_pdf, parse_instruction_text
from copienator.platform import open_path
from .cut_helper import CutHelper
@@ -41,6 +42,7 @@ class ManualResolutionPanel(ttk.Frame):
self.get_evaluation = get_evaluation
self.pdf_buttons = []
self.cut_buttons = []
self.copy_buttons = []
actions = ttk.Frame(self)
actions.pack(fill="x")
self.editor_button = ttk.Button(
@@ -92,11 +94,25 @@ class ManualResolutionPanel(ttk.Frame):
except (OSError, RuntimeError) as exc:
messagebox.showerror("Ouverture du PDF", str(exc))
def open_copy(self, evaluation, copy_id, *, directory=False):
path = evaluation / "Copies" / f"Copie{copy_id}"
if not directory:
path = path.with_suffix(".pdf")
try:
if directory and not path.is_dir():
raise FileNotFoundError(f"Dossier introuvable : {path}")
if not directory and not path.is_file():
raise FileNotFoundError(f"PDF introuvable : {path}")
open_path(path)
except (OSError, RuntimeError) as exc:
messagebox.showerror("Ouverture du dossier" if directory else "Ouverture du PDF", str(exc))
def reload(self):
for button in self.pdf_buttons + self.cut_buttons:
for button in self.pdf_buttons + self.cut_buttons + self.copy_buttons:
button.destroy()
self.pdf_buttons.clear()
self.cut_buttons.clear()
self.copy_buttons.clear()
self.cut_result.set("")
self.text.configure(state="normal")
self.text.delete("1.0", "end")
@@ -111,7 +127,10 @@ class ManualResolutionPanel(ttk.Frame):
else:
self.editor_button.configure(state="normal")
malformed = []
provenance = {}
provenance_error = ""
for number, raw in enumerate(content.splitlines(keepends=True), 1):
populated = []
self.text.insert("end", raw.rstrip("\r\n"))
try:
instructions = parse_instruction_text(raw)
@@ -120,6 +139,21 @@ class ManualResolutionPanel(ttk.Frame):
else:
if instructions:
instruction = instructions[0]
key = (instruction.copy_id, instruction.old_label)
if key not in provenance:
try:
provenance[key] = populated_labels(path.parent, *key)
except (OSError, ValueError) as exc:
provenance[key] = []
provenance_error = f" — Provenance indisponible : {exc}"
populated = provenance[key]
for title, directory in (("pdf", False), ("dir", True)):
button = ttk.Button(
self.text, text=title, width=3,
command=lambda directory=directory, copy_id=instruction.copy_id, root=path.parent: self.open_copy(root, copy_id, directory=directory),
)
self.copy_buttons.append(button)
self.text.window_create("end", window=button, padx=2)
for title, label in (("Source", instruction.old_label),
("Cible", instruction.new_label)):
button = ttk.Button(
@@ -132,12 +166,14 @@ class ManualResolutionPanel(ttk.Frame):
command=lambda item=instruction, root=path.parent: self.open_cut(root, item))
self.cut_buttons.append(button)
self.text.window_create("end", window=button, padx=8)
if populated:
self.text.insert("end", "\n# -> " + ", ".join(populated))
if raw.endswith("\n"):
self.text.insert("end", "\n")
detail = (" — lignes invalides : " + ", ".join(malformed)) if malformed else (
f" — {len(self.pdf_buttons) // 2} instruction(s)"
)
self.status.configure(text=str(path) + detail)
self.status.configure(text=str(path) + detail + provenance_error)
finally:
self.text.configure(state="disabled")
+135
View File
@@ -0,0 +1,135 @@
from __future__ import annotations
import re
import tkinter as tk
from decimal import Decimal, InvalidOperation
from pathlib import Path
from tkinter import messagebox, ttk
from copienator import EvaluationWorkspace, atomic_update_json, read_json
from copienator.utils import natural_key
from .giving_names import RETURN_FOLDER_PATTERN
def score_path_for_id(evaluation: Path, copy_id: str) -> Path:
identifier = copy_id.strip()
if not re.fullmatch(r"[0-9]+", identifier):
raise ValueError("Saisissez un numéro d’id.")
return_dir = EvaluationWorkspace(evaluation).return_dir
matches = []
if return_dir.is_dir():
for folder in return_dir.iterdir():
match = RETURN_FOLDER_PATTERN.fullmatch(folder.name)
if folder.is_dir() and not folder.is_symlink() and match and int(match["copy_id"]) == int(identifier):
matches.append(folder / "score.json")
if not matches:
raise FileNotFoundError(f"Aucun dossier dans A Rendre pour l’id {identifier}.")
if len(matches) > 1:
raise ValueError(f"Plusieurs dossiers dans A Rendre portent l’id {identifier}.")
if not matches[0].is_file():
raise FileNotFoundError(f"score.json introuvable : {matches[0]}")
return matches[0]
def read_scores(path: Path) -> dict:
scores = read_json(path)
if not isinstance(scores, dict):
raise ValueError(f"score.json doit contenir un objet JSON : {path}")
return scores
def set_score(path: Path, label: str, value: str) -> str:
try:
number = Decimal(value.strip().replace(",", "."))
except InvalidOperation:
raise ValueError("Le score doit être un nombre.") from None
if not number.is_finite() or number < 0:
raise ValueError("Le score doit être un nombre positif ou nul.")
previous = ""
def update(scores):
nonlocal previous
if not isinstance(scores, dict) or label not in scores:
raise ValueError(f"Label absent de score.json : {label}")
previous = str(scores[label])
scores[label] = str(number)
# A Rendre/score.json is often a symlink to the annotation score file.
# Resolve it before atomic replacement so the link continues to work.
atomic_update_json(path.resolve(strict=True), update, default_factory=dict)
return previous
class ScoreEditorPanel(ttk.Frame):
def __init__(self, parent, evaluation: Path, on_change):
super().__init__(parent)
self.evaluation = evaluation
self.on_change = on_change
self.current_path = None
self.busy = False
self.id_var = tk.StringVar()
self.label_var = tk.StringVar()
self.score_var = tk.StringVar()
self.status_var = tk.StringVar()
self.columnconfigure(1, weight=1)
ttk.Label(self, text="Id de copie").grid(row=0, column=0, sticky="w", padx=(0, 8), pady=4)
self.id_entry = ttk.Entry(self, textvariable=self.id_var)
self.id_entry.grid(row=0, column=1, sticky="ew", pady=4)
ttk.Label(self, text="Label").grid(row=1, column=0, sticky="w", padx=(0, 8), pady=4)
self.label_entry = ttk.Combobox(self, textvariable=self.label_var)
self.label_entry.grid(row=1, column=1, sticky="ew", pady=4)
ttk.Label(self, text="Score").grid(row=2, column=0, sticky="w", padx=(0, 8), pady=4)
self.score_entry = ttk.Entry(self, textvariable=self.score_var)
self.score_entry.grid(row=2, column=1, sticky="ew", pady=4)
self.set_button = ttk.Button(self, text="Set", command=self.save_score)
self.set_button.grid(row=2, column=2, padx=(8, 0), pady=4)
ttk.Label(self, textvariable=self.status_var, wraplength=650).grid(
row=3, column=0, columnspan=3, sticky="w", pady=8
)
self.id_var.trace_add("write", self.load_score)
self.label_var.trace_add("write", self.load_score)
self.score_entry.bind("<Return>", lambda _event: self.save_score())
self.load_score()
def load_score(self, *_args):
self.current_path = None
self.score_var.set("")
self.label_entry.configure(values=())
try:
path = score_path_for_id(self.evaluation, self.id_var.get())
scores = read_scores(path)
self.label_entry.configure(values=sorted(scores, key=natural_key))
label = self.label_var.get().strip()
if not label:
self.status_var.set("Choisissez ou saisissez un label.")
elif label not in scores:
self.status_var.set(f"Label absent de score.json : {label}")
else:
self.current_path = path
self.score_var.set(str(scores[label]))
self.status_var.set(path.parent.name)
except (OSError, ValueError) as exc:
self.status_var.set(str(exc))
self.set_busy(self.busy)
def set_busy(self, busy: bool):
self.busy = busy
self.id_entry.configure(state="disabled" if busy else "normal")
self.label_entry.configure(state="disabled" if busy else "normal")
state = "normal" if self.current_path is not None and not busy else "disabled"
self.score_entry.configure(state=state)
self.set_button.configure(state=state)
def save_score(self):
if self.current_path is None or self.busy:
return
label = self.label_var.get().strip()
try:
previous = set_score(self.current_path, label, self.score_var.get())
current = str(read_scores(self.current_path)[label])
self.score_var.set(current)
if previous != current:
self.on_change(self.id_var.get().strip(), label, previous, current)
self.status_var.set(f"Score enregistré : {self.current_path.parent.name} — {label} : {current}")
except (OSError, ValueError) as exc:
messagebox.showerror("Modification du score", str(exc))
+39
View File
@@ -74,6 +74,15 @@ def arg_target(help_text: str = "un dossier d’évaluation ou un fichier à tra
)
def arg_correction_workers(variants: tuple[str, ...] = ()) -> ArgumentSpec:
return ArgumentSpec(
"workers", "Requêtes simultanées", "int", "--workers", default=12,
help="Fixe le nombre maximal de requêtes Gemini de correction simultanées (au moins 1). "
"Réduisez cette valeur pour limiter la charge sur l’API.",
variants=variants,
)
def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
def python(ident, label, script, **kwargs):
supports_verbose = kwargs.pop("supports_verbose", True)
@@ -317,6 +326,16 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
(python("default", "Détection des labels", "labels"),),
arguments=(
arg_target(),
ArgumentSpec(
"workers",
"Requêtes simultanées",
"int",
"--workers",
default=12,
help="Fixe le nombre maximal de requêtes Gemini simultanées (au moins 1). "
"Réduisez cette valeur pour limiter la charge sur l’API. "
"Les pages d’une même copie sont traitées dans l’ordre.",
),
ArgumentSpec(
"overwrite",
"Régénérer les résultats",
@@ -411,6 +430,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
),
arguments=(
arg_target("le dossier de l’évaluation ou une image Group_X.jpg"),
arg_correction_workers(("live", "hybrid", "refaire", "integrate")),
ArgumentSpec(
"overwrite",
"Écraser les corrections existantes",
@@ -507,6 +527,12 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
),
arguments=(
arg_target("le dossier de l’évaluation"),
ArgumentSpec(
"clear_import", "Vider le dossier d’import", "bool", "--clear-import",
default=bool(str(getattr(configuration, "IMPORT_DIR", "") or "").strip()),
help="Supprime tous les fichiers et sous-dossiers du dossier IMPORT_DIR configuré "
"après une génération réussie des copies annotées. Le dossier lui-même est conservé.",
),
ArgumentSpec(
"overwrite",
"Écraser les sorties",
@@ -653,6 +679,17 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
),
artifacts=("A Rendre",),
),
StepDefinition(
"edit_scores",
"Finalisation",
"Modifier des scores",
"Saisissez l’id de copie, puis choisissez ou saisissez un label. "
"Le score actuel est chargé automatiquement. Modifiez-le puis cliquez sur Set "
"pour l’enregistrer dans score.json. Cliquez sur « Marquer terminée » quand vous avez fini.",
(manual("default"),),
optional=True,
requires=("A Rendre",),
),
StepDefinition(
"personal_create",
"Étapes personnelles",
@@ -796,6 +833,8 @@ def build_refaire_workflow() -> list[StepDefinition]:
steps = [selection]
for suffix, title, description, program, flags, optional in definitions:
arguments = (arg_target(),) if program else ()
if suffix == "correct":
arguments += (arg_correction_workers(),)
if suffix == "merge":
arguments += (ArgumentSpec(
"annotation_dir",
+94
View File
@@ -0,0 +1,94 @@
import tempfile
import unittest
from contextlib import ExitStack
from pathlib import Path
from unittest.mock import patch
from copienator import CliError, EvaluationWorkspace, ExitCode
from copienator.annotation_data import AnnotationLoadResult
from copienator.commands import annotating as simple
from copienator.commands import annotating_by_label as grouped
from copienator.commands import annotating_with_checks as checks
from copienator.commands import import_annotations
class AnnotationImportCleanupTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
self.workspace = EvaluationWorkspace(self.root / "Exam")
for directory in ("Copies", "Par label"):
(self.workspace.root / directory).mkdir(parents=True)
(self.workspace.root / "labels").write_text("A\n")
(self.workspace.root / "correction.json").write_text("{}")
self.import_dir = self.root / "Annotées"
self.import_dir.mkdir()
self.patch = patch.object(import_annotations, "IMPORT_DIR", self.import_dir)
self.patch.start()
self.addCleanup(self.patch.stop)
def test_cleanup_empties_folder_without_following_child_symlinks(self):
(self.import_dir / "old.pdf").write_bytes(b"return")
nested = self.import_dir / "nested"
nested.mkdir()
(nested / "old.jpg").write_bytes(b"return")
outside = self.root / "keep"
outside.mkdir()
(outside / "keep.pdf").write_bytes(b"keep")
(self.import_dir / "link").symlink_to(outside, target_is_directory=True)
import_annotations.clear_import_directory(self.workspace)
self.assertTrue(self.import_dir.is_dir())
self.assertEqual(list(self.import_dir.iterdir()), [])
self.assertTrue((outside / "keep.pdf").is_file())
def test_cleanup_handles_unconfigured_or_missing_folder(self):
for configured in (None, "", self.root / "absent"):
with self.subTest(configured=configured), \
patch.object(import_annotations, "IMPORT_DIR", configured):
import_annotations.clear_import_directory(self.workspace)
self.assertFalse((self.root / "absent").exists())
def test_cleanup_rejects_an_ancestor_of_evaluation(self):
with patch.object(import_annotations, "IMPORT_DIR", self.root), \
self.assertRaises(CliError):
import_annotations.clear_import_directory(self.workspace)
self.assertTrue(self.workspace.root.is_dir())
def test_all_modes_clean_only_when_checked_and_successful(self):
for module in (simple, checks, grouped):
for clear_import, warnings in ((False, []), (True, []), (True, ["missing answer"])):
with self.subTest(mode=module.__name__, checked=clear_import, warnings=warnings):
old = self.import_dir / "old.pdf"
old.write_bytes(b"return")
with ExitStack() as patches:
patches.enter_context(patch.object(
module, "load_annotation_data",
return_value=AnnotationLoadResult({"01": {"A": {}}}, warnings),
))
if module is simple:
patches.enter_context(patch.object(
simple, "process_correction", return_value=ExitCode.SUCCESS
))
elif module is checks:
patches.enter_context(patch.object(
checks, "_render_student", return_value="success"
))
else:
patches.enter_context(patch.object(
grouped, "_generate_groups", return_value=(1, 0)
))
patches.enter_context(patch.object(
grouped, "_load_label_groups", return_value=[["A"]]
))
targets = (self.workspace.root,) if module is checks else ()
status = module.run(self.workspace, *targets, clear_import=clear_import)
self.assertEqual(status, ExitCode.PARTIAL if warnings else ExitCode.SUCCESS)
self.assertEqual(old.exists(), not (clear_import and not warnings))
def test_cli_forwards_cleanup_flag_for_each_mode(self):
for module in (simple, checks, grouped):
with self.subTest(mode=module.__name__), \
patch.object(module, "run", return_value=ExitCode.SUCCESS) as run:
self.assertEqual(module.main([str(self.workspace.root), "--clear-import"]), 0)
self.assertTrue(run.call_args.kwargs["clear_import"])
+44 -6
View File
@@ -1,6 +1,7 @@
import os
import tempfile
import unittest
from itertools import product
from pathlib import Path
from tkinter import ttk
from unittest.mock import patch
@@ -51,6 +52,19 @@ class GuiConvenienceTests(unittest.TestCase):
start.assert_not_called()
self.assertEqual(self.app.state_store.step(step.id)["status"], "success")
def test_annotation_import_cleanup_can_be_unchecked(self):
self.app.tree.selection_set("annotation")
self.app.update()
self.assertTrue(self.app.arg_vars["clear_import"].get())
self.assertIn("--clear-import", self.app._make_command())
self.app.arg_vars["clear_import"].set(False)
self.assertNotIn("--clear-import", self.app._make_command())
self.app.tree.selection_set("export")
self.app.update()
self.app.tree.selection_set("annotation")
self.app.update()
self.assertFalse(self.app.arg_vars["clear_import"].get())
def test_cutleft_visual_check_option_is_below_and_off_by_default(self):
step = self.app.step_by_id["cutleft"]
self.app.current_step = step
@@ -280,9 +294,11 @@ class GuiConvenienceTests(unittest.TestCase):
self.assertEqual(self.app.state_store.step("correction")["variant"], "integrate")
self.assertIn("--deal-with-batched", self.app._make_command())
def test_integration_preserves_batch_statuses_but_invalidates_downstream_steps(self):
for variant in ("integrate", "batch"):
with self.subTest(variant=variant):
def test_correction_invalidates_batches_only_when_regenerating_or_resetting(self):
for variant, action in product(
("integrate", "live", "refaire", "batch", "hybrid", "reset"), ("run", "mark")
):
with self.subTest(variant=variant, action=action):
self.app.active_step_id = None
self.app.current_step = None
self.app.state_store.update_step("correction", variant=variant, batch_started=True)
@@ -294,14 +310,36 @@ class GuiConvenienceTests(unittest.TestCase):
self.app.tree.selection_set("correction")
self.app.update()
with patch.object(self.app.runner, "start"), \
patch.object(self.app, "_missing_requirements", return_value=[]):
patch.object(self.app, "_missing_requirements", return_value=[]), \
patch("copienator_gui.app.messagebox.askyesno", return_value=True):
if action == "run":
self.app._run_current_step()
self.assertEqual(self.app.active_step_id, "correction")
else:
self.app._mark_step("success")
for step_id, status in statuses.items():
expected = status if variant == "integrate" and step_id != "annotation" else "stale"
preserve = variant in {"integrate", "live", "refaire"} and step_id != "annotation"
expected = status if preserve else "stale"
self.assertEqual(self.app.state_store.step(step_id)["status"], expected)
self.app.active_step_id = None
def test_checking_batch_status_preserves_downstream_statuses(self):
self.app.state_store.update_step("correction", batch_started=True)
statuses = {"fetch_batches": "success", "annotation": "detected"}
for step_id, status in statuses.items():
self.app.state_store.update_step(step_id, status=status)
self.app._populate_tree()
self.app.tree.selection_set("batch_status")
self.app.update()
with patch.object(self.app.runner, "start"), \
patch.object(self.app, "_missing_requirements", return_value=[]):
self.app._run_current_step()
self.assertEqual(self.app.active_step_id, "batch_status")
self.app.active_step_id = None
self.app._mark_step("success")
for step_id, status in statuses.items():
self.assertEqual(self.app.state_store.step(step_id)["status"], status)
def test_unsuccessful_batch_retrieval_stays_on_retrieval(self):
self.app.state_store.update_step("correction", variant="batch", last_run_variant="batch")
self.app._populate_tree()
@@ -505,7 +543,7 @@ class GuiConvenienceTests(unittest.TestCase):
self.app._target_selected_copy()
command = self.app._make_command()
self.assertEqual(Path(command[-1]), copies / "Copie02.pdf")
self.assertEqual(Path(command[5]), copies / "Copie02.pdf")
def button_texts(widget):
return [
+92 -9
View File
@@ -1479,7 +1479,7 @@ class StandardCliTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as directory:
workspace = EvaluationWorkspace(Path(directory))
args = module.build_parser().parse_args(
[str(workspace.root), "--overwrite"]
[str(workspace.root), "--overwrite", "--workers", "1"]
)
module.configure_runtime(
workspace,
@@ -1495,18 +1495,46 @@ class StandardCliTests(unittest.TestCase):
(workspace.root / "saved-result").write_text(task[0])
return []
with patch.object(module, "NB_THREADS", 1), patch.object(
with patch.object(
module.concurrent.futures, "ThreadPoolExecutor", wraps=ThreadPoolExecutor
) as executor, patch.object(
module, "process_single_task", side_effect=process
), patch.object(
module, "resolve_delayed_moves", return_value=[]
):
status = module.run_configured(args)
executor.assert_called_once_with(max_workers=1)
saved_result = (workspace.root / "saved-result").read_text()
self.assertEqual(status, module.ExitCode.INTERRUPTED)
self.assertEqual(processed, ["group-1.jpg"])
self.assertEqual(saved_result, "group-1.jpg")
def test_correction_concurrency_defaults_and_cli_forwarding(self) -> None:
module = self.modules["correction"]
self.assertEqual(module.build_parser().parse_args(["Exam"]).workers, 12)
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "Copies").mkdir()
with patch.object(module, "run", return_value=ExitCode.SUCCESS) as run:
self.assertEqual(module.main([str(root), "--workers", "2"]), 0)
self.assertEqual(run.call_args.args[2].workers, 2)
def test_invalid_correction_concurrency_rejected_before_changes(self) -> None:
module = self.modules["correction"]
with tempfile.TemporaryDirectory() as directory:
workspace = EvaluationWorkspace(Path(directory))
for workers in (0, -1):
args = module.build_parser().parse_args(
[str(workspace.root), "--workers", str(workers)]
)
with self.subTest(workers=workers), \
patch.object(module, "configure_runtime") as configure, \
self.assertRaises(CliError) as caught:
module.run(workspace, [], args)
self.assertEqual(caught.exception.exit_code, ExitCode.INVALID_ARGUMENTS)
configure.assert_not_called()
def test_gemini_call_finishes_inflight_stream_but_does_not_retry(self) -> None:
module = self.modules["correction"]
self.addCleanup(module.stop_requested.clear)
@@ -1562,7 +1590,7 @@ class StandardCliTests(unittest.TestCase):
api_client.models.generate_content_stream.side_effect = stream
args = module.build_parser().parse_args([str(workspace.root)])
with patch.object(module, "NB_THREADS", 1), patch.object(
with patch.object(args, "workers", 1), patch.object(
module.prompting, "generate_request", return_value=([], Mock())
):
module.configure_runtime(
@@ -1636,7 +1664,7 @@ class StandardCliTests(unittest.TestCase):
[str(workspace.root), "--overwrite"]
)
with patch.object(module, "NB_THREADS", 1), patch.object(
with patch.object(args, "workers", 1), patch.object(
module.prompting, "generate_request", return_value=([], Mock())
), patch.object(
module, "handle_label_errors", side_effect=resolve_error
@@ -2208,6 +2236,23 @@ class StandardCliTests(unittest.TestCase):
self.assertIn("Copie01", str(raised.exception))
self.assertIn("cannot be split", str(raised.exception))
def test_grouped_batching_reuses_space_in_earlier_groups(self) -> None:
module = self.modules["annotating_by_label"]
rendered = [
("01", "Ex 1", Image.new("RGB", (10, 70)), 0, []),
("02", "Ex 1", Image.new("RGB", (10, 60)), 0, []),
("03", "Ex 1", Image.new("RGB", (10, 40)), 0, []),
("04", "Ex 1", Image.new("RGB", (10, 15)), 0, []),
("04", "Ex 2", Image.new("RGB", (10, 15)), 0, []),
]
with patch.object(module, "MAX_HEIGHT_PX", 100):
batches = module.split_batches(rendered)
self.assertEqual([[item[0] for item in batch] for batch in batches],
[["01", "04", "04"], ["02", "03"]])
self.assertEqual([sum(item[2].height for item in batch) for batch in batches],
[100, 100])
self.assertEqual(batches[0][-2:], rendered[-2:])
def test_grouped_annotation_height_limit_is_32000_pixels(self) -> None:
module = self.modules["annotating_by_label"]
self.assertEqual(module.MAX_HEIGHT_PX, 32000)
@@ -2223,6 +2268,16 @@ class WorkflowTests(unittest.TestCase):
variant = next(item for item in step.variants if item.id == variant_id)
return build_command(REPOSITORY, step, variant, values, self.evaluation)
def test_label_request_concurrency_is_configurable(self) -> None:
for personal in (False, True):
step = next(step for step in build_workflow(personal) if step.id == "labels")
for values, expected in (({}, "12"), ({"workers": "2"}, "2")):
with self.subTest(personal=personal, values=values):
command = build_command(
REPOSITORY, step, step.variants[0], values, self.evaluation
)
self.assertEqual(command[command.index("--workers") + 1], expected)
def test_personal_steps_are_configurable(self) -> None:
standard_ids = {step.id for step in build_workflow(False)}
personal_ids = {step.id for step in build_workflow(True)}
@@ -2390,6 +2445,7 @@ class WorkflowTests(unittest.TestCase):
"Dupont (02)": (b"two-jpg", b"two-pdf"),
"Dupont (03)": (b"three-jpg", b"three-pdf"),
"Unique (04)": (b"four-jpg", b"four-pdf"),
"unknown (05)": (b"five-jpg", b"five-pdf"),
}
for folder_name, (jpg, pdf) in folders.items():
folder = return_dir / folder_name
@@ -2401,7 +2457,11 @@ class WorkflowTests(unittest.TestCase):
(folder / "answers").mkdir()
issues = find_name_issues(evaluation)
self.assertEqual([issue.copy_id for issue in issues], ["01", "02", "03"])
self.assertEqual([issue.copy_id for issue in issues], ["01", "02", "03", "05"])
self.assertEqual(issues[0].reasons, ("nom inconnu",))
self.assertEqual(issues[3].reasons, ("nom inconnu",))
self.assertEqual(issues[1].reasons, ("nom attribué à 2 copies",))
self.assertEqual(issues[2].reasons, ("nom attribué à 2 copies",))
self.assertEqual(
original_copy_path(evaluation, "01"),
original_dir / "Copie01.pdf",
@@ -2415,7 +2475,7 @@ class WorkflowTests(unittest.TestCase):
self.assertTrue((renamed / "answers").is_dir())
self.assertEqual(
[issue.copy_id for issue in find_name_issues(evaluation)],
["02", "03"],
["02", "03", "05"],
)
def test_export_and_import_defaults_follow_previous_runs(self) -> None:
@@ -2496,10 +2556,19 @@ class WorkflowTests(unittest.TestCase):
)
self.assertEqual(
command_arguments(command),
[self.evaluation, "--overwrite", "--limit", "0"],
[self.evaluation, "--workers", "12", "--overwrite", "--limit", "0"],
)
self.assertEqual(command[4], "correct")
def test_correction_concurrency_applies_to_live_requests(self) -> None:
for variant in self.steps["correction"].variants:
with self.subTest(variant=variant.id):
command = self.command("correction", variant.id, {"workers": "2"})
if variant.id in {"batch", "reset"}:
self.assertNotIn("--workers", command)
else:
self.assertEqual(command[command.index("--workers") + 1], "2")
def test_refaire_correction_does_not_offer_overwrite(self) -> None:
step = self.steps["correction"]
overwrite = next(
@@ -2520,7 +2589,7 @@ class WorkflowTests(unittest.TestCase):
"correction", "hybrid", {"target": self.evaluation, "batch_from": "Ex 4"}
)
self.assertEqual(
command_arguments(command), [self.evaluation, "--batch-from", "Ex 4"]
command_arguments(command), [self.evaluation, "--workers", "12", "--batch-from", "Ex 4"]
)
def test_only_multi_target_steps_offer_free_form_arguments(self) -> None:
@@ -2624,7 +2693,8 @@ class WorkflowTests(unittest.TestCase):
self.evaluation,
verbose=True,
)
self.assertEqual(command_arguments(labels_command), [self.evaluation, "--verbose"])
self.assertEqual(command_arguments(labels_command),
[self.evaluation, "--workers", "12", "--verbose"])
rotate = self.steps["rotate"]
rotate_command = build_command(
@@ -2657,6 +2727,19 @@ class WorkflowTests(unittest.TestCase):
)
self.assertEqual(command[4], "annotate-grouped")
def test_annotation_import_cleanup_defaults_to_configured_folder(self) -> None:
for configured in (None, "", Path("Import")):
with patch.object(workflow_module.configuration, "IMPORT_DIR", configured):
step = next(step for step in build_workflow(False) if step.id == "annotation")
for variant in step.variants:
with self.subTest(configured=configured, variant=variant.id):
command = build_command(REPOSITORY, step, variant, {}, self.evaluation)
self.assertEqual("--clear-import" in command, bool(configured))
command = build_command(
REPOSITORY, step, variant, {"clear_import": False}, self.evaluation
)
self.assertNotIn("--clear-import", command)
def test_copy_preparation_commands_are_python(self) -> None:
rotate = self.command("rotate", "rotate", {"target": self.evaluation})
rename = self.command("rename", "rename", {"target": self.evaluation})
+42 -1
View File
@@ -5,6 +5,7 @@ import unittest
from pathlib import Path
from unittest.mock import call, patch
from copienator.answer_origins import save_origins
from copienator_gui.manual_resolution import ManualResolutionPanel
@@ -34,17 +35,27 @@ class ManualResolutionPanelTests(unittest.TestCase):
path.parent.mkdir(parents=True, exist_ok=True)
path.touch()
paths.append(path)
copy_paths = []
for copy in ("01", "02"):
pdf = self.evaluation / "Copies" / f"Copie{copy}.pdf"
pdf.touch()
copy_paths.extend((pdf, pdf.with_suffix("")))
panel = self.panel()
self.assertEqual(panel.text.get("1.0", "end-1c"), content)
self.assertEqual(len(panel.pdf_buttons), 4)
self.assertEqual([button.cget("text") for button in panel.pdf_buttons],
["Source : Ex 1", "Cible : Ex 2",
"Source : Ex 3", "Cible : Ex 4"])
self.assertEqual([button.cget("text") for button in panel.copy_buttons],
["pdf", "dir", "pdf", "dir"])
with patch("copienator_gui.manual_resolution.open_path") as opened:
panel.editor_button.invoke()
for button in panel.pdf_buttons:
button.invoke()
self.assertEqual(opened.call_args_list, [call(self.path), *map(call, paths)])
for button in panel.copy_buttons:
button.invoke()
self.assertEqual(opened.call_args_list,
[call(self.path), *map(call, paths), *map(call, copy_paths)])
def test_reload_updates_the_displayed_target_and_its_pdf_action(self):
self.path.write_text("Copie01 A -> B|\n", encoding="utf-8")
@@ -63,6 +74,34 @@ class ManualResolutionPanelTests(unittest.TestCase):
panel.pdf_buttons[1].invoke()
opened.assert_called_once_with(copies / "C.pdf")
def test_populated_labels_appear_under_each_relevant_conflict(self):
content = "Copie01 A -> B|\nCopie02 A -> B|\nCopie01 A -> C|"
self.path.write_text(content, encoding="utf-8")
copies = self.evaluation / "Copies" / "Copie01"
copies.mkdir(parents=True)
for label, filename in (("label2", "label2_new.pdf"),
("label1", "label1.pdf"),
("stale", "stale.pdf"),
("archived", "archived_old.pdf")):
pdf = copies / filename
pdf.write_bytes(b"answer")
save_origins(self.evaluation, "01", label, pdf, ["A"])
(copies / "stale.pdf").write_bytes(b"changed answer")
panel = self.panel()
expected = (
"Copie01 A -> B|\n# -> label1, label2\n"
"Copie02 A -> B|\n"
"Copie01 A -> C|\n# -> label1, label2"
)
self.assertEqual(panel.text.get("1.0", "end-1c"), expected)
self.assertEqual(self.path.read_text(encoding="utf-8"), content)
self.assertEqual(len(panel.pdf_buttons), 6)
self.assertIn("3 instruction(s)", panel.status.cget("text"))
(copies / "label1.pdf").unlink()
panel.reload()
self.assertEqual(panel.text.get("1.0", "end-1c"),
expected.replace("label1, label2", "label2"))
def test_reload_keeps_invalid_lines_visible_and_removes_stale_actions(self):
self.path.write_text("Copie01 A -> B|\n", encoding="utf-8")
panel = self.panel()
@@ -71,9 +110,11 @@ class ManualResolutionPanelTests(unittest.TestCase):
self.assertIn("bad instruction", panel.text.get("1.0", "end"))
self.assertIn("lignes invalides : 1", panel.status.cget("text"))
self.assertEqual(len(panel.pdf_buttons), 2)
self.assertEqual(len(panel.copy_buttons), 2)
self.path.unlink()
panel.reload()
self.assertFalse(panel.pdf_buttons)
self.assertFalse(panel.copy_buttons)
self.assertEqual(str(panel.editor_button.cget("state")), "disabled")
def test_missing_source_does_not_prevent_opening_destination(self):
+11
View File
@@ -126,6 +126,17 @@ class RefaireGuiTests(unittest.TestCase):
self.app._populate_tree()
self.assertTrue(self.app.tree.item(section, "open"))
def test_correction_concurrency_can_be_edited_and_saved(self):
self.save_selection()
self.select("refaire_correct")
self.assertEqual(self.app.arg_vars["workers"].get(), "12")
self.app.arg_vars["workers"].set("2")
command = self.app._make_command()
self.assertEqual(command[command.index("--workers") + 1], "2")
self.select("refaire_review")
self.select("refaire_correct")
self.assertEqual(self.app.arg_vars["workers"].get(), "2")
def test_dropdown_selection_saves_json_and_drives_all_commands(self):
self.save_selection()
self.assertEqual(
+113
View File
@@ -0,0 +1,113 @@
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from copienator import read_json
from copienator_gui.app import CopienatorApp
from copienator_gui.scores import score_path_for_id, set_score
from copienator_gui.workflow import build_workflow
class ScoreEditorTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.evaluation = Path(temporary.name)
folder = self.evaluation / "A Rendre" / "Student (01)"
folder.mkdir(parents=True)
self.path = folder / "score.json"
self.path.write_text('{"Ex 1": "2", "Ex 2": "4"}')
def test_step_is_optional_and_follows_return_preparation_in_both_profiles(self):
for personal in (False, True):
steps = build_workflow(personal)
index = next(index for index, step in enumerate(steps) if step.id == "giving_names")
self.assertEqual(steps[index + 1].id, "edit_scores")
self.assertTrue(steps[index + 1].optional)
self.assertTrue(steps[index + 1].is_manual)
def test_update_preserves_other_labels_and_follows_symlinks(self):
source = self.evaluation / "source-score.json"
self.path.rename(source)
self.path.symlink_to(source)
path = score_path_for_id(self.evaluation, "1")
self.assertEqual(path, score_path_for_id(self.evaluation, "01"))
self.assertEqual(set_score(path, "Ex 1", "3,5"), "2")
self.assertTrue(path.is_symlink())
self.assertEqual(read_json(source), {"Ex 1": "3.5", "Ex 2": "4"})
self.assertEqual(read_json(path), read_json(source))
def test_invalid_score_or_missing_label_does_not_modify_file(self):
before = self.path.read_bytes()
for value in ("", "abc", "NaN", "Infinity", "-1"):
with self.subTest(value=value), self.assertRaises(ValueError):
set_score(self.path, "Ex 1", value)
self.assertEqual(self.path.read_bytes(), before)
with self.assertRaises(ValueError):
set_score(self.path, "Missing", "3")
self.assertEqual(self.path.read_bytes(), before)
def test_missing_and_ambiguous_ids_are_rejected(self):
with self.assertRaises(FileNotFoundError):
score_path_for_id(self.evaluation, "2")
with self.assertRaises(ValueError):
score_path_for_id(self.evaluation, "../1")
(self.path.parent.parent / "Other (1)").mkdir()
with self.assertRaises(ValueError):
score_path_for_id(self.evaluation, "1")
@unittest.skipUnless(os.environ.get("DISPLAY"), "Tk requires a display")
class ScoreEditorGuiTests(ScoreEditorTests):
def setUp(self):
super().setUp()
self.app = CopienatorApp(Path.cwd(), True, self.evaluation)
self.addCleanup(self.close_app)
self.app.update()
self.app.tree.selection_set("edit_scores")
self.app.update()
self.panel = self.app.score_editor_panel
def close_app(self):
for callback in self.app.tk.splitlist(self.app.tk.call("after", "info")):
self.app.after_cancel(callback)
self.app.destroy()
def test_selection_loads_score_and_set_keeps_editor_open(self):
self.assertEqual(str(self.panel.set_button.cget("state")), "disabled")
self.panel.id_var.set("1")
self.assertEqual(tuple(self.panel.label_entry.cget("values")), ("Ex 1", "Ex 2"))
self.panel.label_var.set("Ex 1")
self.assertEqual(self.panel.score_var.get(), "2")
self.app.state_store.update_step("final_score", status="success")
self.panel.score_var.set("3,5")
self.panel.set_button.invoke()
self.app.update()
self.assertIs(self.app.score_editor_panel, self.panel)
self.assertEqual(self.panel.id_var.get(), "1")
self.assertEqual(read_json(self.path), {"Ex 1": "3.5", "Ex 2": "4"})
self.assertEqual(self.app.state_store.step("final_score")["status"], "stale")
self.panel.label_var.set("Ex 2")
self.assertEqual(self.panel.score_var.get(), "4")
self.panel.id_var.set("2")
self.assertEqual(self.panel.score_var.get(), "")
self.assertEqual(str(self.panel.set_button.cget("state")), "disabled")
def test_invalid_score_shows_error_and_running_disables_edits(self):
self.panel.id_var.set("01")
self.panel.label_var.set("Ex 1")
self.panel.score_var.set("bad")
with patch("copienator_gui.scores.messagebox.showerror") as error:
self.panel.set_button.invoke()
error.assert_called_once()
self.assertEqual(read_json(self.path)["Ex 1"], "2")
self.panel.set_busy(True)
self.assertEqual(str(self.panel.set_button.cget("state")), "disabled")
def test_skipping_without_changes_preserves_downstream_status(self):
self.app.state_store.update_step("final_score", status="success")
self.app._skip_step()
self.assertEqual(self.app.state_store.step("edit_scores")["status"], "skipped")
self.assertEqual(self.app.state_store.step("final_score")["status"], "success")
+29 -2
View File
@@ -1,13 +1,13 @@
import io
import tempfile
import unittest
from concurrent.futures import wait as real_wait
from concurrent.futures import ThreadPoolExecutor, wait as real_wait
from contextlib import redirect_stdout
from pathlib import Path
from threading import Event
from unittest.mock import Mock, patch
from copienator import EvaluationWorkspace, ExitCode
from copienator import CliError, EvaluationWorkspace, ExitCode
from copienator.commands import gemini_for_labels as labels
@@ -36,6 +36,33 @@ class LabelTimeoutTests(unittest.TestCase):
sleep=lambda _: None, target_interval=0,
)
def test_cli_forwards_selected_concurrency(self):
for arguments, expected in (([], 12), (["--workers", "2"], 2)):
with self.subTest(arguments=arguments), \
patch.object(labels, "run", return_value=ExitCode.SUCCESS) as run:
self.assertEqual(labels.main([str(self.workspace.root), *arguments]), 0)
run.assert_called_once_with(
unittest.mock.ANY, [self.workspace.root],
overwrite=False, max_workers=expected,
)
def test_selected_concurrency_controls_executor(self):
self.client.models.generate_content.return_value = self.response
with patch.object(labels, "selected_images", return_value=(self.images, [])), \
patch.object(labels, "ThreadPoolExecutor", wraps=ThreadPoolExecutor) as executor:
result = labels.run(self.workspace, [], client=self.client,
sleep=lambda _: None, max_workers=1)
self.assertEqual(result, ExitCode.SUCCESS)
executor.assert_called_once_with(max_workers=1)
self.assertEqual(self.client.models.generate_content.call_count, 3)
def test_nonpositive_concurrency_rejected_before_requests(self):
for workers in (0, -1):
with self.subTest(workers=workers), self.assertRaises(CliError) as caught:
labels.run(self.workspace, [], client=self.client, max_workers=workers)
self.assertEqual(caught.exception.exit_code, ExitCode.INVALID_ARGUMENTS)
self.client.models.generate_content.assert_not_called()
def test_timeout_recovery_uses_bounded_requests_and_saves_result(self):
self.client.models.generate_content.side_effect = [TimeoutError(), self.response]
self.assertEqual(self.group(self.images[:1]), 1)