diff --git a/Readme.org b/Readme.org index cfada12..1aeb92e 100644 --- a/Readme.org +++ b/Readme.org @@ -1,7 +1,7 @@ #+title: Script #+author: Sébastien Miquel #+date: 14-03-2026 -# Time-stamp: <22-08-26 09:58> +# Time-stamp: <22-08-26 12:05> #+OPTIONS: * Méta @@ -343,8 +343,6 @@ Optional : Set proxy with ~export HTTPS_PROXY="http://10.0.0.1:3128"~ (chères), pour une version low cost, passer =--limit 0=, toutes les requêtes seront sur Gemini Flash. - Will it resume ? It seems so. Best to wait a bit. - Pour diminuer le coût, il est possible de batch les requêtes, qui seront alors traitées sous au plus 24h. + =python correction.py Interro --batch= @@ -414,10 +412,16 @@ Lors d'une régénération, les nouvelles sorties sont préparées dans un dossier temporaire voisin. Une erreur de rendu conserve donc la sortie précédente ; pour =BGnot --overwrite=, le dossier complet n'est remplacé que si tous les groupes ont été produits. - 3. =python export.py Interro= (gestion perso) + 3. =python export.py Interro BGnot= (gestion perso) Cela déplace les groupes dans le dossier configuré par =EXPORT_DIR= (par défaut =Export=). + Le second argument peut être =BGnot=, =Bnot= ou =Anot= et reste + facultatif (=BGnot= par défaut). =Anot= exporte =Concat.jpg= ; les + deux autres modes exportent =Concat.pdf=. Dans le GUI, seuls les + dossiers présents sont proposés et le mode de la dernière génération + d'annotations est présélectionné. + Il faut ensuite annoter les fichiers dans `EXPORT_DIR` avec une tablette graphique. @@ -426,12 +430,17 @@ tablette graphique. _Before_ : vider le dossier configuré par =IMPORT_DIR= (par défaut =Import=), puis y copier ou synchroniser les fichiers depuis la tablette. - 1. =python import.py Interro= + 1. =python import.py Interro BGnot= Une fois les corrections manuelles appliquées aux fichiers =Concat.pdf=, il faut enregistrer le fichier annoté au même endroit, sous le nom =Concat_annotated.pdf=. + Comme pour l'export, le second argument accepte =BGnot=, =Bnot= ou + =Anot=. Le GUI présélectionne le dossier utilisé lors du dernier + export. Pour =Anot=, l'image est importée sous le nom + =Concat_annotated.jpg=. + 2. =python reading_annotations.py Interro= Lit les =Concat_annotated= dans =Bnot=, regénère les copies avec diff --git a/copienator_gui/app.py b/copienator_gui/app.py index be1ed66..1ab4b0d 100644 --- a/copienator_gui/app.py +++ b/copienator_gui/app.py @@ -36,6 +36,12 @@ STATUS_LABELS = { } DEFAULT_HTTPS_PROXY = "http://10.0.0.1:3128" +ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot") +ANNOTATION_VARIANT_DIRECTORIES = { + "simple": "Anot", + "checks": "Bnot", + "grouped": "BGnot", +} def copy_pdf_paths(evaluation: Path) -> list[Path]: @@ -57,6 +63,12 @@ def copy_pdf_paths(evaluation: Path) -> list[Path]: return [] +def detected_annotation_directories(evaluation: Path) -> tuple[str, ...]: + return tuple( + name for name in ANNOTATION_DIRECTORIES if (evaluation / name).is_dir() + ) + + def plotting_shortcut_lines() -> list[str]: try: from config import PLOTTING_KB @@ -530,6 +542,9 @@ class CopienatorApp(tk.Tk): if spec.variants and variant.id not in spec.variants: continue value = values.get(spec.name, value_for_default(spec.default, evaluation_arg)) + choices = spec.choices + if spec.name == "annotation_dir" and step.id in {"export", "import"}: + choices, value = self._annotation_directory_choices(step.id) ttk.Label(self.form, text=spec.label).grid(row=row, column=0, sticky="nw", pady=4, padx=(0, 8)) if spec.kind == "bool": variable: tk.Variable = tk.BooleanVar(value=bool(value)) @@ -537,7 +552,7 @@ class CopienatorApp(tk.Tk): widget.grid(row=row, column=1, sticky="w", pady=4) elif spec.kind == "choice": variable = tk.StringVar(value=str(value)) - widget = ttk.Combobox(self.form, textvariable=variable, values=spec.choices, state="readonly") + widget = ttk.Combobox(self.form, textvariable=variable, values=choices, state="readonly") widget.grid(row=row, column=1, sticky="ew", pady=4) else: variable = tk.StringVar(value=str(value)) @@ -626,6 +641,25 @@ class CopienatorApp(tk.Tk): row += 1 return row + def _annotation_directory_choices(self, step_id: str) -> tuple[tuple[str, ...], str]: + evaluation = self.evaluation + detected = detected_annotation_directories(evaluation) if evaluation else () + choices = detected or ANNOTATION_DIRECTORIES + + if step_id == "export": + annotation = self.state_store.step("annotation") + variant = annotation.get("last_run_variant", annotation.get("variant", "grouped")) + preferred = ANNOTATION_VARIANT_DIRECTORIES.get(str(variant), "BGnot") + else: + export = self.state_store.step("export") + last_values = export.get("last_run_values", export.get("values", {})) + preferred = ( + str(last_values.get("annotation_dir", "BGnot")) + if isinstance(last_values, dict) + else "BGnot" + ) + return choices, preferred if preferred in choices else choices[0] + def _open_persp(self) -> None: evaluation = self.evaluation self._open_desktop_path(evaluation / "Persp" if evaluation else None, "dossier Persp") @@ -775,9 +809,16 @@ class CopienatorApp(tk.Tk): return self._save_current_form() + run_values = self._values() ordered_ids = [item.id for item in self.steps] self.state_store.invalidate_after(ordered_ids, step.id) - self.state_store.update_step(step.id, status="running", command=command_display(command)) + self.state_store.update_step( + step.id, + status="running", + command=command_display(command), + last_run_variant=variant.id, + last_run_values=run_values, + ) self.active_step_id = step.id workspace = self.state_store.workspace assert workspace is not None diff --git a/copienator_gui/workflow.py b/copienator_gui/workflow.py index bfa9b81..8c979ce 100644 --- a/copienator_gui/workflow.py +++ b/copienator_gui/workflow.py @@ -326,11 +326,19 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: StepDefinition( "export", "Génération des annotations", - "Exporter vers la tablette", - "Exporte les groupes vers le dossier EXPORT_DIR défini dans config.py.", + "Exporter", + "Exporte les annotations vers le dossier EXPORT_DIR défini dans config.py.", (python("default", "Export", "export.py"),), arguments=( arg_target("Dossier de l’évaluation"), + ArgumentSpec( + "annotation_dir", + "Dossier d’annotations", + "choice", + default="BGnot", + choices=("BGnot", "Bnot", "Anot"), + positional=True, + ), ArgumentSpec("refaire", "Mode refaire", "bool", "--refaire"), ), optional=True, @@ -350,6 +358,14 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: (python("default", "Import", "import.py"),), arguments=( arg_target("Dossier de l’évaluation"), + ArgumentSpec( + "annotation_dir", + "Dossier d’annotations", + "choice", + default="BGnot", + choices=("BGnot", "Bnot", "Anot"), + positional=True, + ), ArgumentSpec("refaire", "Mode refaire", "bool", "--refaire"), ), ), diff --git a/export.py b/export.py index 25d5059..77e8430 100644 --- a/export.py +++ b/export.py @@ -13,6 +13,8 @@ from copienator import ( ) from platform_utils import replace_with_link_or_copy +ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot") + def export_directory( workspace: EvaluationWorkspace, @@ -31,12 +33,22 @@ def export_directory( missing_outputs = 0 for subdir in subdirs: - concat_file = subdir / "Concat.pdf" - if not concat_file.is_file(): - print(f"Warning: file not found: {concat_file}", file=sys.stderr) + concat_file = next( + ( + candidate + for candidate in (subdir / "Concat.pdf", subdir / "Concat.jpg") + if candidate.is_file() + ), + None, + ) + if concat_file is None: + print( + f"Warning: no Concat.pdf or Concat.jpg found in {subdir}", + file=sys.stderr, + ) missing_outputs += 1 continue - destination = sync_dir / f"{subdir.name}.pdf" + destination = sync_dir / f"{subdir.name}{concat_file.suffix.lower()}" method = replace_with_link_or_copy(concat_file, destination, prefer="hardlink") print(f"Exported: {destination} ({method})") return ExitCode.PARTIAL if missing_outputs else ExitCode.SUCCESS @@ -44,12 +56,24 @@ def export_directory( def build_parser() -> argparse.ArgumentParser: parser = evaluation_parser("Export annotated PDFs to the tablet directory.") + parser.add_argument( + "annotation_dir", + nargs="?", + choices=ANNOTATION_DIRECTORIES, + default="BGnot", + help="Annotation directory to export (default: BGnot)", + ) parser.add_argument("--refaire", action="store_true", help="Process only copies/labels defined in refaire.json") return parser -def run(workspace: EvaluationWorkspace, *, refaire: bool = False) -> ExitCode: - return export_directory(workspace, "BRnot" if refaire else "BGnot") +def run( + workspace: EvaluationWorkspace, + *, + annotation_dir: str = "BGnot", + refaire: bool = False, +) -> ExitCode: + return export_directory(workspace, "BRnot" if refaire else annotation_dir) def main(argv: Sequence[str] | None = None) -> int: @@ -57,7 +81,11 @@ def main(argv: Sequence[str] | None = None) -> int: return execute( parser, argv, - lambda args: run(workspace_from_args(args), refaire=args.refaire), + lambda args: run( + workspace_from_args(args), + annotation_dir=args.annotation_dir, + refaire=args.refaire, + ), ) diff --git a/import.py b/import.py index 8827562..2261b03 100644 --- a/import.py +++ b/import.py @@ -13,43 +13,69 @@ from copienator import ( workspace_from_args, ) +ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot") + def sync_annotated( workspace: EvaluationWorkspace, *, - refaire: bool = False, + annotation_dir_name: str, import_dir: Path, ) -> ExitCode: - annotation_name = "BRnot" if refaire else "BGnot" - workspace.require_directories(annotation_name) - annotation_dir = workspace.root / annotation_name + workspace.require_directories(annotation_dir_name) + annotation_dir = workspace.root / annotation_dir_name annotated_dir = Path(import_dir).expanduser() if not annotated_dir.is_dir(): print(f"Error: directory does not exist: {annotated_dir}", file=sys.stderr) return ExitCode.INVALID_WORKSPACE missing_targets = 0 - for pdf_file in annotated_dir.glob("*.pdf"): - target_subdir = annotation_dir / pdf_file.stem + annotated_files = sorted( + ( + path + for path in annotated_dir.iterdir() + if path.is_file() and path.suffix.casefold() in {".pdf", ".jpg", ".jpeg"} + ), + key=lambda path: path.name.casefold(), + ) + for annotated_file in annotated_files: + target_subdir = annotation_dir / annotated_file.stem if not target_subdir.is_dir(): print(f"Warning: directory not found: {target_subdir}", file=sys.stderr) missing_targets += 1 else: - dest_file = target_subdir / "Concat_annotated.pdf" - print(f"Copying {pdf_file} to {dest_file}") - shutil.copy2(pdf_file, dest_file) + suffix = annotated_file.suffix.lower() + dest_file = target_subdir / f"Concat_annotated{suffix}" + print(f"Copying {annotated_file} to {dest_file}") + shutil.copy2(annotated_file, dest_file) return ExitCode.PARTIAL if missing_targets else ExitCode.SUCCESS def build_parser() -> argparse.ArgumentParser: parser = evaluation_parser("Import handwritten annotations from the tablet directory.") + parser.add_argument( + "annotation_dir", + nargs="?", + choices=ANNOTATION_DIRECTORIES, + default="BGnot", + help="Annotation directory receiving imported PDFs (default: BGnot)", + ) parser.add_argument("--refaire", action="store_true", help="Process only copies/labels defined in refaire.json") return parser -def run(workspace: EvaluationWorkspace, *, refaire: bool = False) -> ExitCode: - return sync_annotated(workspace, refaire=refaire, import_dir=Path(IMPORT_DIR)) +def run( + workspace: EvaluationWorkspace, + *, + annotation_dir: str = "BGnot", + refaire: bool = False, +) -> ExitCode: + return sync_annotated( + workspace, + annotation_dir_name="BRnot" if refaire else annotation_dir, + import_dir=Path(IMPORT_DIR), + ) def main(argv: Sequence[str] | None = None) -> int: @@ -57,7 +83,11 @@ def main(argv: Sequence[str] | None = None) -> int: return execute( parser, argv, - lambda args: run(workspace_from_args(args), refaire=args.refaire), + lambda args: run( + workspace_from_args(args), + annotation_dir=args.annotation_dir, + refaire=args.refaire, + ), ) diff --git a/tests/test_gui_core.py b/tests/test_gui_core.py index 26e69ce..5ee363f 100644 --- a/tests/test_gui_core.py +++ b/tests/test_gui_core.py @@ -32,9 +32,11 @@ from copienator.annotation_actions import apply_checkbox_actions, apply_score_ov from copienator.annotation_data import AnnotationLoadResult, load_annotation_data from copienator.filesystem import staged_directory, staged_files from copienator_gui.app import ( + CopienatorApp, DEFAULT_HTTPS_PROXY, build_runner_environment, copy_pdf_paths, + detected_annotation_directories, has_manual_conflicts, plotting_shortcut_lines, process_status, @@ -453,8 +455,16 @@ class StandardCliTests(unittest.TestCase): "personal", {"target": evaluation}, ), - "export": ("export", "default", {"target": evaluation, "refaire": True}), - "import": ("import", "default", {"target": evaluation, "refaire": True}), + "export": ( + "export", + "default", + {"target": evaluation, "annotation_dir": "Bnot", "refaire": True}, + ), + "import": ( + "import", + "default", + {"target": evaluation, "annotation_dir": "Anot", "refaire": True}, + ), "giving_names": ( "giving_names", "default", @@ -596,6 +606,23 @@ class StandardCliTests(unittest.TestCase): exported = base / "Export" / "Exam" / "Ex 1.pdf" self.assertEqual(exported.read_bytes(), b"annotated") + def test_export_accepts_each_annotation_directory(self) -> None: + module = self.modules["export"] + for annotation_dir in ("Anot", "Bnot"): + with self.subTest(annotation_dir=annotation_dir), tempfile.TemporaryDirectory() as directory: + base = Path(directory) + evaluation = base / "Exam" + source = evaluation / annotation_dir / "Copie01" + source.mkdir(parents=True) + suffix = ".jpg" if annotation_dir == "Anot" else ".pdf" + (source / f"Concat{suffix}").write_bytes(annotation_dir.encode()) + with patch.object(module, "EXPORT_DIR", base / "Export"): + self.assertEqual(module.main([str(evaluation), annotation_dir]), 0) + self.assertEqual( + (base / "Export" / "Exam" / f"Copie01{suffix}").read_bytes(), + annotation_dir.encode(), + ) + def test_import_main_copies_handwritten_annotations(self) -> None: module = self.modules["import"] with tempfile.TemporaryDirectory() as directory: @@ -612,6 +639,24 @@ class StandardCliTests(unittest.TestCase): (target / "Concat_annotated.pdf").read_bytes(), b"handwritten" ) + def test_import_accepts_each_annotation_directory(self) -> None: + module = self.modules["import"] + for annotation_dir in ("Anot", "Bnot"): + with self.subTest(annotation_dir=annotation_dir), tempfile.TemporaryDirectory() as directory: + base = Path(directory) + evaluation = base / "Exam" + target = evaluation / annotation_dir / "Copie01" + target.mkdir(parents=True) + import_dir = base / "Import" + import_dir.mkdir() + suffix = ".jpg" if annotation_dir == "Anot" else ".pdf" + (import_dir / f"Copie01{suffix}").write_bytes(b"handwritten") + with patch.object(module, "IMPORT_DIR", import_dir): + self.assertEqual(module.main([str(evaluation), annotation_dir]), 0) + self.assertEqual( + (target / f"Concat_annotated{suffix}").read_bytes(), b"handwritten" + ) + def test_giving_names_main_builds_return_directory(self) -> None: module = self.modules["giving_names"] with tempfile.TemporaryDirectory() as directory: @@ -1406,6 +1451,15 @@ class WorkflowTests(unittest.TestCase): def test_review_persp_has_shorter_title(self) -> None: self.assertEqual(self.steps["review_persp"].title, "Relire les barèmes") + def test_export_has_shorter_title_and_annotation_argument(self) -> None: + self.assertEqual(self.steps["export"].title, "Exporter") + command = self.command( + "export", + "default", + {"target": self.evaluation, "annotation_dir": "Bnot"}, + ) + self.assertEqual(command[3:], [self.evaluation, "Bnot"]) + def test_copy_listing_prefers_processed_copies(self) -> None: with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) @@ -1421,6 +1475,40 @@ class WorkflowTests(unittest.TestCase): ["Copie01.pdf", "Copie02.pdf"], ) + def test_annotation_directory_detection_uses_known_directories(self) -> None: + with tempfile.TemporaryDirectory() as directory: + evaluation = Path(directory) + (evaluation / "Bnot").mkdir() + (evaluation / "Anot").mkdir() + (evaluation / "Other").mkdir() + self.assertEqual( + detected_annotation_directories(evaluation), ("Bnot", "Anot") + ) + + def test_export_and_import_defaults_follow_previous_runs(self) -> None: + with tempfile.TemporaryDirectory() as directory: + evaluation = Path(directory) + (evaluation / "Bnot").mkdir() + (evaluation / "Anot").mkdir() + store = StateStore() + store.load(evaluation) + store.update_step("annotation", last_run_variant="simple") + app = SimpleNamespace(evaluation=evaluation, state_store=store) + + choices, default = CopienatorApp._annotation_directory_choices( + app, "export" + ) + self.assertEqual(choices, ("Bnot", "Anot")) + self.assertEqual(default, "Anot") + + store.update_step( + "export", last_run_values={"annotation_dir": "Bnot"} + ) + _choices, default = CopienatorApp._annotation_directory_choices( + app, "import" + ) + self.assertEqual(default, "Bnot") + def test_plotting_shortcuts_describe_open_actions(self) -> None: rendered = "\n".join(plotting_shortcut_lines()) self.assertIn("ouvrir l’énoncé", rendered)