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): def origins_for(root, copy_id, label, pdf):
entry = read_json(_path(root), default={}).get(str(copy_id), {}).get(label, {}) 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(): if not entry or not Path(pdf).is_file():
return [] return []
if entry.get("sha256") != sha256(Path(pdf).read_bytes()).hexdigest(): 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", []) 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): def save_origins(root, copy_id, label, pdf, origins):
entry = {"labels": list(dict.fromkeys(origins)), entry = {"labels": list(dict.fromkeys(origins)),
"sha256": sha256(Path(pdf).read_bytes()).hexdigest()} "sha256": sha256(Path(pdf).read_bytes()).hexdigest()}
+9 -2
View File
@@ -30,6 +30,7 @@ from copienator import (
workspace_from_args, workspace_from_args,
) )
from copienator.annotation_data import load_annotation_data 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.answer_info import build_answer_info
from copienator.filesystem import staged_directory from copienator.filesystem import staged_directory
from copienator.feedback_boxes import valid_feedback_box 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: def build_parser() -> argparse.ArgumentParser:
parser = evaluation_parser("Generate simple annotated copies.") 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( parser.add_argument(
"--overwrite", "--overwrite",
action="store_true", action="store_true",
@@ -533,7 +536,8 @@ def build_parser() -> argparse.ArgumentParser:
return parser 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_files("labels", "correction.json")
workspace.require_directories("Copies", "Par label") workspace.require_directories("Copies", "Par label")
labels = utils.read_all_labels(workspace.root) 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: if loaded.warnings and result == ExitCode.SUCCESS:
return ExitCode.PARTIAL return ExitCode.PARTIAL
if clear_import and result == ExitCode.SUCCESS:
clear_import_directory(workspace)
return result return result
@@ -559,7 +565,8 @@ def main(argv: Sequence[str] | None = None) -> int:
return execute( return execute(
parser, parser,
argv, 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.annotation_data import load_annotation_data
from copienator.commands import annotating, annotating_with_checks 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.filesystem import staged_directory
from copienator.utils import natural_key from copienator.utils import natural_key
@@ -274,15 +275,7 @@ def _existing_group_state(
def split_batches(rendered): def split_batches(rendered):
batches = [] batches = []
current = [] heights = []
current_height = 0
def flush() -> None:
nonlocal current, current_height
if current:
batches.append(current)
current = []
current_height = 0
index = 0 index = 0
while index < len(rendered): while index < len(rendered):
@@ -301,15 +294,17 @@ def split_batches(rendered):
f"maximum group height is {MAX_HEIGHT_PX} px." f"maximum group height is {MAX_HEIGHT_PX} px."
) )
# Consider the student's complete block before adding its first image. # Reuse space in earlier groups, keeping each student's block intact.
# Otherwise later images from that same student could overflow the batch. for batch_index, height in enumerate(heights):
if current and current_height + student_height > MAX_HEIGHT_PX: if height + student_height <= MAX_HEIGHT_PX:
flush() batches[batch_index].extend(student_items)
current.extend(student_items) heights[batch_index] += student_height
current_height += student_height break
else:
batches.append(list(student_items))
heights.append(student_height)
index = end index = end
flush()
return batches return batches
@@ -347,7 +342,8 @@ def _generate_groups(
def run( def run(
workspace: EvaluationWorkspace, *, overwrite: bool = False, refaire: bool = False workspace: EvaluationWorkspace, *, overwrite: bool = False, refaire: bool = False,
clear_import: bool = False,
) -> ExitCode: ) -> ExitCode:
workspace.require_files("labels", "correction.json") workspace.require_files("labels", "correction.json")
workspace.require_directories("Copies", "Par label") workspace.require_directories("Copies", "Par label")
@@ -408,11 +404,17 @@ def run(
return ExitCode.PARTIAL return ExitCode.PARTIAL
if generated == 0: if generated == 0:
print("No new grouped annotations were required.") 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: def build_parser() -> argparse.ArgumentParser:
parser = evaluation_parser("Generate annotated PDFs grouped by labels.") 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( parser.add_argument(
"--overwrite", action="store_true", help="Replace annotation outputs safely" "--overwrite", action="store_true", help="Replace annotation outputs safely"
) )
@@ -430,7 +432,8 @@ def main(argv: Sequence[str] | None = None) -> int:
parser, parser,
argv, argv,
lambda args: run( 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.annotation_data import load_annotation_data
from copienator.commands import annotating from copienator.commands import annotating
from copienator.commands.import_annotations import clear_import_directory
from copienator.filesystem import staged_directory from copienator.filesystem import staged_directory
from copienator.utils import natural_key from copienator.utils import natural_key
@@ -269,6 +270,7 @@ def run(
*, *,
overwrite: bool = False, overwrite: bool = False,
refaire: bool = False, refaire: bool = False,
clear_import: bool = False,
) -> ExitCode: ) -> ExitCode:
workspace.require_files("labels", "correction.json") workspace.require_files("labels", "correction.json")
workspace.require_directories("Copies", "Par label") workspace.require_directories("Copies", "Par label")
@@ -319,6 +321,8 @@ def run(
except IncompleteRedo: except IncompleteRedo:
print("Warning: incomplete redo generation; previous BRnot was preserved.") print("Warning: incomplete redo generation; previous BRnot was preserved.")
return ExitCode.PARTIAL return ExitCode.PARTIAL
if clear_import:
clear_import_directory(workspace)
return ExitCode.SUCCESS return ExitCode.SUCCESS
statuses: list[str] = [] statuses: list[str] = []
@@ -338,11 +342,15 @@ def run(
statuses.append(future.result()) statuses.append(future.result())
if loaded.warnings or "partial" in statuses: if loaded.warnings or "partial" in statuses:
return ExitCode.PARTIAL return ExitCode.PARTIAL
if clear_import:
clear_import_directory(workspace)
return ExitCode.SUCCESS return ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
parser = target_parser("Generate annotated PDFs with checkboxes.") 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( parser.add_argument(
"--overwrite", action="store_true", help="Replace existing outputs" "--overwrite", action="store_true", help="Replace existing outputs"
) )
@@ -364,6 +372,7 @@ def main(argv: Sequence[str] | None = None) -> int:
target, target,
overwrite=args.overwrite, overwrite=args.overwrite,
refaire=args.refaire, refaire=args.refaire,
clear_import=args.clear_import,
) )
return execute(parser, argv, handler) return execute(parser, argv, handler)
+17 -3
View File
@@ -736,8 +736,17 @@ def resolve_delayed_moves():
return new_tasks 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: def run_configured(args: argparse.Namespace) -> ExitCode:
global client, tasks_to_process global client, tasks_to_process
workers = request_workers(args)
if client is None: if client is None:
client = genai.Client(api_key=api_key) client = genai.Client(api_key=api_key)
if args.refaire: if args.refaire:
@@ -950,15 +959,15 @@ def run_configured(args: argparse.Namespace) -> ExitCode:
made_progress = True made_progress = True
while tasks_to_process or made_progress: while tasks_to_process or made_progress:
if tasks_to_process: if tasks_to_process:
print(f"Starting processing on {len(tasks_to_process)} tasks with {NB_THREADS} threads...") print(f"Starting processing on {len(tasks_to_process)} tasks with {workers} threads...")
with concurrent.futures.ThreadPoolExecutor(max_workers=NB_THREADS) as executor: with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
waiting_tasks = list(tasks_to_process) waiting_tasks = list(tasks_to_process)
futures = {} futures = {}
def submit_available_tasks() -> None: def submit_available_tasks() -> None:
while ( while (
waiting_tasks waiting_tasks
and len(futures) < NB_THREADS and len(futures) < workers
and not stop_requested.is_set() and not stop_requested.is_set()
): ):
task = waiting_tasks.pop(0) task = waiting_tasks.pop(0)
@@ -1074,6 +1083,7 @@ def run(
*, *,
api_client=None, api_client=None,
) -> ExitCode: ) -> ExitCode:
request_workers(args)
if args.reset: if args.reset:
workspace.require_directories("Copies") workspace.require_directories("Copies")
reset_workspace(workspace) 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("--overwrite", action="store_true", help="Redo requests")
parser.add_argument("--limit", type=int, help="Maximum Gemini Pro calls") 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( parser.add_argument(
"--refaire", "--refaire",
action="store_true", action="store_true",
+10 -1
View File
@@ -534,6 +534,9 @@ def run(
sleep: Sleep = time.sleep, sleep: Sleep = time.sleep,
max_workers: int = 12, max_workers: int = 12,
) -> ExitCode: ) -> ExitCode:
if max_workers < 1:
raise CliError("Le nombre de requêtes simultanées doit être positif.",
ExitCode.INVALID_ARGUMENTS)
workspace.require_files("labels") workspace.require_files("labels")
images, warnings = selected_images(workspace, targets) images, warnings = selected_images(workspace, targets)
for warning in warnings: for warning in warnings:
@@ -628,6 +631,12 @@ def build_parser() -> argparse.ArgumentParser:
action="store_true", action="store_true",
help="Regenerate JSON outputs that already exist", 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 return parser
@@ -655,7 +664,7 @@ def main(argv: Sequence[str] | None = None) -> int:
ExitCode.INVALID_ARGUMENTS, ExitCode.INVALID_ARGUMENTS,
) )
targets.append(resolved) 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) return execute(parser, argv, handle)
+26 -1
View File
@@ -5,15 +5,40 @@ from collections.abc import Sequence
from pathlib import Path from pathlib import Path
from copienator import ( from copienator import (
CliError,
EvaluationWorkspace, EvaluationWorkspace,
ExitCode, ExitCode,
evaluation_parser, evaluation_parser,
execute, execute,
workspace_from_args, workspace_from_args,
) )
from copienator.configuration import IMPORT_DIR from copienator import configuration
ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot") 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( def sync_annotated(
+48 -10
View File
@@ -26,6 +26,7 @@ from copienator.rubrics import present_rubric_labels
from .diagnostics import collect_diagnostics from .diagnostics import collect_diagnostics
from .batch_monitor import BatchMonitor from .batch_monitor import BatchMonitor
from .giving_names import GivingNamesPanel, find_name_issues from .giving_names import GivingNamesPanel, find_name_issues
from .scores import ScoreEditorPanel
from .notifications import notify_desktop from .notifications import notify_desktop
from .syncthing import SyncthingPanel from .syncthing import SyncthingPanel
from .manual_resolution import ManualResolutionPanel from .manual_resolution import ManualResolutionPanel
@@ -279,6 +280,7 @@ class CopienatorApp(tk.Tk):
self._rendering = False self._rendering = False
self.refaire_panel: RefaireSelection | None = None self.refaire_panel: RefaireSelection | None = None
self.giving_names_panel: GivingNamesPanel | 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.complete_giving_names_button: ttk.Button | None = None
self.label_groups_button: ttk.Button | None = None self.label_groups_button: ttk.Button | None = None
self.pending_refaire_commands: list[list[str]] = [] self.pending_refaire_commands: list[list[str]] = []
@@ -809,7 +811,7 @@ class CopienatorApp(tk.Tk):
if not step: if not step:
return return
self._rendering = True self._rendering = True
if step.id == "personal_read": if step.id in {"personal_read", "edit_scores"}:
self.command_box.grid_remove() self.command_box.grid_remove()
else: else:
self.command_box.grid() self.command_box.grid()
@@ -822,6 +824,7 @@ class CopienatorApp(tk.Tk):
"plotting", "plotting",
"manual_resolution", "manual_resolution",
"giving_names", "giving_names",
"edit_scores",
"statement_persp", "statement_persp",
} }
self.console.configure(height=8 if expanded_form else 14) self.console.configure(height=8 if expanded_form else 14)
@@ -835,6 +838,7 @@ class CopienatorApp(tk.Tk):
self.arg_vars.clear() self.arg_vars.clear()
self.refaire_panel = None self.refaire_panel = None
self.giving_names_panel = None self.giving_names_panel = None
self.score_editor_panel = None
self.complete_giving_names_button = None self.complete_giving_names_button = None
self.label_groups_button = None self.label_groups_button = None
entry = self.state_store.step(step.id) if self.state_store.evaluation else {} 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() variant = self._current_variant()
evaluation_arg = self._evaluation_arg() evaluation_arg = self._evaluation_arg()
for spec in step.arguments: for spec in step.arguments:
if step.section == REFAIRE_SECTION: if step.section == REFAIRE_SECTION and spec.name != "workers":
continue continue
if spec.variants and variant.id not in spec.variants: if spec.variants and variant.id not in spec.variants:
continue continue
@@ -1050,6 +1054,12 @@ class CopienatorApp(tk.Tk):
) )
row += 1 row += 1
return row 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: if step.id == "giving_names" and self.evaluation:
self.giving_names_panel = GivingNamesPanel( self.giving_names_panel = GivingNamesPanel(
self.form, self.evaluation 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 and self.current_step.section == REFAIRE_SECTION:
if self.current_step.id == "refaire_selection" and self.refaire_panel: if self.current_step.id == "refaire_selection" and self.refaire_panel:
return self.refaire_panel.values() 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()} return {name: variable.get() for name, variable in self.arg_vars.items()}
def _save_current_form(self) -> None: def _save_current_form(self) -> None:
@@ -1587,12 +1601,7 @@ class CopienatorApp(tk.Tk):
self._save_current_form() self._save_current_form()
run_values = self._values() run_values = self._values()
ordered_ids = self._progression_ids(step.id) self._invalidate_after_step(step, variant.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.state_store.update_step( self.state_store.update_step(
step.id, step.id,
status="running", status="running",
@@ -1642,13 +1651,26 @@ class CopienatorApp(tk.Tk):
self._populate_tree() self._populate_tree()
self._update_controls() 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( def _mark_step(
self, status: str, *, automatic: bool = False, reason: str | None = None self, status: str, *, automatic: bool = False, reason: str | None = None
) -> None: ) -> None:
if not self.current_step or not self.state_store.evaluation or self.active_step_id: if not self.current_step or not self.state_store.evaluation or self.active_step_id:
return return
self._save_current_form() 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) self.state_store.update_step(self.current_step.id, status=status)
history: dict[str, object] = { history: dict[str, object] = {
"step": self.current_step.id, "step": self.current_step.id,
@@ -1677,6 +1699,20 @@ class CopienatorApp(tk.Tk):
self._populate_tree() self._populate_tree()
self.info_var.set("Renommage validé — aucun fichier modifié.") 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: def _complete_giving_names(self) -> None:
if ( if (
not self.current_step not self.current_step
@@ -2081,6 +2117,8 @@ class CopienatorApp(tk.Tk):
self.complete_giving_names_button.configure( self.complete_giving_names_button.configure(
state="disabled" if running else "normal" 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: def _move_selection(self, delta: int) -> None:
if not self.current_step: if not self.current_step:
+1 -1
View File
@@ -44,7 +44,7 @@ def find_name_issues(evaluation: Path) -> list[NameIssue]:
reasons = [] reasons = []
if name.casefold() == "unknown": if name.casefold() == "unknown":
reasons.append("nom inconnu") reasons.append("nom inconnu")
if counts[name.casefold()] > 1: elif counts[name.casefold()] > 1:
reasons.append(f"nom attribué à {counts[name.casefold()]} copies") reasons.append(f"nom attribué à {counts[name.casefold()]} copies")
if reasons: if reasons:
issues.append(NameIssue(folder, name, copy_id, tuple(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 tkinter import messagebox, ttk
from copienator import CliError 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.commands.resolve_manual import get_actual_pdf, parse_instruction_text
from copienator.platform import open_path from copienator.platform import open_path
from .cut_helper import CutHelper from .cut_helper import CutHelper
@@ -41,6 +42,7 @@ class ManualResolutionPanel(ttk.Frame):
self.get_evaluation = get_evaluation self.get_evaluation = get_evaluation
self.pdf_buttons = [] self.pdf_buttons = []
self.cut_buttons = [] self.cut_buttons = []
self.copy_buttons = []
actions = ttk.Frame(self) actions = ttk.Frame(self)
actions.pack(fill="x") actions.pack(fill="x")
self.editor_button = ttk.Button( self.editor_button = ttk.Button(
@@ -92,11 +94,25 @@ class ManualResolutionPanel(ttk.Frame):
except (OSError, RuntimeError) as exc: except (OSError, RuntimeError) as exc:
messagebox.showerror("Ouverture du PDF", str(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): 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() button.destroy()
self.pdf_buttons.clear() self.pdf_buttons.clear()
self.cut_buttons.clear() self.cut_buttons.clear()
self.copy_buttons.clear()
self.cut_result.set("") self.cut_result.set("")
self.text.configure(state="normal") self.text.configure(state="normal")
self.text.delete("1.0", "end") self.text.delete("1.0", "end")
@@ -111,7 +127,10 @@ class ManualResolutionPanel(ttk.Frame):
else: else:
self.editor_button.configure(state="normal") self.editor_button.configure(state="normal")
malformed = [] malformed = []
provenance = {}
provenance_error = ""
for number, raw in enumerate(content.splitlines(keepends=True), 1): for number, raw in enumerate(content.splitlines(keepends=True), 1):
populated = []
self.text.insert("end", raw.rstrip("\r\n")) self.text.insert("end", raw.rstrip("\r\n"))
try: try:
instructions = parse_instruction_text(raw) instructions = parse_instruction_text(raw)
@@ -120,6 +139,21 @@ class ManualResolutionPanel(ttk.Frame):
else: else:
if instructions: if instructions:
instruction = instructions[0] 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), for title, label in (("Source", instruction.old_label),
("Cible", instruction.new_label)): ("Cible", instruction.new_label)):
button = ttk.Button( button = ttk.Button(
@@ -132,12 +166,14 @@ class ManualResolutionPanel(ttk.Frame):
command=lambda item=instruction, root=path.parent: self.open_cut(root, item)) command=lambda item=instruction, root=path.parent: self.open_cut(root, item))
self.cut_buttons.append(button) self.cut_buttons.append(button)
self.text.window_create("end", window=button, padx=8) self.text.window_create("end", window=button, padx=8)
if populated:
self.text.insert("end", "\n# -> " + ", ".join(populated))
if raw.endswith("\n"): if raw.endswith("\n"):
self.text.insert("end", "\n") self.text.insert("end", "\n")
detail = (" — lignes invalides : " + ", ".join(malformed)) if malformed else ( detail = (" — lignes invalides : " + ", ".join(malformed)) if malformed else (
f" — {len(self.pdf_buttons) // 2} instruction(s)" 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: finally:
self.text.configure(state="disabled") 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 build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
def python(ident, label, script, **kwargs): def python(ident, label, script, **kwargs):
supports_verbose = kwargs.pop("supports_verbose", True) 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"),), (python("default", "Détection des labels", "labels"),),
arguments=( arguments=(
arg_target(), 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( ArgumentSpec(
"overwrite", "overwrite",
"Régénérer les résultats", "Régénérer les résultats",
@@ -411,6 +430,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
), ),
arguments=( arguments=(
arg_target("le dossier de l’évaluation ou une image Group_X.jpg"), arg_target("le dossier de l’évaluation ou une image Group_X.jpg"),
arg_correction_workers(("live", "hybrid", "refaire", "integrate")),
ArgumentSpec( ArgumentSpec(
"overwrite", "overwrite",
"Écraser les corrections existantes", "Écraser les corrections existantes",
@@ -507,6 +527,12 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
), ),
arguments=( arguments=(
arg_target("le dossier de l’évaluation"), 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( ArgumentSpec(
"overwrite", "overwrite",
"Écraser les sorties", "Écraser les sorties",
@@ -653,6 +679,17 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
), ),
artifacts=("A Rendre",), 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( StepDefinition(
"personal_create", "personal_create",
"Étapes personnelles", "Étapes personnelles",
@@ -796,6 +833,8 @@ def build_refaire_workflow() -> list[StepDefinition]:
steps = [selection] steps = [selection]
for suffix, title, description, program, flags, optional in definitions: for suffix, title, description, program, flags, optional in definitions:
arguments = (arg_target(),) if program else () arguments = (arg_target(),) if program else ()
if suffix == "correct":
arguments += (arg_correction_workers(),)
if suffix == "merge": if suffix == "merge":
arguments += (ArgumentSpec( arguments += (ArgumentSpec(
"annotation_dir", "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 os
import tempfile import tempfile
import unittest import unittest
from itertools import product
from pathlib import Path from pathlib import Path
from tkinter import ttk from tkinter import ttk
from unittest.mock import patch from unittest.mock import patch
@@ -51,6 +52,19 @@ class GuiConvenienceTests(unittest.TestCase):
start.assert_not_called() start.assert_not_called()
self.assertEqual(self.app.state_store.step(step.id)["status"], "success") 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): def test_cutleft_visual_check_option_is_below_and_off_by_default(self):
step = self.app.step_by_id["cutleft"] step = self.app.step_by_id["cutleft"]
self.app.current_step = step self.app.current_step = step
@@ -280,9 +294,11 @@ class GuiConvenienceTests(unittest.TestCase):
self.assertEqual(self.app.state_store.step("correction")["variant"], "integrate") self.assertEqual(self.app.state_store.step("correction")["variant"], "integrate")
self.assertIn("--deal-with-batched", self.app._make_command()) self.assertIn("--deal-with-batched", self.app._make_command())
def test_integration_preserves_batch_statuses_but_invalidates_downstream_steps(self): def test_correction_invalidates_batches_only_when_regenerating_or_resetting(self):
for variant in ("integrate", "batch"): for variant, action in product(
with self.subTest(variant=variant): ("integrate", "live", "refaire", "batch", "hybrid", "reset"), ("run", "mark")
):
with self.subTest(variant=variant, action=action):
self.app.active_step_id = None self.app.active_step_id = None
self.app.current_step = None self.app.current_step = None
self.app.state_store.update_step("correction", variant=variant, batch_started=True) 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.tree.selection_set("correction")
self.app.update() self.app.update()
with patch.object(self.app.runner, "start"), \ 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.app._run_current_step()
self.assertEqual(self.app.active_step_id, "correction") self.assertEqual(self.app.active_step_id, "correction")
else:
self.app._mark_step("success")
for step_id, status in statuses.items(): 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.assertEqual(self.app.state_store.step(step_id)["status"], expected)
self.app.active_step_id = None 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): def test_unsuccessful_batch_retrieval_stays_on_retrieval(self):
self.app.state_store.update_step("correction", variant="batch", last_run_variant="batch") self.app.state_store.update_step("correction", variant="batch", last_run_variant="batch")
self.app._populate_tree() self.app._populate_tree()
@@ -505,7 +543,7 @@ class GuiConvenienceTests(unittest.TestCase):
self.app._target_selected_copy() self.app._target_selected_copy()
command = self.app._make_command() 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): def button_texts(widget):
return [ return [
+92 -9
View File
@@ -1479,7 +1479,7 @@ class StandardCliTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
workspace = EvaluationWorkspace(Path(directory)) workspace = EvaluationWorkspace(Path(directory))
args = module.build_parser().parse_args( args = module.build_parser().parse_args(
[str(workspace.root), "--overwrite"] [str(workspace.root), "--overwrite", "--workers", "1"]
) )
module.configure_runtime( module.configure_runtime(
workspace, workspace,
@@ -1495,18 +1495,46 @@ class StandardCliTests(unittest.TestCase):
(workspace.root / "saved-result").write_text(task[0]) (workspace.root / "saved-result").write_text(task[0])
return [] 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 module, "process_single_task", side_effect=process
), patch.object( ), patch.object(
module, "resolve_delayed_moves", return_value=[] module, "resolve_delayed_moves", return_value=[]
): ):
status = module.run_configured(args) status = module.run_configured(args)
executor.assert_called_once_with(max_workers=1)
saved_result = (workspace.root / "saved-result").read_text() saved_result = (workspace.root / "saved-result").read_text()
self.assertEqual(status, module.ExitCode.INTERRUPTED) self.assertEqual(status, module.ExitCode.INTERRUPTED)
self.assertEqual(processed, ["group-1.jpg"]) self.assertEqual(processed, ["group-1.jpg"])
self.assertEqual(saved_result, "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: def test_gemini_call_finishes_inflight_stream_but_does_not_retry(self) -> None:
module = self.modules["correction"] module = self.modules["correction"]
self.addCleanup(module.stop_requested.clear) self.addCleanup(module.stop_requested.clear)
@@ -1562,7 +1590,7 @@ class StandardCliTests(unittest.TestCase):
api_client.models.generate_content_stream.side_effect = stream api_client.models.generate_content_stream.side_effect = stream
args = module.build_parser().parse_args([str(workspace.root)]) 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.prompting, "generate_request", return_value=([], Mock())
): ):
module.configure_runtime( module.configure_runtime(
@@ -1636,7 +1664,7 @@ class StandardCliTests(unittest.TestCase):
[str(workspace.root), "--overwrite"] [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()) module.prompting, "generate_request", return_value=([], Mock())
), patch.object( ), patch.object(
module, "handle_label_errors", side_effect=resolve_error module, "handle_label_errors", side_effect=resolve_error
@@ -2208,6 +2236,23 @@ class StandardCliTests(unittest.TestCase):
self.assertIn("Copie01", str(raised.exception)) self.assertIn("Copie01", str(raised.exception))
self.assertIn("cannot be split", 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: def test_grouped_annotation_height_limit_is_32000_pixels(self) -> None:
module = self.modules["annotating_by_label"] module = self.modules["annotating_by_label"]
self.assertEqual(module.MAX_HEIGHT_PX, 32000) 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) variant = next(item for item in step.variants if item.id == variant_id)
return build_command(REPOSITORY, step, variant, values, self.evaluation) 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: def test_personal_steps_are_configurable(self) -> None:
standard_ids = {step.id for step in build_workflow(False)} standard_ids = {step.id for step in build_workflow(False)}
personal_ids = {step.id for step in build_workflow(True)} 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 (02)": (b"two-jpg", b"two-pdf"),
"Dupont (03)": (b"three-jpg", b"three-pdf"), "Dupont (03)": (b"three-jpg", b"three-pdf"),
"Unique (04)": (b"four-jpg", b"four-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(): for folder_name, (jpg, pdf) in folders.items():
folder = return_dir / folder_name folder = return_dir / folder_name
@@ -2401,7 +2457,11 @@ class WorkflowTests(unittest.TestCase):
(folder / "answers").mkdir() (folder / "answers").mkdir()
issues = find_name_issues(evaluation) 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( self.assertEqual(
original_copy_path(evaluation, "01"), original_copy_path(evaluation, "01"),
original_dir / "Copie01.pdf", original_dir / "Copie01.pdf",
@@ -2415,7 +2475,7 @@ class WorkflowTests(unittest.TestCase):
self.assertTrue((renamed / "answers").is_dir()) self.assertTrue((renamed / "answers").is_dir())
self.assertEqual( self.assertEqual(
[issue.copy_id for issue in find_name_issues(evaluation)], [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: def test_export_and_import_defaults_follow_previous_runs(self) -> None:
@@ -2496,10 +2556,19 @@ class WorkflowTests(unittest.TestCase):
) )
self.assertEqual( self.assertEqual(
command_arguments(command), command_arguments(command),
[self.evaluation, "--overwrite", "--limit", "0"], [self.evaluation, "--workers", "12", "--overwrite", "--limit", "0"],
) )
self.assertEqual(command[4], "correct") 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: def test_refaire_correction_does_not_offer_overwrite(self) -> None:
step = self.steps["correction"] step = self.steps["correction"]
overwrite = next( overwrite = next(
@@ -2520,7 +2589,7 @@ class WorkflowTests(unittest.TestCase):
"correction", "hybrid", {"target": self.evaluation, "batch_from": "Ex 4"} "correction", "hybrid", {"target": self.evaluation, "batch_from": "Ex 4"}
) )
self.assertEqual( 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: def test_only_multi_target_steps_offer_free_form_arguments(self) -> None:
@@ -2624,7 +2693,8 @@ class WorkflowTests(unittest.TestCase):
self.evaluation, self.evaluation,
verbose=True, 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 = self.steps["rotate"]
rotate_command = build_command( rotate_command = build_command(
@@ -2657,6 +2727,19 @@ class WorkflowTests(unittest.TestCase):
) )
self.assertEqual(command[4], "annotate-grouped") 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: def test_copy_preparation_commands_are_python(self) -> None:
rotate = self.command("rotate", "rotate", {"target": self.evaluation}) rotate = self.command("rotate", "rotate", {"target": self.evaluation})
rename = self.command("rename", "rename", {"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 pathlib import Path
from unittest.mock import call, patch from unittest.mock import call, patch
from copienator.answer_origins import save_origins
from copienator_gui.manual_resolution import ManualResolutionPanel from copienator_gui.manual_resolution import ManualResolutionPanel
@@ -34,17 +35,27 @@ class ManualResolutionPanelTests(unittest.TestCase):
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
path.touch() path.touch()
paths.append(path) 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() panel = self.panel()
self.assertEqual(panel.text.get("1.0", "end-1c"), content) self.assertEqual(panel.text.get("1.0", "end-1c"), content)
self.assertEqual(len(panel.pdf_buttons), 4) self.assertEqual(len(panel.pdf_buttons), 4)
self.assertEqual([button.cget("text") for button in panel.pdf_buttons], self.assertEqual([button.cget("text") for button in panel.pdf_buttons],
["Source : Ex 1", "Cible : Ex 2", ["Source : Ex 1", "Cible : Ex 2",
"Source : Ex 3", "Cible : Ex 4"]) "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: with patch("copienator_gui.manual_resolution.open_path") as opened:
panel.editor_button.invoke() panel.editor_button.invoke()
for button in panel.pdf_buttons: for button in panel.pdf_buttons:
button.invoke() 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): def test_reload_updates_the_displayed_target_and_its_pdf_action(self):
self.path.write_text("Copie01 A -> B|\n", encoding="utf-8") self.path.write_text("Copie01 A -> B|\n", encoding="utf-8")
@@ -63,6 +74,34 @@ class ManualResolutionPanelTests(unittest.TestCase):
panel.pdf_buttons[1].invoke() panel.pdf_buttons[1].invoke()
opened.assert_called_once_with(copies / "C.pdf") 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): def test_reload_keeps_invalid_lines_visible_and_removes_stale_actions(self):
self.path.write_text("Copie01 A -> B|\n", encoding="utf-8") self.path.write_text("Copie01 A -> B|\n", encoding="utf-8")
panel = self.panel() panel = self.panel()
@@ -71,9 +110,11 @@ class ManualResolutionPanelTests(unittest.TestCase):
self.assertIn("bad instruction", panel.text.get("1.0", "end")) self.assertIn("bad instruction", panel.text.get("1.0", "end"))
self.assertIn("lignes invalides : 1", panel.status.cget("text")) self.assertIn("lignes invalides : 1", panel.status.cget("text"))
self.assertEqual(len(panel.pdf_buttons), 2) self.assertEqual(len(panel.pdf_buttons), 2)
self.assertEqual(len(panel.copy_buttons), 2)
self.path.unlink() self.path.unlink()
panel.reload() panel.reload()
self.assertFalse(panel.pdf_buttons) self.assertFalse(panel.pdf_buttons)
self.assertFalse(panel.copy_buttons)
self.assertEqual(str(panel.editor_button.cget("state")), "disabled") self.assertEqual(str(panel.editor_button.cget("state")), "disabled")
def test_missing_source_does_not_prevent_opening_destination(self): 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.app._populate_tree()
self.assertTrue(self.app.tree.item(section, "open")) 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): def test_dropdown_selection_saves_json_and_drives_all_commands(self):
self.save_selection() self.save_selection()
self.assertEqual( 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 io
import tempfile import tempfile
import unittest 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 contextlib import redirect_stdout
from pathlib import Path from pathlib import Path
from threading import Event from threading import Event
from unittest.mock import Mock, patch 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 from copienator.commands import gemini_for_labels as labels
@@ -36,6 +36,33 @@ class LabelTimeoutTests(unittest.TestCase):
sleep=lambda _: None, target_interval=0, 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): def test_timeout_recovery_uses_bounded_requests_and_saves_result(self):
self.client.models.generate_content.side_effect = [TimeoutError(), self.response] self.client.models.generate_content.side_effect = [TimeoutError(), self.response]
self.assertEqual(self.group(self.images[:1]), 1) self.assertEqual(self.group(self.images[:1]), 1)