diff --git a/copienator/configuration.py b/copienator/configuration.py index 7a07aff..04ba22f 100644 --- a/copienator/configuration.py +++ b/copienator/configuration.py @@ -31,6 +31,8 @@ if CONFIG_PATH is None: else: _configuration = _load_user_config(CONFIG_PATH) +# Keep new optional settings compatible with older personal configuration files. +ALWAYS_CROP = False for _name in dir(_configuration): if not _name.startswith("_"): globals()[_name] = getattr(_configuration, _name) diff --git a/copienator_gui/app.py b/copienator_gui/app.py index 2fd0dce..cc1d147 100644 --- a/copienator_gui/app.py +++ b/copienator_gui/app.py @@ -178,6 +178,7 @@ class CopienatorApp(tk.Tk): self.api_key_var = tk.StringVar(value=os.environ.get("GEMINI_API_KEY", "")) self.proxy_var = tk.StringVar(value=DEFAULT_HTTPS_PROXY) self.use_proxy_var = tk.BooleanVar(value=False) + self.verbose_var = tk.BooleanVar(value=False) self.copy_var = tk.StringVar() self.variant_var = tk.StringVar() self.extra_var = tk.StringVar() @@ -186,6 +187,7 @@ class CopienatorApp(tk.Tk): self._build_ui(show_personal_steps) self.extra_var.trace_add("write", lambda *_args: self._update_command_preview()) + self.verbose_var.trace_add("write", lambda *_args: self._update_command_preview()) self.after(60, self._poll_runner) if initial_evaluation and initial_evaluation.is_dir(): @@ -237,6 +239,11 @@ class CopienatorApp(tk.Tk): ).pack(side="left", padx=(0, 6)) self.proxy_entry = ttk.Entry(environment, textvariable=self.proxy_var, width=28, state="disabled") self.proxy_entry.pack(side="left") + ttk.Checkbutton( + environment, + text="Afficher les détails en cas d’erreur (--verbose)", + variable=self.verbose_var, + ).pack(side="left", padx=(10, 0)) profile = "standard + personnel" if show_personal_steps else "standard" ttk.Label(environment, text=f"Profil : {profile}").pack(side="left", padx=(12, 0)) @@ -672,12 +679,24 @@ class CopienatorApp(tk.Tk): row += 1 row += 1 - if not step.is_manual and step.section != REFAIRE_SECTION: + show_extra = bool(step.extra_arguments_help) and ( + not step.extra_arguments_variants + or variant.id in step.extra_arguments_variants + ) + if show_extra and not step.is_manual and step.section != REFAIRE_SECTION: ttk.Label(self.form, text="Arguments supplémentaires").grid( row=row, column=0, sticky="w", pady=(10, 4), padx=(0, 8) ) ttk.Entry(self.form, textvariable=self.extra_var).grid(row=row, column=1, sticky="ew", pady=(10, 4)) row += 1 + ttk.Label( + self.form, + text=step.extra_arguments_help, + foreground="#666666", + wraplength=540, + justify="left", + ).grid(row=row, column=1, sticky="w") + row += 1 if self.show_personal_steps and step.id == "statement": actions = ttk.LabelFrame(self.form, text="Après génération — facultatif", padding=7) @@ -953,7 +972,11 @@ class CopienatorApp(tk.Tk): self.current_step.id, variant=self.variant_var.get() or self.current_step.variants[0].id, values=merged_values, - extra=self.extra_var.get(), + extra=( + self.extra_var.get() + if self.current_step.extra_arguments_help + else "" + ), ) def _evaluation_arg(self) -> str: @@ -1020,21 +1043,32 @@ class CopienatorApp(tk.Tk): choice = self.state_store.step("refaire_selection").get("values", {}).get("layout", "auto") layout = resolve_layout(selection, EvaluationWorkspace(self.evaluation).read_labels(), choice) variant = replace(variant, program="annotate-grouped" if layout == "grouped" else "annotate-checks") - return [build_command(self.repository, self.current_step, variant, - {**values, "target": target}, self._evaluation_arg()) for target in targets] + return [build_command( + self.repository, + self.current_step, + variant, + {**values, "target": target}, + self._evaluation_arg(), + verbose=bool(self.verbose_var.get()), + ) for target in targets] def _make_command(self) -> list[str]: if not self.current_step: return [] if self.current_step.section == REFAIRE_SECTION: return self._refaire_commands()[0] + allow_extra = bool(self.current_step.extra_arguments_help) and ( + not self.current_step.extra_arguments_variants + or self._current_variant().id in self.current_step.extra_arguments_variants + ) return build_command( self.repository, self.current_step, self._current_variant(), self._values(), self._evaluation_arg(), - self.extra_var.get(), + self.extra_var.get() if allow_extra else "", + verbose=bool(self.verbose_var.get()), ) def _update_command_preview(self) -> None: diff --git a/copienator_gui/workflow.py b/copienator_gui/workflow.py index 9c9d677..3b24c05 100644 --- a/copienator_gui/workflow.py +++ b/copienator_gui/workflow.py @@ -7,6 +7,8 @@ from collections.abc import Iterable from dataclasses import dataclass from pathlib import Path +from copienator.configuration import ALWAYS_CROP + EVALUATION = "${evaluation}" @@ -33,6 +35,7 @@ class CommandVariant: fixed_args_before_positionals: bool = False dangerous: bool = False danger_warning: str | None = None + supports_verbose: bool = False @dataclass(frozen=True) @@ -50,6 +53,8 @@ class StepDefinition: auto_start_first_visit: bool = False skip_for_live_correction: bool = False skip_without_manual_conflicts: bool = False + extra_arguments_help: str = "" + extra_arguments_variants: tuple[str, ...] = () @property def is_manual(self) -> bool: @@ -68,9 +73,16 @@ def arg_target(help_text: str = "Dossier d’évaluation ou fichier à traiter") def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: - python = lambda ident, label, script, **kwargs: CommandVariant( - ident, label, script, "python", **kwargs - ) + def python(ident, label, script, **kwargs): + supports_verbose = kwargs.pop("supports_verbose", True) + return CommandVariant( + ident, + label, + script, + "python", + supports_verbose=supports_verbose, + **kwargs, + ) manual = lambda ident, label="Étape manuelle": CommandVariant( ident, label, None, "manual" ) @@ -111,7 +123,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: "statement_groups", "Prétraitement de l’énoncé", "Regrouper les questions avec Gemini", "Facultatif après la génération : remplace les groupes par exercice par des groupes " "proposés par Gemini, en conservant les labels, les énoncés, les solutions et les barèmes.", - (CommandVariant("default", "Groupes Gemini", "statement", fixed_args=("--groups-only",)),), + (python("default", "Groupes Gemini", "statement", fixed_args=("--groups-only",)),), arguments=(arg_target("Dossier de l’évaluation"),), optional=True, personal=True, requires=("labels", "Text2", "Sol2"), ), @@ -119,7 +131,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: "statement_persp", "Prétraitement de l’énoncé", "Remplacer les barèmes par Gemini", "Facultatif : remplace Persp par des barèmes Gemini sur 4 points, pour les groupes actuels. " "Les énoncés et les solutions personnels sont conservés.", - (CommandVariant("default", "Barèmes Gemini", "statement", fixed_args=("--persp-only",)),), + (python("default", "Barèmes Gemini", "statement", fixed_args=("--persp-only",)),), arguments=(arg_target("Dossier de l’évaluation"),), optional=True, personal=True, requires=("labels", "label_groups", "Text2", "Sol2"), ), @@ -145,6 +157,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: "python", ("rotate",), fixed_args_before_positionals=True, + supports_verbose=True, ), ), arguments=(arg_target("Dossier de l’évaluation"),), @@ -163,6 +176,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: "python", ("rename",), fixed_args_before_positionals=True, + supports_verbose=True, ), ), arguments=(arg_target("Dossier de l’évaluation"),), @@ -188,6 +202,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: ArgumentSpec("workers", "Copies traitées en parallèle", "int", "--workers", default=5)), optional=True, requires=("Copies",), + auto_start_first_visit=ALWAYS_CROP, ), StepDefinition( "cutleft", @@ -215,6 +230,10 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: ), requires=("labels", "Copies", "Cutleft"), artifacts=("Copies/*.json",), + extra_arguments_help=( + "PDF de copie ou images Cutleft supplémentaires de cette évaluation, " + "séparés par des espaces. Mettez entre guillemets les chemins contenant des espaces." + ), ), StepDefinition( "plotting", @@ -257,6 +276,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: ), optional=True, requires=("Copies/Copie*/*.pdf",), + auto_start_first_visit=ALWAYS_CROP, ), StepDefinition( "grouping", @@ -289,12 +309,29 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: ), arguments=( arg_target("Évaluation ou image Group_X.jpg"), - ArgumentSpec("overwrite", "Écraser les corrections existantes", "bool", "--overwrite", variants=("live",)), - ArgumentSpec("limit", "Limite d’appels Pro", "int", "--limit", variants=("live",)), + ArgumentSpec( + "overwrite", + "Écraser les corrections existantes", + "bool", + "--overwrite", + variants=("live", "batch", "hybrid", "refaire"), + ), + ArgumentSpec( + "limit", + "Limite d’appels Pro", + "int", + "--limit", + variants=("live", "hybrid", "refaire"), + ), ArgumentSpec("batch_from", "Premier label envoyé en batch", "text", "--batch-from", variants=("hybrid",)), ), requires=("Par label", "Persp", "labels"), artifacts=("correction.json", "batch_requests_*.jsonl"), + extra_arguments_help=( + "Images Group_X.jpg supplémentaires de cette évaluation, séparées par des espaces. " + "Mettez entre guillemets les chemins contenant des espaces." + ), + extra_arguments_variants=("live", "batch", "hybrid", "refaire"), ), StepDefinition( "submit_batches", @@ -313,7 +350,16 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: "Consulter l’état des batchs", "Affiche les jobs Gemini en cours. L’identifiant de téléchargement est facultatif.", (python("default", "État des batchs", "batch-status"),), - arguments=(ArgumentSpec("download", "Télécharger le job", "text", "--download"),), + arguments=( + ArgumentSpec("download", "Télécharger le job", "text", "--download"), + ArgumentSpec( + "output", + "Fichier JSONL de destination", + "path", + "--output", + help="Utilisé avec un identifiant de téléchargement.", + ), + ), optional=True, skip_for_live_correction=True, ), @@ -360,7 +406,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: arguments=( arg_target("Dossier de l’évaluation"), ArgumentSpec("overwrite", "Écraser les sorties", "bool", "--overwrite"), - ArgumentSpec("refaire", "Mode refaire", "bool", "--refaire", variants=("checks",)), + ArgumentSpec("refaire", "Mode refaire", "bool", "--refaire", variants=("checks", "grouped")), ), requires=("correction.json",), artifacts=("Anot", "Bnot", "BGnot"), @@ -424,6 +470,15 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: arg_target("Dossier de l’évaluation"), ArgumentSpec("update_score", "Réappliquer les score.json", "bool", "--update-score"), ArgumentSpec("refaire", "Mode refaire", "bool", "--refaire", variants=("grouped",)), + ArgumentSpec( + "annotation_dir", + "Passage principal du mode refaire", + "choice", + "--annotation-dir", + default="BGnot", + choices=("BGnot", "Bnot", "Anot"), + variants=("grouped",), + ), ), ), StepDefinition( @@ -466,7 +521,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: "Étapes personnelles", "Mettre à jour le fichier ODS", "Transfère les scores, avec la possibilité de n’écrire que leur somme.", - (python("default", "Mise à jour ODS", "update-ods"),), + (python("default", "Mise à jour ODS", "update-ods", supports_verbose=False),), arguments=( arg_target("Dossier de l’évaluation"), ArgumentSpec("sum", "Écrire seulement la somme", "bool", "--sum"), @@ -494,7 +549,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: "Étapes personnelles", "Ajouter le score final", "Génère les fichiers de diffusion avec le score final.", - (python("default", "Score final", "add-final-score"),), + (python("default", "Score final", "add-final-score", supports_verbose=False),), arguments=(arg_target("Dossier de l’évaluation"),), personal=True, ), @@ -540,6 +595,12 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: "Continuer ?" ), ), + python( + "dry_run", + "Prévisualiser sans supprimer", + "clean", + fixed_args=("--dry-run",), + ), ), arguments=(arg_target("Dossier de l’évaluation"),), optional=True, @@ -580,7 +641,14 @@ def build_refaire_workflow() -> list[StepDefinition]: requirements += ("BRnot",) steps.append(StepDefinition( f"refaire_{suffix}", SECTION, title, description, - (CommandVariant("default", title, program, "python" if program else "manual", flags),), + (CommandVariant( + "default", + title, + program, + "python" if program else "manual", + flags, + supports_verbose=program is not None, + ),), arguments=arguments, optional=optional, requires=requirements, )) return steps @@ -597,6 +665,7 @@ def build_command( values: dict[str, object], evaluation_arg: str, extra_arguments: str = "", + verbose: bool = False, ) -> list[str]: if variant.kind == "manual" or not variant.program: return [] @@ -633,6 +702,8 @@ def build_command( if not variant.fixed_args_before_positionals: command.extend(variant.fixed_args) command.extend(options) + if verbose and variant.supports_verbose: + command.append("--verbose") if extra_arguments.strip(): command.extend(shlex.split(extra_arguments, posix=os.name != "nt")) return command diff --git a/default_config.py b/default_config.py index e337946..02e21ac 100644 --- a/default_config.py +++ b/default_config.py @@ -9,6 +9,9 @@ IMPORT_DIR = Path("Import") # Les étapes gestion_classe, ODS et publication sont masquées par défaut. SHOW_PERSONAL_STEPS = False +# Lance automatiquement les deux étapes facultatives de rognage dans le GUI. +ALWAYS_CROP = False + # Chemins utilisés uniquement par les étapes personnelles. CURRENT_SCORE_ODS_PATH = Path("current_eval.ods") FINAL_SCORE_ODS_PATH = Path("simple_eval.ods") diff --git a/tests/test_crop_exercise_bottoms_command.py b/tests/test_crop_exercise_bottoms_command.py index de33a7c..ec94b02 100644 --- a/tests/test_crop_exercise_bottoms_command.py +++ b/tests/test_crop_exercise_bottoms_command.py @@ -116,7 +116,7 @@ class CropExerciseBottomsCommandTests(unittest.TestCase): self.assertEqual(steps[index - 1].id, "splitting") self.assertEqual(steps[index + 1].id, "grouping") self.assertTrue(steps[index].optional) - self.assertFalse(steps[index].auto_start_first_visit) + self.assertTrue(steps[index].auto_start_first_visit) if __name__ == "__main__": diff --git a/tests/test_crop_margins_command.py b/tests/test_crop_margins_command.py index 1a8ef6f..0bd424f 100644 --- a/tests/test_crop_margins_command.py +++ b/tests/test_crop_margins_command.py @@ -182,7 +182,7 @@ class CropMarginsCommandTests(unittest.TestCase): self.assertEqual(steps[index-1].id, "page_splitter") self.assertEqual(steps[index+1].id, "cutleft") self.assertTrue(steps[index].optional) - self.assertFalse(steps[index].auto_start_first_visit) + self.assertTrue(steps[index].auto_start_first_visit) if __name__ == "__main__": diff --git a/tests/test_gui_convenience.py b/tests/test_gui_convenience.py index e69fc91..cb5145c 100644 --- a/tests/test_gui_convenience.py +++ b/tests/test_gui_convenience.py @@ -27,6 +27,18 @@ class GuiConvenienceTests(unittest.TestCase): self.app.destroy() self.temp.cleanup() + @staticmethod + def _label_texts(widget): + return [ + text + for child in widget.winfo_children() + for text in ( + [str(child.cget("text"))] + if isinstance(child, ttk.Label) + else GuiConvenienceTests._label_texts(child) + ) + ] + def test_reload_detects_added_and_removed_files_without_advancing(self): for name in ("enonce.pdf", "enonce.tex", "correction.tex"): (self.evaluation / name).touch() @@ -57,6 +69,7 @@ class GuiConvenienceTests(unittest.TestCase): copies.mkdir() source = copies/"Copie01.pdf" source.touch() + self.app.state_store.update_step("crop_blank_margins", visited=True) self.app.tree.selection_set("crop_blank_margins") self.app.update() self.assertEqual(self.app.current_step.id, "crop_blank_margins") @@ -72,6 +85,7 @@ class GuiConvenienceTests(unittest.TestCase): answers = self.evaluation / "Copies" / "Copie01" answers.mkdir(parents=True) (answers / "Ex 1.pdf").touch() + self.app.state_store.update_step("crop_exercise_bottoms", visited=True) self.app.tree.selection_set("crop_exercise_bottoms") self.app.update() self.assertEqual(self.app.current_step.id, "crop_exercise_bottoms") @@ -135,6 +149,24 @@ class GuiConvenienceTests(unittest.TestCase): self.assertIn("Cibler la copie sélectionnée", controls) self.assertIn("Cibler tout le dossier", controls) + def test_free_form_arguments_are_shown_only_when_documented(self): + self.app.tree.selection_set("labels") + self.app.update() + labels_text = self._label_texts(self.app.form) + self.assertIn("Arguments supplémentaires", labels_text) + self.assertTrue(any("Cutleft" in text for text in labels_text)) + + self.app.tree.selection_set("plotting") + self.app.update() + self.assertNotIn("Arguments supplémentaires", self._label_texts(self.app.form)) + + def test_verbose_checkbox_updates_supported_commands(self): + self.app.tree.selection_set("labels") + self.app.update() + self.assertNotIn("--verbose", self.app._make_command()) + self.app.verbose_var.set(True) + self.assertIn("--verbose", self.app._make_command()) + def test_console_selection_survives_output_and_is_read_only(self): self.app._append_console("Première ligne\nDeuxième ligne\n") self.app.console.tag_add("sel", "1.0", "1.end") diff --git a/tests/test_gui_core.py b/tests/test_gui_core.py index 26fc099..91f8ba6 100644 --- a/tests/test_gui_core.py +++ b/tests/test_gui_core.py @@ -1674,12 +1674,37 @@ class WorkflowTests(unittest.TestCase): if step.skip_without_manual_conflicts } - self.assertEqual(auto_start, {"splitting", "grouping"}) + self.assertEqual( + auto_start, + {"crop_blank_margins", "splitting", "crop_exercise_bottoms", "grouping"}, + ) self.assertEqual( skip_for_live, {"submit_batches", "batch_status", "fetch_batches"} ) self.assertEqual(skip_without_conflicts, {"manual_resolution"}) + def test_crop_auto_start_follows_always_crop_configuration(self) -> None: + for enabled in (False, True): + with self.subTest(enabled=enabled), patch( + "copienator_gui.workflow.ALWAYS_CROP", enabled + ): + steps = {step.id: step for step in build_workflow(False)} + self.assertEqual( + steps["crop_blank_margins"].auto_start_first_visit, enabled + ) + self.assertEqual( + steps["crop_exercise_bottoms"].auto_start_first_visit, enabled + ) + self.assertTrue(steps["crop_blank_margins"].optional) + self.assertTrue(steps["crop_exercise_bottoms"].optional) + + def test_default_and_repository_crop_configuration(self) -> None: + import config + import default_config + + self.assertFalse(default_config.ALWAYS_CROP) + self.assertTrue(config.ALWAYS_CROP) + def test_review_persp_has_shorter_title(self) -> None: self.assertEqual(self.steps["review_persp"].title, "Relire les barèmes") @@ -1790,6 +1815,82 @@ class WorkflowTests(unittest.TestCase): command_arguments(command), [self.evaluation, "--batch-from", "Ex 4"] ) + def test_only_multi_target_steps_offer_free_form_arguments(self) -> None: + documented = { + step.id: step + for step in self.steps.values() + if step.extra_arguments_help + } + self.assertEqual(set(documented), {"labels", "correction"}) + self.assertIn("Cutleft", documented["labels"].extra_arguments_help) + self.assertIn("Group_X.jpg", documented["correction"].extra_arguments_help) + self.assertEqual( + documented["correction"].extra_arguments_variants, + ("live", "batch", "hybrid", "refaire"), + ) + + def test_options_previously_requiring_free_form_arguments_have_controls(self) -> None: + status = self.command( + "batch_status", + "default", + {"download": "files/job-1", "output": "/tmp/result.jsonl"}, + ) + self.assertEqual( + command_arguments(status), + ["--download", "files/job-1", "--output", "/tmp/result.jsonl"], + ) + + grouped = self.command( + "read_annotations", + "grouped", + { + "target": self.evaluation, + "refaire": True, + "annotation_dir": "Bnot", + }, + ) + self.assertEqual( + command_arguments(grouped), + [self.evaluation, "--refaire", "--annotation-dir", "Bnot"], + ) + + def test_verbose_is_added_only_to_commands_that_support_it(self) -> None: + labels = self.steps["labels"] + labels_command = build_command( + REPOSITORY, + labels, + labels.variants[0], + {"target": self.evaluation}, + self.evaluation, + verbose=True, + ) + self.assertEqual(command_arguments(labels_command), [self.evaluation, "--verbose"]) + + rotate = self.steps["rotate"] + rotate_command = build_command( + REPOSITORY, + rotate, + rotate.variants[0], + {"target": self.evaluation}, + self.evaluation, + verbose=True, + ) + self.assertEqual( + command_arguments(rotate_command), + ["rotate", self.evaluation, "--verbose"], + ) + + update = self.steps["update_ods"] + update_command = build_command( + REPOSITORY, + update, + update.variants[0], + {"target": self.evaluation}, + self.evaluation, + verbose=True, + ) + self.assertNotIn("--verbose", update_command) + def test_annotation_variants_are_exclusive_commands(self) -> None: command = self.command( "annotation", "grouped", {"target": self.evaluation, "overwrite": True} @@ -1815,6 +1916,20 @@ class WorkflowTests(unittest.TestCase): ), [self.evaluation, "--yes"], ) + preview = next(variant for variant in clean.variants if variant.id == "dry_run") + self.assertFalse(preview.dangerous) + self.assertEqual( + command_arguments( + build_command( + REPOSITORY, + clean, + preview, + {"target": self.evaluation}, + self.evaluation, + ) + ), + [self.evaluation, "--dry-run"], + ) class CrossPlatformFileTests(unittest.TestCase):