Standardisation des scripts

This commit is contained in:
2026-08-20 13:01:26 +02:00
parent 2f1cd00e32
commit 352d36e38c
10 changed files with 590 additions and 144 deletions
+23
View File
@@ -147,6 +147,29 @@ une modification concurrente de type lire-modifier-écrire, utiliser
=atomic_update_json= : cet utilitaire protège l'opération complète avec =atomic_update_json= : cet utilitaire protège l'opération complète avec
un verrou inter-processus Linux/Windows. un verrou inter-processus Linux/Windows.
*** Convention des scripts standardisés
Les scripts standardisés exposent =build_parser()=, =run(...)= et
=main(argv=None)=. Leur import ne lance aucun traitement. Ils acceptent
le dossier d'évaluation comme premier argument positionnel, utilisent
=EvaluationWorkspace= pour les chemins partagés et peuvent afficher la
trace complète d'une erreur avec =--verbose=.
Les codes de sortie communs sont :
| Code | Signification |
|------+---------------|
| 0 | réussite |
| 1 | erreur de traitement |
| 2 | arguments invalides |
| 3 | évaluation ou prérequis invalides |
| 4 | traitement partiel, avec avertissements |
| 130 | interruption par l'utilisateur |
Le GUI distingue notamment un traitement partiel d'un échec. Les
premiers scripts migrés vers cette convention sont =export.py=,
=import.py=, =giving_names.py= et =grouping.py=.
** Correction d'un paquet de copies ** Correction d'un paquet de copies
1. Créer un fichier =names= dans le dossier courant, avec les 1. Créer un fichier =names= dans le dossier courant, avec les
+14
View File
@@ -1,5 +1,13 @@
"""Core building blocks shared by Copienator scripts and interfaces.""" """Core building blocks shared by Copienator scripts and interfaces."""
from .cli import (
CliError,
ExitCode,
evaluation_parser,
evaluation_workspace,
execute,
workspace_from_args,
)
from .json_io import ( from .json_io import (
JsonLockTimeout, JsonLockTimeout,
atomic_update_json, atomic_update_json,
@@ -14,12 +22,18 @@ from .workspace import (
) )
__all__ = [ __all__ = [
"CliError",
"EvaluationWorkspace", "EvaluationWorkspace",
"ExitCode",
"JsonLockTimeout", "JsonLockTimeout",
"WorkspaceNotFoundError", "WorkspaceNotFoundError",
"WorkspaceValidationError", "WorkspaceValidationError",
"atomic_update_json", "atomic_update_json",
"atomic_write_json", "atomic_write_json",
"atomic_write_text", "atomic_write_text",
"evaluation_parser",
"evaluation_workspace",
"execute",
"read_json", "read_json",
"workspace_from_args",
] ]
+102
View File
@@ -0,0 +1,102 @@
from __future__ import annotations
import argparse
import sys
import traceback
from collections.abc import Callable, Sequence
from enum import IntEnum
from pathlib import Path
from typing import Any
from .workspace import (
EvaluationWorkspace,
WorkspaceNotFoundError,
WorkspaceValidationError,
)
class ExitCode(IntEnum):
SUCCESS = 0
FAILURE = 1
INVALID_ARGUMENTS = 2
INVALID_WORKSPACE = 3
PARTIAL = 4
INTERRUPTED = 130
class CliError(Exception):
def __init__(self, message: str, exit_code: ExitCode = ExitCode.FAILURE) -> None:
self.exit_code = exit_code
super().__init__(message)
def evaluation_parser(description: str) -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=description)
parser.add_argument(
"evaluation",
type=Path,
help="Evaluation directory",
)
parser.add_argument(
"--verbose",
action="store_true",
help="Show a traceback when an unexpected error occurs",
)
return parser
def evaluation_workspace(
path: str | Path,
*,
repository: str | Path | None = None,
) -> EvaluationWorkspace:
root = Path(path).expanduser().resolve()
if not root.exists():
raise CliError(
f"Evaluation directory does not exist: {root}",
ExitCode.INVALID_WORKSPACE,
)
if not root.is_dir():
raise CliError(
f"Evaluation path is not a directory: {root}",
ExitCode.INVALID_WORKSPACE,
)
return EvaluationWorkspace(root, Path(repository) if repository is not None else None)
def workspace_from_args(
args: argparse.Namespace,
*,
repository: str | Path | None = None,
) -> EvaluationWorkspace:
return evaluation_workspace(args.evaluation, repository=repository)
def execute(
parser: argparse.ArgumentParser,
argv: Sequence[str] | None,
handler: Callable[[argparse.Namespace], int | ExitCode | None],
) -> int:
args = parser.parse_args(argv)
try:
result = handler(args)
except CliError as exc:
print(f"Error: {exc}", file=sys.stderr)
return int(exc.exit_code)
except (WorkspaceNotFoundError, WorkspaceValidationError) as exc:
print(f"Error: {exc}", file=sys.stderr)
return int(ExitCode.INVALID_WORKSPACE)
except KeyboardInterrupt:
print("Interrupted by user.", file=sys.stderr)
return int(ExitCode.INTERRUPTED)
except Exception as exc: # noqa: BLE001 - executable boundary
if getattr(args, "verbose", False):
traceback.print_exc()
else:
print(f"Error: {exc}", file=sys.stderr)
return int(ExitCode.FAILURE)
return int(ExitCode.SUCCESS if result is None else result)
def stderr(message: Any) -> None:
print(message, file=sys.stderr)
+18
View File
@@ -186,6 +186,24 @@ class EvaluationWorkspace:
if missing: if missing:
raise WorkspaceValidationError(self.root, missing) raise WorkspaceValidationError(self.root, missing)
def require_files(self, *relative_paths: str) -> None:
missing = [
relative_path
for relative_path in relative_paths
if not (self.root / relative_path).is_file()
]
if missing:
raise WorkspaceValidationError(self.root, missing)
def require_directories(self, *relative_paths: str) -> None:
missing = [
relative_path
for relative_path in relative_paths
if not (self.root / relative_path).is_dir()
]
if missing:
raise WorkspaceValidationError(self.root, missing)
def ensure_control_directories(self) -> None: def ensure_control_directories(self) -> None:
self.logs_dir.mkdir(parents=True, exist_ok=True) self.logs_dir.mkdir(parents=True, exist_ok=True)
self.runs_dir.mkdir(parents=True, exist_ok=True) self.runs_dir.mkdir(parents=True, exist_ok=True)
+13 -4
View File
@@ -7,6 +7,7 @@ from pathlib import Path
from tkinter import filedialog, messagebox, ttk from tkinter import filedialog, messagebox, ttk
from typing import Any from typing import Any
from copienator import ExitCode
from platform_utils import WindowsLabelError, validate_windows_labels from platform_utils import WindowsLabelError, validate_windows_labels
from .diagnostics import collect_diagnostics from .diagnostics import collect_diagnostics
@@ -26,6 +27,7 @@ STATUS_LABELS = {
"ready": "Prête", "ready": "Prête",
"running": "En cours", "running": "En cours",
"success": "Réussie", "success": "Réussie",
"partial": "Partielle",
"failed": "Échouée", "failed": "Échouée",
"interrupted": "Interrompue", "interrupted": "Interrompue",
"skipped": "Ignorée", "skipped": "Ignorée",
@@ -34,6 +36,16 @@ STATUS_LABELS = {
} }
def process_status(return_code: int, interrupted: bool = False) -> str:
if interrupted or return_code == ExitCode.INTERRUPTED:
return "interrupted"
if return_code == ExitCode.SUCCESS:
return "success"
if return_code == ExitCode.PARTIAL:
return "partial"
return "failed"
class CopienatorApp(tk.Tk): class CopienatorApp(tk.Tk):
def __init__( def __init__(
self, self,
@@ -624,10 +636,7 @@ class CopienatorApp(tk.Tk):
step_id = self.active_step_id step_id = self.active_step_id
if not step_id: if not step_id:
return return
if interrupted: status = process_status(return_code, interrupted)
status = "interrupted"
else:
status = "success" if return_code == 0 else "failed"
self.state_store.update_step(step_id, status=status, return_code=return_code) self.state_store.update_step(step_id, status=status, return_code=return_code)
self.state_store.add_history( self.state_store.add_history(
{ {
+39 -18
View File
@@ -1,44 +1,65 @@
import argparse import argparse
import sys import sys
from collections.abc import Sequence
from pathlib import Path from pathlib import Path
from config import EXPORT_DIR from config import EXPORT_DIR
from copienator import (
EvaluationWorkspace,
ExitCode,
evaluation_parser,
execute,
workspace_from_args,
)
from platform_utils import replace_with_link_or_copy from platform_utils import replace_with_link_or_copy
def export_directory(base_dir, source_dir_name): def export_directory(
base_dir = Path(base_dir).expanduser().resolve() workspace: EvaluationWorkspace,
source_dir = base_dir / source_dir_name source_dir_name: str,
sync_dir = Path(EXPORT_DIR).expanduser() / base_dir.name ) -> ExitCode:
workspace.require_directories(source_dir_name)
source_dir = workspace.root / source_dir_name
sync_dir = Path(EXPORT_DIR).expanduser() / workspace.name
sync_dir.mkdir(parents=True, exist_ok=True) sync_dir.mkdir(parents=True, exist_ok=True)
if not source_dir.is_dir():
print(f"Erreur : le sous-dossier {source_dir} n'existe pas.")
return
subdirs = [directory for directory in source_dir.iterdir() if directory.is_dir()] subdirs = [directory for directory in source_dir.iterdir() if directory.is_dir()]
if source_dir_name == "BGnot" and subdirs: if source_dir_name == "BGnot" and subdirs:
all_start_with_copie = all(directory.name.startswith("Copie") for directory in subdirs) all_start_with_copie = all(directory.name.startswith("Copie") for directory in subdirs)
if not all_start_with_copie: if not all_start_with_copie:
subdirs = [directory for directory in subdirs if not directory.name.startswith("Copie")] subdirs = [directory for directory in subdirs if not directory.name.startswith("Copie")]
missing_outputs = 0
for subdir in subdirs: for subdir in subdirs:
concat_file = subdir / "Concat.pdf" concat_file = subdir / "Concat.pdf"
if not concat_file.is_file(): if not concat_file.is_file():
print(f"Attention : le fichier {concat_file} est introuvable.") print(f"Warning: file not found: {concat_file}", file=sys.stderr)
missing_outputs += 1
continue continue
destination = sync_dir / f"{subdir.name}.pdf" destination = sync_dir / f"{subdir.name}.pdf"
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
def build_parser() -> argparse.ArgumentParser:
parser = evaluation_parser("Export annotated PDFs to the tablet directory.")
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 main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
return execute(
parser,
argv,
lambda args: run(workspace_from_args(args), refaire=args.refaire),
)
if __name__ == "__main__": if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Move to tablette folder.") raise SystemExit(main())
parser.add_argument("dir", help="The directory to process")
parser.add_argument("--refaire", action="store_true", help="Process only copies/labels defined in refaire.json")
args = parser.parse_args()
if args.refaire:
export_directory(args.dir, "BRnot")
sys.exit(0)
export_directory(args.dir, "BGnot")
+136 -73
View File
@@ -1,102 +1,165 @@
import json from __future__ import annotations
import os
import argparse
import re import re
import sys import sys
from collections import defaultdict from collections import defaultdict
from collections.abc import Sequence
from pathlib import Path from pathlib import Path
from copienator import (
EvaluationWorkspace,
ExitCode,
evaluation_parser,
execute,
read_json,
workspace_from_args,
)
from platform_utils import replace_with_link_or_copy, safe_filename from platform_utils import replace_with_link_or_copy, safe_filename
def main(): ANNOTATION_CHOICES = ("BGnot", "Bnot", "Anot")
if len(sys.argv) < 3:
print("Usage: python giving_names.py <directory_path> <Bnot/BGnot>")
sys.exit(1)
work_dir = os.path.abspath(sys.argv[1])
copies_dir = Path(work_dir) / "Copies"
bnot_dir = sys.argv[2]
target_subdir = os.path.join(work_dir, "A Rendre")
os.makedirs(target_subdir, exist_ok=True)
# --- 1. Load the expected names list --- def build_parser() -> argparse.ArgumentParser:
expected_names = set() parser = evaluation_parser("Assign student names and prepare the return directory.")
names_path = os.path.join(work_dir, "names") parser.add_argument(
if not os.path.exists(names_path): "annotation_dir",
names_path = "names" # Fallback to current dir choices=ANNOTATION_CHOICES,
help="Annotation directory to use",
)
return parser
if os.path.exists(names_path):
with open(names_path, 'r', encoding='utf-8') as f:
expected_names = {line.strip() for line in f if line.strip()}
else:
print(f"Warning: 'names' file not found in {work_dir} or current directory.")
# --- 2. Existing Collection Logic --- def _read_expected_names(workspace: EvaluationWorkspace) -> set[str]:
names_path = workspace.names_file()
if not names_path.exists():
print(
f"Warning: names file not found in {workspace.root} or the current directory.",
file=sys.stderr,
)
return set()
return {
line.strip()
for line in names_path.read_text(encoding="utf-8").splitlines()
if line.strip()
}
def prepare_named_returns(
workspace: EvaluationWorkspace,
annotation_dir_name: str,
) -> ExitCode:
workspace.require_directories("Copies", annotation_dir_name)
workspace.return_dir.mkdir(parents=True, exist_ok=True)
expected_names = _read_expected_names(workspace)
copies_map: defaultdict[str, list[str]] = defaultdict(list)
pattern = re.compile(r"^Copie(\d+)\.json$") pattern = re.compile(r"^Copie(\d+)\.json$")
copies_map = defaultdict(list) had_errors = False
assigned_names = set() # To track which names were successfully linked
for filename in os.listdir(copies_dir): for json_path in workspace.copies_dir.iterdir():
match = pattern.match(filename) match = pattern.match(json_path.name)
if match: if not match:
copie_id = match.group(1) continue
json_path = os.path.join(copies_dir, filename)
try: try:
with open(json_path, 'r', encoding='utf-8') as f: data = read_json(json_path)
data = json.load(f) if not isinstance(data, dict):
name = data.get("name", "Unknown").strip() raise TypeError("expected a JSON object")
copies_map[name].append(copie_id) name = str(data.get("name", "Unknown")).strip()
except Exception as e: copies_map[name].append(match.group(1))
print(f"Error processing {filename}: {e}") except (OSError, TypeError, ValueError) as exc:
print(f"Error processing {json_path}: {exc}", file=sys.stderr)
had_errors = True
# --- 3. Process and Link --- assigned_names: set[str] = set()
for name, ids in copies_map.items(): selected_annotations = workspace.root / annotation_dir_name
fallback_annotations = workspace.annotation_dir("simple")
for name, copy_ids in copies_map.items():
if name == "Unknown": if name == "Unknown":
print(f"ALERT: 'Unknown' name found for IDs: {', '.join(ids)}") print(
elif len(ids) > 1: f"Warning: unknown name for copies: {', '.join(copy_ids)}",
print(f"ALERT: Name '{name}' assigned to multiple IDs: {', '.join(ids)}") file=sys.stderr,
)
elif len(copy_ids) > 1:
print(
f"Warning: name {name!r} is assigned to multiple copies: "
f"{', '.join(copy_ids)}",
file=sys.stderr,
)
safe_name = safe_filename(name) safe_name = safe_filename(name)
for copy_id in copy_ids:
for copie_id in ids: selected = selected_annotations / f"Copie{copy_id}"
path_b = os.path.join(work_dir, f"{bnot_dir}/Copie{copie_id}") fallback = fallback_annotations / f"Copie{copy_id}"
path_a = os.path.join(work_dir, f"Anot/Copie{copie_id}")
source_folder = None source_folder = None
if os.path.exists(os.path.join(path_b, "Concat.jpg")) and os.path.exists(os.path.join(path_b, "score.json")): for candidate in (selected, fallback):
source_folder = path_b if (candidate / "Concat.jpg").exists() and (
elif os.path.exists(os.path.join(path_a, "Concat.jpg")) and os.path.exists(os.path.join(path_a, "score.json")): candidate / "score.json"
source_folder = path_a ).exists():
source_folder = candidate
if not source_folder: break
if source_folder is None:
continue continue
# If we reached here, the link is possible
assigned_names.add(name) assigned_names.add(name)
destination = workspace.return_dir / f"{safe_name} ({copy_id})"
dest_folder_name = f"{safe_name} ({copie_id})" destination.mkdir(parents=True, exist_ok=True)
dest_path = os.path.join(target_subdir, dest_folder_name) links = (
os.makedirs(dest_path, exist_ok=True) ("Concat.jpg", f"{safe_name}.jpg"),
("Concat_F.pdf", f"{safe_name}.pdf"),
links = [("Concat.jpg", f"{safe_name}.jpg"),("Concat_F.pdf", f"{safe_name}.pdf"), ("score.json", "score.json")] ("score.json", "score.json"),
for src_name, dst_name in links: )
src_file = os.path.join(source_folder, src_name) for source_name, destination_name in links:
dst_link = os.path.join(dest_path, dst_name) source = source_folder / source_name
if not source.exists():
continue
try: try:
if os.path.exists(src_file): method = replace_with_link_or_copy(
method = replace_with_link_or_copy(src_file, dst_link, prefer="symlink") source,
destination / destination_name,
prefer="symlink",
)
if method == "copy": if method == "copy":
print(f"Copied {src_name} for {dest_folder_name} (links unavailable)") print(
except OSError as e: f"Copied {source_name} for {destination.name} "
print(f"Error linking {src_name} for {dest_folder_name}: {e}") "(links unavailable)"
)
except OSError as exc:
print(
f"Error linking {source} for {destination.name}: {exc}",
file=sys.stderr,
)
had_errors = True
# --- 4. Print Unassigned Names ---
unassigned = expected_names - assigned_names unassigned = expected_names - assigned_names
if unassigned: if unassigned:
print("\n" + "!" * 40) print("Names from the list that were not assigned:", file=sys.stderr)
print("NAMES FROM LIST NOT ASSIGNED:") for name in sorted(unassigned):
for n in sorted(unassigned): print(f" - {name}", file=sys.stderr)
print(f" - {n}")
print("!" * 40) return ExitCode.PARTIAL if had_errors else ExitCode.SUCCESS
def run(
workspace: EvaluationWorkspace,
*,
annotation_dir: str,
) -> ExitCode:
return prepare_named_returns(workspace, annotation_dir)
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
return execute(
parser,
argv,
lambda args: run(
workspace_from_args(args, repository=Path.cwd()),
annotation_dir=args.annotation_dir,
),
)
if __name__ == "__main__": if __name__ == "__main__":
main() raise SystemExit(main())
+42 -22
View File
@@ -1,13 +1,22 @@
import argparse
import os import os
import json
import re import re
import sys
import shutil import shutil
from pathlib import Path
from collections import defaultdict from collections import defaultdict
from collections.abc import Sequence
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from PIL import Image, ImageDraw, ImageFont
from pdf2image import convert_from_path, pdfinfo_from_path from pdf2image import convert_from_path, pdfinfo_from_path
from PIL import Image, ImageDraw, ImageFont
from copienator import (
EvaluationWorkspace,
ExitCode,
atomic_write_json,
evaluation_parser,
execute,
workspace_from_args,
)
# Configuration # Configuration
DPI = 200 # Good balance for readability and size DPI = 200 # Good balance for readability and size
@@ -34,7 +43,7 @@ def get_pdf_height(path):
# Return total height # Return total height
return single_page_px * num_pages return single_page_px * num_pages
except Exception as e: except Exception as e: # noqa: BLE001 - pdfinfo may raise backend-specific errors
print(f"Error reading {path}: {e}") print(f"Error reading {path}: {e}")
return 0 return 0
@@ -78,7 +87,7 @@ def group_files(file_list):
groups = [] groups = []
for item in sorted_files: for item in sorted_files:
dd, path, height = item _, _, height = item
placed = False placed = False
# 2. Try to fit item into an existing group (First Fit) # 2. Try to fit item into an existing group (First Fit)
@@ -140,7 +149,7 @@ def create_jpg(identifier, group_index, group, root_dir):
combined_img = stitch_pdf_pages(imgs) combined_img = stitch_pdf_pages(imgs)
if combined_img: if combined_img:
images.append((dd, combined_img)) images.append((dd, combined_img))
except Exception as e: except Exception as e: # noqa: BLE001 - PDF/image backends vary by platform
print(f"Failed to convert {path}: {e}") print(f"Failed to convert {path}: {e}")
if not images: if not images:
@@ -159,7 +168,7 @@ def create_jpg(identifier, group_index, group, root_dir):
# Try loading a font, fallback to default # Try loading a font, fallback to default
try: try:
font = ImageFont.truetype("DejaVuSans.ttf", 40) font = ImageFont.truetype("DejaVuSans.ttf", 40)
except IOError: except OSError:
print("font not found") print("font not found")
font = ImageFont.load_default() font = ImageFont.load_default()
@@ -193,8 +202,7 @@ def create_jpg(identifier, group_index, group, root_dir):
# Save JSON metadata # Save JSON metadata
json_filename = f"Group_{group_index+1}.json" json_filename = f"Group_{group_index+1}.json"
json_path = os.path.join(target_folder, json_filename) json_path = os.path.join(target_folder, json_filename)
with open(json_path, 'w') as f: atomic_write_json(json_path, metadata, indent=None)
json.dump(metadata, f)
# Save with size constraints # Save with size constraints
output_filename = f"Group_{group_index+1}.jpg" output_filename = f"Group_{group_index+1}.jpg"
@@ -227,18 +235,16 @@ def process_identifier(identifier, files_info, output_dir):
for idx, group in enumerate(file_groups): for idx, group in enumerate(file_groups):
create_jpg(identifier, idx, group, output_dir) create_jpg(identifier, idx, group, output_dir)
def main(): def build_parser() -> argparse.ArgumentParser:
if len(sys.argv) < 2: return evaluation_parser("Group copy extracts by question label.")
print("Usage: python app.py <Path_to_Dir>")
sys.exit(1)
root_dir = Path(sys.argv[1])
copies_dir = root_dir / "Copies" def run(workspace: EvaluationWorkspace) -> ExitCode:
par_label_dir = root_dir / "Par label" workspace.require_directories("Copies")
workspace.groups_dir.mkdir(parents=True, exist_ok=True)
print("Scanning files...") print("Scanning files...")
data = collect_files(copies_dir) data = collect_files(workspace.copies_dir)
print(f"Found {len(data)} identifiers. Processing...") print(f"Found {len(data)} identifiers. Processing...")
@@ -247,11 +253,25 @@ def main():
# Process using 8 threads # Process using 8 threads
with ThreadPoolExecutor(max_workers=8) as executor: with ThreadPoolExecutor(max_workers=8) as executor:
for identifier in sorted_identifiers: futures = [
executor.submit(process_identifier, identifier, data[identifier], executor.submit(
par_label_dir) process_identifier,
identifier,
data[identifier],
workspace.groups_dir,
)
for identifier in sorted_identifiers
]
for future in futures:
future.result()
print("Done.") print("Done.")
return ExitCode.SUCCESS
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
return execute(parser, argv, lambda args: run(workspace_from_args(args)))
if __name__ == "__main__": if __name__ == "__main__":
main() raise SystemExit(main())
+47 -25
View File
@@ -1,43 +1,65 @@
import sys import argparse
import shutil import shutil
import sys
from collections.abc import Sequence
from pathlib import Path from pathlib import Path
from config import IMPORT_DIR from config import IMPORT_DIR
from copienator import (
EvaluationWorkspace,
ExitCode,
evaluation_parser,
execute,
workspace_from_args,
)
def sync_annotated(dir_arg, refaire):
if not refaire:
bgnot_dir = Path(dir_arg) / "BGnot"
else:
bgnot_dir = Path(dir_arg) / "BRnot"
annotated_dir = IMPORT_DIR
def sync_annotated(
workspace: EvaluationWorkspace,
*,
refaire: bool = False,
import_dir: Path,
) -> ExitCode:
annotation_name = "BRnot" if refaire else "BGnot"
workspace.require_directories(annotation_name)
annotation_dir = workspace.root / annotation_name
annotated_dir = Path(import_dir).expanduser()
if not annotated_dir.is_dir(): if not annotated_dir.is_dir():
print(f"Error: Directory {annotated_dir} does not exist.") print(f"Error: directory does not exist: {annotated_dir}", file=sys.stderr)
return return ExitCode.INVALID_WORKSPACE
# Iterate over all PDF files in the annotated directory missing_targets = 0
for pdf_file in annotated_dir.glob("*.pdf"): for pdf_file in annotated_dir.glob("*.pdf"):
subdir_name = pdf_file.stem # 'f' from 'f.pdf' target_subdir = annotation_dir / pdf_file.stem
target_subdir = bgnot_dir / subdir_name
if not target_subdir.is_dir(): if not target_subdir.is_dir():
print(f"Warning: Directory {target_subdir} not found.") print(f"Warning: directory not found: {target_subdir}", file=sys.stderr)
missing_targets += 1
else: else:
dest_file = target_subdir / "Concat_annotated.pdf" dest_file = target_subdir / "Concat_annotated.pdf"
print("copying ", pdf_file, " to ", dest_file) print(f"Copying {pdf_file} to {dest_file}")
shutil.copy2(pdf_file, dest_file) shutil.copy2(pdf_file, dest_file)
return ExitCode.PARTIAL if missing_targets else ExitCode.SUCCESS
import argparse
def build_parser() -> argparse.ArgumentParser:
parser = evaluation_parser("Import handwritten annotations from the tablet directory.")
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 main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
return execute(
parser,
argv,
lambda args: run(workspace_from_args(args), refaire=args.refaire),
)
if __name__ == "__main__": if __name__ == "__main__":
raise SystemExit(main())
parser = argparse.ArgumentParser(description="Move to tablette folder.")
parser.add_argument("dir", help="The directory to process")
parser.add_argument("--refaire", action="store_true", help="Process only copies/labels defined in refaire.json")
args = parser.parse_args()
root_dir = args.dir
sync_annotated(root_dir, args.refaire)
+154
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
import importlib.util
import io
import os import os
import queue import queue
import sys import sys
@@ -7,6 +9,7 @@ import tempfile
import time import time
import unittest import unittest
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from contextlib import redirect_stderr
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
@@ -20,6 +23,7 @@ from copienator import (
atomic_write_json, atomic_write_json,
read_json, read_json,
) )
from copienator_gui.app import process_status
from copienator_gui.diagnostics import collect_diagnostics from copienator_gui.diagnostics import collect_diagnostics
from copienator_gui.runner import ProcessRunner from copienator_gui.runner import ProcessRunner
from copienator_gui.state import StateStore from copienator_gui.state import StateStore
@@ -36,6 +40,15 @@ from platform_utils import (
REPOSITORY = Path(__file__).resolve().parents[1] REPOSITORY = Path(__file__).resolve().parents[1]
def load_script_module(filename: str, module_name: str):
spec = importlib.util.spec_from_file_location(module_name, REPOSITORY / filename)
if spec is None or spec.loader is None:
raise RuntimeError(f"Could not load {filename}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class WorkspaceTests(unittest.TestCase): class WorkspaceTests(unittest.TestCase):
def test_canonical_paths_and_no_constructor_side_effects(self) -> None: def test_canonical_paths_and_no_constructor_side_effects(self) -> None:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
@@ -73,6 +86,10 @@ class WorkspaceTests(unittest.TestCase):
workspace.require("enonce.pdf", "labels") workspace.require("enonce.pdf", "labels")
self.assertEqual(context.exception.missing, ["enonce.pdf", "labels"]) self.assertEqual(context.exception.missing, ["enonce.pdf", "labels"])
(root / "Copies").write_text("not a directory", encoding="utf-8")
with self.assertRaises(WorkspaceValidationError):
workspace.require_directories("Copies")
def test_names_file_prefers_evaluation_then_repository(self) -> None: def test_names_file_prefers_evaluation_then_repository(self) -> None:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
repository = Path(directory) repository = Path(directory)
@@ -134,6 +151,143 @@ class AtomicJsonTests(unittest.TestCase):
self.assertEqual(persisted["steps"]["labels"]["status"], "success") self.assertEqual(persisted["steps"]["labels"]["status"], "success")
class StandardCliTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.modules = {
"export": load_script_module("export.py", "copienator_export_test"),
"import": load_script_module("import.py", "copienator_import_test"),
"giving_names": load_script_module(
"giving_names.py", "copienator_giving_names_test"
),
"grouping": load_script_module("grouping.py", "copienator_grouping_test"),
}
def test_missing_evaluation_has_standard_exit_code(self) -> None:
with tempfile.TemporaryDirectory() as directory:
missing = str(Path(directory) / "missing")
invocations = {
"export": [missing],
"import": [missing],
"giving_names": [missing, "BGnot"],
"grouping": [missing],
}
for name, arguments in invocations.items():
with self.subTest(script=name), redirect_stderr(io.StringIO()):
self.assertEqual(self.modules[name].main(arguments), 3)
def test_invalid_arguments_use_argparse_exit_code(self) -> None:
with redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as context:
self.modules["grouping"].main([])
self.assertEqual(context.exception.code, 2)
def test_unexpected_processing_error_has_failure_exit_code(self) -> None:
module = self.modules["grouping"]
with tempfile.TemporaryDirectory() as directory:
with patch.object(
module, "run", side_effect=RuntimeError("broken")
), redirect_stderr(io.StringIO()) as errors:
self.assertEqual(module.main([directory]), 1)
self.assertIn("broken", errors.getvalue())
def test_prerequisites_are_checked_before_creating_outputs(self) -> None:
module = self.modules["export"]
with tempfile.TemporaryDirectory() as directory:
base = Path(directory)
evaluation = base / "Exam"
evaluation.mkdir()
export_dir = base / "Export"
with patch.object(module, "EXPORT_DIR", export_dir), redirect_stderr(
io.StringIO()
):
self.assertEqual(module.main([str(evaluation)]), 3)
self.assertFalse(export_dir.exists())
def test_gui_understands_standard_process_exit_codes(self) -> None:
self.assertEqual(process_status(0), "success")
self.assertEqual(process_status(1), "failed")
self.assertEqual(process_status(3), "failed")
self.assertEqual(process_status(4), "partial")
self.assertEqual(process_status(130), "interrupted")
self.assertEqual(process_status(0, interrupted=True), "interrupted")
def test_gui_commands_are_accepted_by_script_parsers(self) -> None:
steps = {step.id: step for step in build_workflow(True)}
evaluation = "Evaluation with spaces"
cases = {
"export": ("export", "default", {"target": evaluation, "refaire": True}),
"import": ("import", "default", {"target": evaluation, "refaire": True}),
"giving_names": (
"giving_names",
"default",
{"target": evaluation, "annotation_dir": "BGnot"},
),
"grouping": ("grouping", "default", {"target": evaluation}),
}
for module_name, (step_id, variant_id, values) in cases.items():
step = steps[step_id]
variant = next(item for item in step.variants if item.id == variant_id)
command = build_command(REPOSITORY, step, variant, values, evaluation)
with self.subTest(script=module_name):
parsed = self.modules[module_name].build_parser().parse_args(command[3:])
self.assertEqual(str(parsed.evaluation), evaluation)
def test_export_main_copies_outputs(self) -> None:
module = self.modules["export"]
with tempfile.TemporaryDirectory() as directory:
base = Path(directory)
evaluation = base / "Exam"
source = evaluation / "BGnot" / "Ex 1"
source.mkdir(parents=True)
(source / "Concat.pdf").write_bytes(b"annotated")
with patch.object(module, "EXPORT_DIR", base / "Export"):
self.assertEqual(module.main([str(evaluation)]), 0)
exported = base / "Export" / "Exam" / "Ex 1.pdf"
self.assertEqual(exported.read_bytes(), b"annotated")
def test_import_main_copies_handwritten_annotations(self) -> None:
module = self.modules["import"]
with tempfile.TemporaryDirectory() as directory:
base = Path(directory)
evaluation = base / "Exam"
target = evaluation / "BGnot" / "Ex 1"
target.mkdir(parents=True)
import_dir = base / "Import"
import_dir.mkdir()
(import_dir / "Ex 1.pdf").write_bytes(b"handwritten")
with patch.object(module, "IMPORT_DIR", import_dir):
self.assertEqual(module.main([str(evaluation)]), 0)
self.assertEqual(
(target / "Concat_annotated.pdf").read_bytes(), b"handwritten"
)
def test_giving_names_main_builds_return_directory(self) -> None:
module = self.modules["giving_names"]
with tempfile.TemporaryDirectory() as directory:
evaluation = Path(directory) / "Exam"
copies = evaluation / "Copies"
annotations = evaluation / "BGnot" / "Copie01"
copies.mkdir(parents=True)
annotations.mkdir(parents=True)
atomic_write_json(copies / "Copie01.json", {"name": "Élève Test"})
atomic_write_json(annotations / "score.json", {"total": 10})
(annotations / "Concat.jpg").write_bytes(b"image")
(evaluation / "names").write_text("Élève Test\n", encoding="utf-8")
self.assertEqual(module.main([str(evaluation), "BGnot"]), 0)
destination = evaluation / "A Rendre" / "Élève Test (01)"
self.assertEqual((destination / "Élève Test.jpg").read_bytes(), b"image")
self.assertEqual(read_json(destination / "score.json"), {"total": 10})
def test_grouping_main_accepts_empty_copies_directory(self) -> None:
module = self.modules["grouping"]
with tempfile.TemporaryDirectory() as directory:
evaluation = Path(directory) / "Exam"
(evaluation / "Copies").mkdir(parents=True)
self.assertEqual(module.main([str(evaluation)]), 0)
self.assertTrue((evaluation / "Par label").is_dir())
class WorkflowTests(unittest.TestCase): class WorkflowTests(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.steps = {step.id: step for step in build_workflow(True)} self.steps = {step.id: step for step in build_workflow(True)}