Remove `Arguments supplémentaires' boxes

This commit is contained in:
2026-09-09 18:11:00 +02:00
parent 0a167072ed
commit f7b3689f23
8 changed files with 277 additions and 20 deletions
+2
View File
@@ -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)
+39 -5
View File
@@ -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 derreur (--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:
+83 -12
View File
@@ -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 dappels Pro", "int", "--limit", variants=("live",)),
ArgumentSpec(
"overwrite",
"Écraser les corrections existantes",
"bool",
"--overwrite",
variants=("live", "batch", "hybrid", "refaire"),
),
ArgumentSpec(
"limit",
"Limite dappels 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. Lidentifiant 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
+3
View File
@@ -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")
+1 -1
View File
@@ -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__":
+1 -1
View File
@@ -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__":
+32
View File
@@ -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")
+116 -1
View File
@@ -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):