extension de export/import aux autres modes de génération

This commit is contained in:
2026-08-22 12:14:20 +02:00
parent 9af05f2c86
commit 92a9f9883e
6 changed files with 242 additions and 30 deletions
+14 -5
View File
@@ -1,7 +1,7 @@
#+title: Script #+title: Script
#+author: Sébastien Miquel #+author: Sébastien Miquel
#+date: 14-03-2026 #+date: 14-03-2026
# Time-stamp: <22-08-26 09:58> # Time-stamp: <22-08-26 12:05>
#+OPTIONS: #+OPTIONS:
* Méta * 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 (chères), pour une version low cost, passer =--limit 0=, toutes
les requêtes seront sur Gemini Flash. 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 Pour diminuer le coût, il est possible de batch les requêtes, qui
seront alors traitées sous au plus 24h. seront alors traitées sous au plus 24h.
+ =python correction.py Interro --batch= + =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 dossier temporaire voisin. Une erreur de rendu conserve donc la sortie
précédente ; pour =BGnot --overwrite=, le dossier complet n'est remplacé précédente ; pour =BGnot --overwrite=, le dossier complet n'est remplacé
que si tous les groupes ont été produits. 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= Cela déplace les groupes dans le dossier configuré par =EXPORT_DIR=
(par défaut =Export=). (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 Il faut ensuite annoter les fichiers dans `EXPORT_DIR` avec une
tablette graphique. tablette graphique.
@@ -426,12 +430,17 @@ tablette graphique.
_Before_ : vider le dossier configuré par =IMPORT_DIR= (par défaut _Before_ : vider le dossier configuré par =IMPORT_DIR= (par défaut
=Import=), puis y copier ou synchroniser les fichiers depuis la tablette. =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 Une fois les corrections manuelles appliquées aux fichiers
=Concat.pdf=, il faut enregistrer le fichier annoté au même endroit, =Concat.pdf=, il faut enregistrer le fichier annoté au même endroit,
sous le nom =Concat_annotated.pdf=. 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= 2. =python reading_annotations.py Interro=
Lit les =Concat_annotated= dans =Bnot=, regénère les copies avec Lit les =Concat_annotated= dans =Bnot=, regénère les copies avec
+43 -2
View File
@@ -36,6 +36,12 @@ STATUS_LABELS = {
} }
DEFAULT_HTTPS_PROXY = "http://10.0.0.1:3128" 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]: def copy_pdf_paths(evaluation: Path) -> list[Path]:
@@ -57,6 +63,12 @@ def copy_pdf_paths(evaluation: Path) -> list[Path]:
return [] 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]: def plotting_shortcut_lines() -> list[str]:
try: try:
from config import PLOTTING_KB from config import PLOTTING_KB
@@ -530,6 +542,9 @@ class CopienatorApp(tk.Tk):
if spec.variants and variant.id not in spec.variants: if spec.variants and variant.id not in spec.variants:
continue continue
value = values.get(spec.name, value_for_default(spec.default, evaluation_arg)) 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)) ttk.Label(self.form, text=spec.label).grid(row=row, column=0, sticky="nw", pady=4, padx=(0, 8))
if spec.kind == "bool": if spec.kind == "bool":
variable: tk.Variable = tk.BooleanVar(value=bool(value)) 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) widget.grid(row=row, column=1, sticky="w", pady=4)
elif spec.kind == "choice": elif spec.kind == "choice":
variable = tk.StringVar(value=str(value)) 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) widget.grid(row=row, column=1, sticky="ew", pady=4)
else: else:
variable = tk.StringVar(value=str(value)) variable = tk.StringVar(value=str(value))
@@ -626,6 +641,25 @@ class CopienatorApp(tk.Tk):
row += 1 row += 1
return row 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: def _open_persp(self) -> None:
evaluation = self.evaluation evaluation = self.evaluation
self._open_desktop_path(evaluation / "Persp" if evaluation else None, "dossier Persp") self._open_desktop_path(evaluation / "Persp" if evaluation else None, "dossier Persp")
@@ -775,9 +809,16 @@ class CopienatorApp(tk.Tk):
return return
self._save_current_form() self._save_current_form()
run_values = self._values()
ordered_ids = [item.id for item in self.steps] ordered_ids = [item.id for item in self.steps]
self.state_store.invalidate_after(ordered_ids, step.id) 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 self.active_step_id = step.id
workspace = self.state_store.workspace workspace = self.state_store.workspace
assert workspace is not None assert workspace is not None
+18 -2
View File
@@ -326,11 +326,19 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
StepDefinition( StepDefinition(
"export", "export",
"Génération des annotations", "Génération des annotations",
"Exporter vers la tablette", "Exporter",
"Exporte les groupes vers le dossier EXPORT_DIR défini dans config.py.", "Exporte les annotations vers le dossier EXPORT_DIR défini dans config.py.",
(python("default", "Export", "export.py"),), (python("default", "Export", "export.py"),),
arguments=( arguments=(
arg_target("Dossier de l’évaluation"), arg_target("Dossier de l’évaluation"),
ArgumentSpec(
"annotation_dir",
"Dossier dannotations",
"choice",
default="BGnot",
choices=("BGnot", "Bnot", "Anot"),
positional=True,
),
ArgumentSpec("refaire", "Mode refaire", "bool", "--refaire"), ArgumentSpec("refaire", "Mode refaire", "bool", "--refaire"),
), ),
optional=True, optional=True,
@@ -350,6 +358,14 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
(python("default", "Import", "import.py"),), (python("default", "Import", "import.py"),),
arguments=( arguments=(
arg_target("Dossier de l’évaluation"), arg_target("Dossier de l’évaluation"),
ArgumentSpec(
"annotation_dir",
"Dossier dannotations",
"choice",
default="BGnot",
choices=("BGnot", "Bnot", "Anot"),
positional=True,
),
ArgumentSpec("refaire", "Mode refaire", "bool", "--refaire"), ArgumentSpec("refaire", "Mode refaire", "bool", "--refaire"),
), ),
), ),
+35 -7
View File
@@ -13,6 +13,8 @@ from copienator import (
) )
from platform_utils import replace_with_link_or_copy from platform_utils import replace_with_link_or_copy
ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot")
def export_directory( def export_directory(
workspace: EvaluationWorkspace, workspace: EvaluationWorkspace,
@@ -31,12 +33,22 @@ def export_directory(
missing_outputs = 0 missing_outputs = 0
for subdir in subdirs: for subdir in subdirs:
concat_file = subdir / "Concat.pdf" concat_file = next(
if not concat_file.is_file(): (
print(f"Warning: file not found: {concat_file}", file=sys.stderr) 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 missing_outputs += 1
continue 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") method = replace_with_link_or_copy(concat_file, destination, prefer="hardlink")
print(f"Exported: {destination} ({method})") print(f"Exported: {destination} ({method})")
return ExitCode.PARTIAL if missing_outputs else ExitCode.SUCCESS return ExitCode.PARTIAL if missing_outputs else ExitCode.SUCCESS
@@ -44,12 +56,24 @@ def export_directory(
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
parser = evaluation_parser("Export annotated PDFs to the tablet directory.") 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") parser.add_argument("--refaire", action="store_true", help="Process only copies/labels defined in refaire.json")
return parser return parser
def run(workspace: EvaluationWorkspace, *, refaire: bool = False) -> ExitCode: def run(
return export_directory(workspace, "BRnot" if refaire else "BGnot") 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: def main(argv: Sequence[str] | None = None) -> int:
@@ -57,7 +81,11 @@ def main(argv: Sequence[str] | None = None) -> int:
return execute( return execute(
parser, parser,
argv, 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,
),
) )
+42 -12
View File
@@ -13,43 +13,69 @@ from copienator import (
workspace_from_args, workspace_from_args,
) )
ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot")
def sync_annotated( def sync_annotated(
workspace: EvaluationWorkspace, workspace: EvaluationWorkspace,
*, *,
refaire: bool = False, annotation_dir_name: str,
import_dir: Path, import_dir: Path,
) -> ExitCode: ) -> ExitCode:
annotation_name = "BRnot" if refaire else "BGnot" workspace.require_directories(annotation_dir_name)
workspace.require_directories(annotation_name) annotation_dir = workspace.root / annotation_dir_name
annotation_dir = workspace.root / annotation_name
annotated_dir = Path(import_dir).expanduser() annotated_dir = Path(import_dir).expanduser()
if not annotated_dir.is_dir(): if not annotated_dir.is_dir():
print(f"Error: directory does not exist: {annotated_dir}", file=sys.stderr) print(f"Error: directory does not exist: {annotated_dir}", file=sys.stderr)
return ExitCode.INVALID_WORKSPACE return ExitCode.INVALID_WORKSPACE
missing_targets = 0 missing_targets = 0
for pdf_file in annotated_dir.glob("*.pdf"): annotated_files = sorted(
target_subdir = annotation_dir / pdf_file.stem (
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(): if not target_subdir.is_dir():
print(f"Warning: directory not found: {target_subdir}", file=sys.stderr) print(f"Warning: directory not found: {target_subdir}", file=sys.stderr)
missing_targets += 1 missing_targets += 1
else: else:
dest_file = target_subdir / "Concat_annotated.pdf" suffix = annotated_file.suffix.lower()
print(f"Copying {pdf_file} to {dest_file}") dest_file = target_subdir / f"Concat_annotated{suffix}"
shutil.copy2(pdf_file, dest_file) print(f"Copying {annotated_file} to {dest_file}")
shutil.copy2(annotated_file, dest_file)
return ExitCode.PARTIAL if missing_targets else ExitCode.SUCCESS return ExitCode.PARTIAL if missing_targets else ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
parser = evaluation_parser("Import handwritten annotations from the tablet directory.") 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") parser.add_argument("--refaire", action="store_true", help="Process only copies/labels defined in refaire.json")
return parser return parser
def run(workspace: EvaluationWorkspace, *, refaire: bool = False) -> ExitCode: def run(
return sync_annotated(workspace, refaire=refaire, import_dir=Path(IMPORT_DIR)) 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: def main(argv: Sequence[str] | None = None) -> int:
@@ -57,7 +83,11 @@ def main(argv: Sequence[str] | None = None) -> int:
return execute( return execute(
parser, parser,
argv, 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,
),
) )
+90 -2
View File
@@ -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.annotation_data import AnnotationLoadResult, load_annotation_data
from copienator.filesystem import staged_directory, staged_files from copienator.filesystem import staged_directory, staged_files
from copienator_gui.app import ( from copienator_gui.app import (
CopienatorApp,
DEFAULT_HTTPS_PROXY, DEFAULT_HTTPS_PROXY,
build_runner_environment, build_runner_environment,
copy_pdf_paths, copy_pdf_paths,
detected_annotation_directories,
has_manual_conflicts, has_manual_conflicts,
plotting_shortcut_lines, plotting_shortcut_lines,
process_status, process_status,
@@ -453,8 +455,16 @@ class StandardCliTests(unittest.TestCase):
"personal", "personal",
{"target": evaluation}, {"target": evaluation},
), ),
"export": ("export", "default", {"target": evaluation, "refaire": True}), "export": (
"import": ("import", "default", {"target": evaluation, "refaire": True}), "export",
"default",
{"target": evaluation, "annotation_dir": "Bnot", "refaire": True},
),
"import": (
"import",
"default",
{"target": evaluation, "annotation_dir": "Anot", "refaire": True},
),
"giving_names": ( "giving_names": (
"giving_names", "giving_names",
"default", "default",
@@ -596,6 +606,23 @@ class StandardCliTests(unittest.TestCase):
exported = base / "Export" / "Exam" / "Ex 1.pdf" exported = base / "Export" / "Exam" / "Ex 1.pdf"
self.assertEqual(exported.read_bytes(), b"annotated") 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: def test_import_main_copies_handwritten_annotations(self) -> None:
module = self.modules["import"] module = self.modules["import"]
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
@@ -612,6 +639,24 @@ class StandardCliTests(unittest.TestCase):
(target / "Concat_annotated.pdf").read_bytes(), b"handwritten" (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: def test_giving_names_main_builds_return_directory(self) -> None:
module = self.modules["giving_names"] module = self.modules["giving_names"]
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
@@ -1406,6 +1451,15 @@ class WorkflowTests(unittest.TestCase):
def test_review_persp_has_shorter_title(self) -> None: def test_review_persp_has_shorter_title(self) -> None:
self.assertEqual(self.steps["review_persp"].title, "Relire les barèmes") 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: def test_copy_listing_prefers_processed_copies(self) -> None:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
evaluation = Path(directory) evaluation = Path(directory)
@@ -1421,6 +1475,40 @@ class WorkflowTests(unittest.TestCase):
["Copie01.pdf", "Copie02.pdf"], ["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: def test_plotting_shortcuts_describe_open_actions(self) -> None:
rendered = "\n".join(plotting_shortcut_lines()) rendered = "\n".join(plotting_shortcut_lines())
self.assertIn("ouvrir l’énoncé", rendered) self.assertIn("ouvrir l’énoncé", rendered)