diff --git a/Readme.org b/Readme.org index 4054bbe..858c4ba 100644 --- a/Readme.org +++ b/Readme.org @@ -191,6 +191,27 @@ ou éventuellement, la renseigner directement dans le fichier ** Configuration Copier `default_config.py` en `config.py`. Éventuellement le modifier. +Les options absentes conservent leur valeur par défaut. La variable +=COPIENATOR_CONFIG= permet de choisir un autre fichier global. + +Chaque dossier d'évaluation peut aussi contenir un =config.py= avec +seulement les options à remplacer, par exemple : + +#+BEGIN_SRC python +RETURN_PDF_ENABLED = False +MODEL_FLASH_ID = "gemini-3.8-flash" +#+END_SRC + +Ces options prennent le dessus sur la configuration globale, y compris +celle choisie par =COPIENATOR_CONFIG=. Les autres options restent héritées. +Les chemins relatifs gardent leur interprétation depuis le dossier de +lancement. Les options explicites des commandes prennent le dessus sur +la configuration locale. L'interface recharge ces options quand on ouvre +une évaluation. + +Un fichier =names= dans le dossier d'évaluation remplace entièrement celui +du dossier de lancement, sans fusion des listes. En son absence, le fichier +global =names= reste utilisé. Une liste locale vide est également prioritaire. Les niveaux de réflexion Gemini se règlent indépendamment des modèles : =THINKING_LEVEL_LITE= (énoncés, ="low"=), =THINKING_LEVEL_FOR_LABEL= diff --git a/copienator/annotation_documents.py b/copienator/annotation_documents.py index b5e5f22..2b278c8 100644 --- a/copienator/annotation_documents.py +++ b/copienator/annotation_documents.py @@ -1,15 +1,20 @@ """Locate tablet documents, including the parts of a large checked copy.""" from pathlib import Path -from copienator.configuration import ANNOTATION_MAX_HEIGHT_PX, ANNOTATION_MAX_PIXELS +from copienator import configuration from copienator.json_io import read_json -if type(ANNOTATION_MAX_HEIGHT_PX) is not int or ANNOTATION_MAX_HEIGHT_PX <= 0: - raise ValueError("ANNOTATION_MAX_HEIGHT_PX must be a positive integer (pixels)") -MAX_HEIGHT_PX = ANNOTATION_MAX_HEIGHT_PX -if type(ANNOTATION_MAX_PIXELS) is not int or ANNOTATION_MAX_PIXELS <= 0: - raise ValueError("ANNOTATION_MAX_PIXELS must be a positive integer (pixels)") -MAX_PIXELS = ANNOTATION_MAX_PIXELS + +def annotation_limits() -> tuple[int, int]: + values = (configuration.ANNOTATION_MAX_HEIGHT_PX, configuration.ANNOTATION_MAX_PIXELS) + for name, value in zip(("ANNOTATION_MAX_HEIGHT_PX", "ANNOTATION_MAX_PIXELS"), values): + if type(value) is not int or value <= 0: + raise ValueError(f"{name} must be a positive integer (pixels)") + return values + + +# Validate global settings at import, as before. +annotation_limits() def document_fits(width: int, height: int, max_height: int, max_pixels: int) -> bool: diff --git a/copienator/cli.py b/copienator/cli.py index bb112de..1b24722 100644 --- a/copienator/cli.py +++ b/copienator/cli.py @@ -109,6 +109,15 @@ def execute( ) -> int: args = parser.parse_args(argv) try: + from . import configuration + evaluation = getattr(args, "evaluation", None) + if evaluation is None and getattr(args, "target", None) is not None: + try: + evaluation = EvaluationWorkspace.discover(args.target).root + except WorkspaceNotFoundError: + # The handler owns target validation and its error reporting. + evaluation = None + configuration.use_evaluation(evaluation) from .evaluation_lock import command_lock with command_lock(args): result = handler(args) diff --git a/copienator/commands/add_final_score.py b/copienator/commands/add_final_score.py index 4f65a6c..f0c707c 100644 --- a/copienator/commands/add_final_score.py +++ b/copienator/commands/add_final_score.py @@ -10,21 +10,11 @@ import pandas as pd from PIL import Image, ImageDraw, ImageFont from copienator import atomic_write_json, read_json -from copienator.configuration import ( - FINAL_SCORE_FONT_PATH, - FINAL_SCORE_HISTOGRAM_PATH, - FINAL_SCORE_ODS_PATH, - FINAL_SCORE_OUTPUT_DIR, -) - -# Configuration constants -ODS_PATH = Path(FINAL_SCORE_ODS_PATH).expanduser() -OUTPUT_DIR = Path(FINAL_SCORE_OUTPUT_DIR).expanduser() -HISTOGRAM_PATH = Path(FINAL_SCORE_HISTOGRAM_PATH).expanduser() +from copienator import configuration def score_font(size): - candidates = [FINAL_SCORE_FONT_PATH, "DejaVuSans-Bold.ttf", "arial.ttf"] + candidates = [configuration.FINAL_SCORE_FONT_PATH, "DejaVuSans-Bold.ttf", "arial.ttf"] for candidate in candidates: if not candidate: continue @@ -64,9 +54,10 @@ def copy_return_artifacts(source_dir: Path, destination_dir: Path) -> None: def copy_histogram(output_dir: Path, *, copy_merged_histogramme: bool = False) -> None: """Copy the score histogram beside the per-student output folders.""" - sources = [(HISTOGRAM_PATH, "histogramme.pdf")] + histogram_path = Path(configuration.FINAL_SCORE_HISTOGRAM_PATH).expanduser() + sources = [(histogram_path, "histogramme.pdf")] if copy_merged_histogramme: - sources.append((HISTOGRAM_PATH.with_name("merged_histogramme.pdf"), "merged_histogramme.pdf")) + sources.append((histogram_path.with_name("merged_histogramme.pdf"), "merged_histogramme.pdf")) for source, filename in sources: if not source.is_file(): print(f"Warning: Missing histogram '{source}'.") @@ -113,7 +104,7 @@ def process_images(base_dir, output_dir, *, copy_merged_histogramme=False): try: # header=None assumes the file starts directly with data. # If row 0 is a header, change to header=0 - df = pd.read_excel(ODS_PATH, engine="odf", header=None) + df = pd.read_excel(Path(configuration.FINAL_SCORE_ODS_PATH).expanduser(), engine="odf", header=None) # Create a lookup dictionary: {Name: Score} score_db = dict(zip(df[0], df[1])) except Exception as e: @@ -209,7 +200,8 @@ def main(argv=None): args = parser.parse_args(argv) base_dir = args.dir.expanduser().resolve() - output_dir = OUTPUT_DIR / base_dir.name + configuration.use_evaluation(base_dir) + output_dir = Path(configuration.FINAL_SCORE_OUTPUT_DIR).expanduser() / base_dir.name output_dir.mkdir(parents=True, exist_ok=True) process_images(base_dir, output_dir, copy_merged_histogramme=args.copy_merged_histogramme) return 0 diff --git a/copienator/commands/annotating.py b/copienator/commands/annotating.py index d835cbe..6939237 100644 --- a/copienator/commands/annotating.py +++ b/copienator/commands/annotating.py @@ -20,8 +20,8 @@ from matplotlib import pyplot as plt from pdf2image import convert_from_path from PIL import Image, ImageDraw, ImageFont +from copienator import configuration from copienator import utils -from copienator.configuration import LATEX_ANOT_AFTER, LATEX_ANOT_BEFORE from copienator import ( EvaluationWorkspace, ExitCode, @@ -190,10 +190,10 @@ def render_real_latex_text(text, width_px, bg_color=(255, 255, 255, 255), max_li line_spacing = int(fontsize * 1.2) # Use the 'standalone' class with 'varwidth' to auto-crop height while restricting width - header = LATEX_ANOT_BEFORE.substitute( + header = configuration.LATEX_ANOT_BEFORE.substitute( width_in=width_in, fontsize=fontsize, line_spacing=line_spacing ) - latex_template = f"{header}{text}{LATEX_ANOT_AFTER}" + latex_template = f"{header}{text}{configuration.LATEX_ANOT_AFTER}" with tempfile.TemporaryDirectory() as temp_dir: tex_path = os.path.join(temp_dir, 'text.tex') diff --git a/copienator/commands/annotating_by_label.py b/copienator/commands/annotating_by_label.py index 24dec40..f5a94cd 100644 --- a/copienator/commands/annotating_by_label.py +++ b/copienator/commands/annotating_by_label.py @@ -23,7 +23,7 @@ from copienator import ( workspace_from_args, ) from copienator.annotation_data import load_annotation_data -from copienator.annotation_documents import MAX_HEIGHT_PX, MAX_PIXELS, document_fits +from copienator.annotation_documents import annotation_limits, document_fits from copienator.commands import annotating, annotating_with_checks from copienator.commands.import_annotations import clear_import_directory from copienator.filesystem import staged_directory @@ -60,15 +60,16 @@ def render_item(item): def save_batch(batch, prefix, group_id, output_root: Path) -> None: + max_height_px, max_pixels = annotation_limits() output_dir = output_root / f"{prefix} G{group_id}" print(f"Generating group PDF: {prefix} G{group_id} ({len(batch)} elements)") max_width = max(item[2].width for item in batch) total_height = sum(item[2].height for item in batch) - if not document_fits(max_width, total_height, MAX_HEIGHT_PX, MAX_PIXELS): + if not document_fits(max_width, total_height, max_height_px, max_pixels): raise CliError( f"{prefix} G{group_id} would be {max_width} × {total_height} px " f"({max_width * total_height} pixels); document limits are " - f"{MAX_HEIGHT_PX} px high and {MAX_PIXELS} pixels." + f"{max_height_px} px high and {max_pixels} pixels." ) concatenated = Image.new("RGB", (max_width, total_height), "white") draw = ImageDraw.Draw(concatenated) @@ -280,12 +281,13 @@ def _existing_group_state( def split_batches(rendered): + max_height_px, max_pixels = annotation_limits() batches = [] heights = [] widths = [] def fits(width, height): - return document_fits(width, height, MAX_HEIGHT_PX, MAX_PIXELS) + return document_fits(width, height, max_height_px, max_pixels) def add_block(items): width = max(item[2].width for item in items) @@ -326,7 +328,7 @@ def split_batches(rendered): f"Copie{student_id} ({item[1]}) is {image.width} × " f"{image.height} px ({image.width * image.height} pixels); " f"a single answer cannot be split. Document limits are " - f"{MAX_HEIGHT_PX} px high and {MAX_PIXELS} pixels." + f"{max_height_px} px high and {max_pixels} pixels." ) if block and not fits(max(width, image.width), height + image.height): add_block(block) diff --git a/copienator/commands/annotating_with_checks.py b/copienator/commands/annotating_with_checks.py index 9f7ae39..eadfa61 100644 --- a/copienator/commands/annotating_with_checks.py +++ b/copienator/commands/annotating_with_checks.py @@ -26,7 +26,7 @@ from copienator import ( workspace_from_target, ) from copienator.annotation_data import load_annotation_data -from copienator.annotation_documents import MAX_HEIGHT_PX, MAX_PIXELS, copy_documents, document_fits +from copienator.annotation_documents import annotation_limits, copy_documents, document_fits from copienator.commands import annotating from copienator.commands.import_annotations import clear_import_directory from copienator.filesystem import staged_directory @@ -160,6 +160,7 @@ def _render_student( output_mode: str, output_root: Path | None = None, ) -> str: + max_height_px, max_pixels = annotation_limits() output_dir = ( output_root if output_root is not None @@ -214,15 +215,15 @@ def _render_student( height = 0 width = 0 for index, image in enumerate(label_images): - if not document_fits(image.width, image.height, MAX_HEIGHT_PX, MAX_PIXELS): + if not document_fits(image.width, image.height, max_height_px, max_pixels): raise CliError( f"Copie{student_id} ({bnote_entries[index]['label']}) is " f"{image.width} × {image.height} px ({image.width * image.height} pixels); " f"a single answer cannot be split. Document limits are " - f"{MAX_HEIGHT_PX} px high and {MAX_PIXELS} pixels." + f"{max_height_px} px high and {max_pixels} pixels." ) if not document_fits(max(width, image.width), height + image.height, - MAX_HEIGHT_PX, MAX_PIXELS): + max_height_px, max_pixels): batches.append((start, index)) start = index height = 0 @@ -254,13 +255,14 @@ def _save_document( checkbox_groups: list[list[dict[str, Any]]], bnote_entries: list[dict[str, Any]], ) -> None: + max_height_px, max_pixels = annotation_limits() max_width = max(image.width for image in label_images) total_height = sum(image.height for image in label_images) - if not document_fits(max_width, total_height, MAX_HEIGHT_PX, MAX_PIXELS): + if not document_fits(max_width, total_height, max_height_px, max_pixels): raise CliError( f"Document would be {max_width} × {total_height} px " f"({max_width * total_height} pixels); document limits are " - f"{MAX_HEIGHT_PX} px high and {MAX_PIXELS} pixels." + f"{max_height_px} px high and {max_pixels} pixels." ) concatenated = Image.new("RGB", (max_width, total_height), "white") checkbox_map: list[dict[str, Any]] = [] diff --git a/copienator/commands/correction.py b/copienator/commands/correction.py index 0d7ad4a..fe9e5b1 100644 --- a/copienator/commands/correction.py +++ b/copienator/commands/correction.py @@ -41,9 +41,6 @@ from copienator.utils import enonce_total, read_all_labels NB_THREADS = 12 # PROXY_URL = "http://192.168.241.1:3128" -MODEL_ID_pro = config.MODEL_PRO_ID -MODEL_ID_flash = config.MODEL_FLASH_ID -api_key = config.API_KEY # Runtime globals retained while the processing helpers are migrated incrementally. INPUT_DIR = Path() @@ -270,13 +267,18 @@ def reset_workspace(workspace: EvaluationWorkspace) -> None: "_new PDFs from 'Par label'." ) +_DEFAULT_FALLBACK = object() + + def call_gemini_with_retries(model_id, contents, config, - fallback_model_id=MODEL_ID_flash): + fallback_model_id=_DEFAULT_FALLBACK): """Handles requests to Gemini with a 1min and 5min retry mechanism, and quota fallback.""" global pro_quota_exhausted delays = [60, 300] request_seed = None # Own the retries here so the SDK cannot silently resend a fixed seed. + if fallback_model_id is _DEFAULT_FALLBACK: + fallback_model_id = settings.MODEL_FLASH_ID http_options = config.http_options or types.HttpOptions() config = config.model_copy(update={"http_options": http_options.model_copy( update={"retry_options": types.HttpRetryOptions(attempts=1)} @@ -289,7 +291,7 @@ def call_gemini_with_retries(model_id, contents, config, config = config.model_copy(update={"seed": request_seed}) tprint(f"\tGemini attempt {attempt + 1}/3: seed={request_seed}") # Switch to fallback immediately if quota was exhausted by another thread - if model_id == MODEL_ID_pro and pro_quota_exhausted and fallback_model_id: + if model_id == settings.MODEL_PRO_ID and pro_quota_exhausted and fallback_model_id: model_id = fallback_model_id config = config.model_copy(update={"thinking_config": thinking_config( settings.THINKING_LEVEL_FLASH @@ -324,7 +326,7 @@ def call_gemini_with_retries(model_id, contents, config, continue # Retry same model # Immediately fallback to Flash without waiting if it's a Pro quota error - if is_quota_error and model_id == MODEL_ID_pro and fallback_model_id: + if is_quota_error and model_id == settings.MODEL_PRO_ID and fallback_model_id: tprint(f"\tGemini Pro quota hit ({e}). \n\n\tFalling back to Flash permanently...") model_id = fallback_model_id config = config.model_copy(update={"thinking_config": thinking_config( @@ -347,7 +349,7 @@ def correct_boxes_with_gemini(pid, label, pdf_path, original_feedbacks, # pdf_path = COPIES_DIR / f"Copie{pid}" / f"{label}.pdf" contents, config = prompting.request_for_box_correction(pdf_path, original_feedbacks) - response_text = call_gemini_with_retries(MODEL_ID_flash, contents, config) + response_text = call_gemini_with_retries(settings.MODEL_FLASH_ID, contents, config) corrected_feedbacks = parse_correction_response(response_text) global_feedbacks = [f for f in original_feedbacks if not f["box_2d"]] @@ -404,7 +406,7 @@ def handle_label_errors(pid, label, res, pdf_path, *, group_geometry=None): if error_type == "wrong-label": tprint(f"\tHandling wrong-label for {pid} {label}") contents, config = prompting.request_for_wrong_label(pdf_path, label, enonce, labels_txt) - new_label = call_gemini_with_retries(MODEL_ID_flash, contents, config).strip().strip('"\'') + new_label = call_gemini_with_retries(settings.MODEL_FLASH_ID, contents, config).strip().strip('"\'') if new_label not in all_labels: tprint(f"\t\tCopie{pid} returned an incorrect label {new_label} from an initial wrong label {label}. Ignoring") res["error"] = "wrg-lbl:cldtfix" @@ -413,7 +415,7 @@ def handle_label_errors(pid, label, res, pdf_path, *, group_geometry=None): tprint(f"\t\tLabel confirmed for Copie{pid}: regrading {label}.") contents, config = prompting.request_for_confirmed_label(INPUT_DIR, pdf_path, label, pid) response = parse_correction_response( - call_gemini_with_retries(MODEL_ID_flash, contents, config) + call_gemini_with_retries(settings.MODEL_FLASH_ID, contents, config) ) if not isinstance(response, list) or len(response) != 1 or response[0].get("id") != pid: raise ValueError(f"Confirmed-label regrading must return exactly Copie{pid}") @@ -469,7 +471,7 @@ def handle_label_errors(pid, label, res, pdf_path, *, group_geometry=None): contents, config = prompting.request_for_additional_answer(pdf_path, label, enonce, labels_txt) tprint(f"\tHandling additional-answer for {pid} {label}") try: - add_labels = json.loads(call_gemini_with_retries(MODEL_ID_flash, contents, config)) + add_labels = json.loads(call_gemini_with_retries(settings.MODEL_FLASH_ID, contents, config)) except CorrectionStopRequested: raise except Exception: # noqa: BLE001 - invalid auxiliary model response @@ -555,7 +557,7 @@ def process_single_task(task_tuple, precomputed_response=None): thinking_level=(settings.THINKING_LEVEL_FLASH if use_flash else settings.THINKING_LEVEL_PRO), ) - model_to_use = MODEL_ID_flash if use_flash else MODEL_ID_pro + model_to_use = settings.MODEL_FLASH_ID if use_flash else settings.MODEL_PRO_ID if precomputed_response: tprint(f"Using saved response for: {label} {group_name}") @@ -810,7 +812,7 @@ def run_configured(args: argparse.Namespace) -> ExitCode: global client, tasks_to_process workers = request_workers(args) if client is None: - client = genai.Client(api_key=api_key) + client = genai.Client(api_key=settings.API_KEY) if args.refaire: refaire_path = INPUT_DIR / "refaire.json" overwritten_path = INPUT_DIR / "overwritten_correction.json" @@ -979,8 +981,8 @@ def run_configured(args: argparse.Namespace) -> ExitCode: ) print("Batch generation complete.") - print(f" - {count_flash} requests saved to {batch_flash_file} (for {MODEL_ID_flash})") - print(f" - {count_pro} requests saved to {batch_pro_file} (for {MODEL_ID_pro})") + print(f" - {count_flash} requests saved to {batch_flash_file} (for {settings.MODEL_FLASH_ID})") + print(f" - {count_pro} requests saved to {batch_pro_file} (for {settings.MODEL_PRO_ID})") print("Upload these files via the File API and create two separate batch jobs.") # If there's no live tasks to do, and we aren't doing a batched ingestion, exit right away diff --git a/copienator/commands/cutleft.py b/copienator/commands/cutleft.py index 3055f49..cddd2e0 100644 --- a/copienator/commands/cutleft.py +++ b/copienator/commands/cutleft.py @@ -15,6 +15,7 @@ import pymupdf from pdf2image import convert_from_path from PIL import Image, ImageTk +from copienator import configuration from copienator import ( CliError, EvaluationWorkspace, @@ -27,7 +28,6 @@ from copienator import ( ) from copienator.filesystem import staged_files from copienator.copy_errors import clear_copy_error, mark_copy_error, marked_copy_paths -from copienator.configuration import SHOW_PERSONAL_STEPS from copienator.left_margin import analyse_margins from copienator.name_grids import name_bottom_height_fraction from copienator.annotation_layout import fingerprint @@ -42,8 +42,8 @@ NARROW_PAGE_RATIO = 0.8 AUTO_STRIP_WIDTH_FACTOR = 1.10 pdf_cache_lock = threading.Lock() -# The requested spelling is kept as the public switch for this alternate layout. -personnal_binding = bool(SHOW_PERSONAL_STEPS) +def personal_bindings_enabled() -> bool: + return bool(configuration.SHOW_PERSONAL_STEPS) def distribute_pages(total_pages: int, max_per_file: int = 5) -> list[int]: @@ -316,7 +316,7 @@ class ImageReviewer: self.root.bind("", lambda _event: self.on_shift(CROP_SHIFT_STEP)) self.root.bind("", lambda _event: self.on_enlarge(CROP_WIDTH_STEP)) self.root.bind("", lambda _event: self.on_enlarge(-CROP_WIDTH_STEP)) - if personnal_binding: + if personal_bindings_enabled(): self.root.bind("t", lambda _event: self.on_shift(-CROP_SHIFT_STEP)) self.root.bind("n", lambda _event: self.on_shift(CROP_SHIFT_STEP)) self.root.bind("r", lambda _event: self.on_enlarge(CROP_WIDTH_STEP)) @@ -416,7 +416,7 @@ class ImageReviewer: self.label_img.image = tk_image personal_instructions = ( "\nt/s/r/n also available: left / narrower / wider / right" - if personnal_binding + if personal_bindings_enabled() else "" ) analysis = schema.get('auto_margin') diff --git a/copienator/commands/export.py b/copienator/commands/export.py index 92a9f63..147cb2f 100644 --- a/copienator/commands/export.py +++ b/copienator/commands/export.py @@ -3,6 +3,7 @@ import sys from collections.abc import Sequence from pathlib import Path +from copienator import configuration from copienator import ( EvaluationWorkspace, ExitCode, @@ -10,7 +11,6 @@ from copienator import ( execute, workspace_from_args, ) -from copienator.configuration import EXPORT_DIR from copienator.annotation_documents import tablet_documents from copienator.platform import replace_with_link_or_copy @@ -25,7 +25,7 @@ def export_directory( source_dir = workspace.annotation_dir("refaire") if source_dir_name == "BRnot" else workspace.root / source_dir_name session_id = workspace.refaire_session_id if source_dir_name == "BRnot" else None prefix = f"{session_id}__" if session_id else "" - sync_dir = Path(EXPORT_DIR).expanduser() / workspace.name + sync_dir = Path(configuration.EXPORT_DIR).expanduser() / workspace.name if session_id: sync_dir /= session_id sync_dir.mkdir(parents=True, exist_ok=True) diff --git a/copienator/commands/finalise_web.py b/copienator/commands/finalise_web.py index 76d12cc..6e1d5ae 100644 --- a/copienator/commands/finalise_web.py +++ b/copienator/commands/finalise_web.py @@ -3,7 +3,7 @@ from pathlib import Path import time import webbrowser -from copienator import evaluation_parser +from copienator import configuration, evaluation_parser def main(argv=None): @@ -14,6 +14,7 @@ def main(argv=None): from copienator.finalisation_server import WebSession session = None try: + configuration.use_evaluation(args.evaluation) session = WebSession(Path(args.evaluation).resolve(), args.annotation_dir) print(session.url, flush=True) if not args.no_browser: diff --git a/copienator/commands/gemini_for_enonce.py b/copienator/commands/gemini_for_enonce.py index e67fb36..60a0cdb 100644 --- a/copienator/commands/gemini_for_enonce.py +++ b/copienator/commands/gemini_for_enonce.py @@ -48,9 +48,6 @@ def get_lcp(s1: str, s2: str) -> str: return lcp -MODEL_ID = config.MODEL_LITE_ID -api_key = config.API_KEY - # --- Modèles pour la Requête 1 --- class StatementContent(BaseModel): @field_validator("question_content", "solution_content", "context_content", @@ -246,7 +243,7 @@ def find_file(folder: Path, base_name: str) -> Path | None: def _request_rubrics(client, group_context_text: str): response = client.models.generate_content( - model=MODEL_ID, + model=config.MODEL_LITE_ID, contents=[types.Content(role="user", parts=[ types.Part.from_text(text=PROMPT_4), types.Part.from_text(text=f"--- CONTENU DU GROUPE ---\n{group_context_text}"), @@ -378,13 +375,13 @@ def refine_existing(workspace: EvaluationWorkspace, mode: str, *, api_client=Non questions[label] = "\n".join(parts) context = utils.enonce_total(workspace.root) if api_client is None: - if not api_key: + if not config.API_KEY: raise CliError("GEMINI_API_KEY is not configured") - api_client = genai.Client(api_key=api_key) + api_client = genai.Client(api_key=config.API_KEY) if mode == "groups": response = api_client.models.generate_content( - model=MODEL_ID, + model=config.MODEL_LITE_ID, contents=[types.Content(role="user", parts=[types.Part.from_text(text=( "Regroupe ces questions d’examen en groupes cohérents pour la correction " "et l’annotation, selon leurs dépendances et leur contexte commun. " @@ -515,9 +512,9 @@ def process_exam( correction_text = correction_path.read_text(encoding="utf-8") if api_client is None: - if not api_key: + if not config.API_KEY: raise CliError("GEMINI_API_KEY is not configured") - api_client = genai.Client(api_key=api_key) + api_client = genai.Client(api_key=config.API_KEY) client = api_client # ========================================== @@ -543,7 +540,7 @@ def process_exam( questions_data = ExamQuestions(questions=extract_stage( folder, 'questions', QuestionOnlyItem, 'question_content', - lambda _pending: client.models.generate_content(model=MODEL_ID, contents=contents_1, config=config_1), + lambda _pending: client.models.generate_content(model=config.MODEL_LITE_ID, contents=contents_1, config=config_1), )) # ========================================== @@ -567,7 +564,7 @@ def process_exam( types.Part.from_text(text='--- QUESTIONS EXTRAITES ---\n' + selected.model_dump_json(indent=2)), types.Part.from_text(text=f'--- CORRECTION SOURCE ({correction_path.name}) ---\n{correction_text}'), ])] - return client.models.generate_content(model=MODEL_ID, contents=contents, config=config_2) + return client.models.generate_content(model=config.MODEL_LITE_ID, contents=contents, config=config_2) solutions_data = ExamSolutions(solutions=extract_stage( folder, 'solutions', SolutionOnlyItem, 'solution_content', request_solutions, labels=question_labels, )) @@ -599,7 +596,7 @@ def process_exam( contents = [types.Content(role='user', parts=[*contents_3[0].parts, types.Part.from_text(text='Produis des contextes UNIQUEMENT pour les labels cibles suivants : ' + ', '.join(pending))])] - return client.models.generate_content(model=MODEL_ID, contents=contents, config=config_3) + return client.models.generate_content(model=config.MODEL_LITE_ID, contents=contents, config=config_3) context_data = ExamContext(contexts=extract_stage( folder, 'contexts', ExtractedContext, 'context_content', request_contexts, labels=question_labels, )) @@ -1083,7 +1080,7 @@ def compile_existing_pdfs(workspace: EvaluationWorkspace) -> ExitCode: if not source_path.is_file(): source_path = None fingerprint = hashlib.sha256((utils.fragment_latex_header(source_path) - + utils.LATEX_AFTER + str(source_path)).encode()).hexdigest() + + config.LATEX_AFTER + str(source_path)).encode()).hexdigest() status = json.loads(status_path.read_text()) if status_path.exists() else {} failed_before = not status.get('complete') or status.get('preamble_hash') != fingerprint if (pdf_path.is_file() and pdf_path.stat().st_size > 0 diff --git a/copienator/commands/gemini_for_labels.py b/copienator/commands/gemini_for_labels.py index c13baef..699cd22 100644 --- a/copienator/commands/gemini_for_labels.py +++ b/copienator/commands/gemini_for_labels.py @@ -31,8 +31,6 @@ from copienator import ( ) from copienator.utils import natural_key, read_all_labels -MODEL_ID = config.MODEL_FOR_LABEL_ID -api_key = config.API_KEY REQUEST_TIMEOUT_SECONDS = 80 my_prompt = """I'm giving you an image of the left columns of a written exam. @@ -583,7 +581,7 @@ def process_copy_group( known_name=known_name, ) response = client.models.generate_content( - model=MODEL_ID, + model=config.MODEL_FOR_LABEL_ID, contents=contents, config=request_config, ) @@ -720,7 +718,7 @@ def run( line.strip() for line in names_text.splitlines() if line.strip() } | {"Unknown", "Continued"} if client is None: - client = genai.Client(api_key=api_key) + client = genai.Client(api_key=config.API_KEY) groups = group_images(images) pending_groups = {key: files for key, files in groups.items() @@ -828,7 +826,7 @@ def main(argv: Sequence[str] | None = None) -> int: def handle(args: argparse.Namespace) -> ExitCode: workspace, target = workspace_from_target( - args, repository=Path(__file__).resolve().parents[2] + args, repository=Path.cwd() ) targets = [target] for additional in args.additional_targets: diff --git a/copienator/commands/import_annotations.py b/copienator/commands/import_annotations.py index baba334..06807ab 100644 --- a/copienator/commands/import_annotations.py +++ b/copienator/commands/import_annotations.py @@ -16,15 +16,15 @@ from copienator import configuration from copienator.annotation_documents import tablet_documents ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot") -IMPORT_DIR = getattr(configuration, "IMPORT_DIR", None) def clear_import_directory(workspace: EvaluationWorkspace) -> None: """Empty the configured import folder while preserving the folder itself.""" - if IMPORT_DIR is None or not str(IMPORT_DIR).strip(): + import_dir = configuration.IMPORT_DIR + if import_dir is None or not str(import_dir).strip(): print("Aucun dossier d’import configuré ; aucun fichier supprimé.") return - directory = Path(IMPORT_DIR).expanduser().resolve() + directory = Path(import_dir).expanduser().resolve() if workspace.root.resolve().is_relative_to(directory) or Path.cwd().resolve().is_relative_to(directory): raise CliError(f"Le dossier d’import ne peut pas contenir l’évaluation ou le projet : {directory}", ExitCode.INVALID_ARGUMENTS) @@ -112,7 +112,7 @@ def run( return sync_annotated( workspace, annotation_dir_name="BRnot" if refaire else annotation_dir, - import_dir=Path(IMPORT_DIR), + import_dir=Path(configuration.IMPORT_DIR), ) diff --git a/copienator/commands/page_splitter.py b/copienator/commands/page_splitter.py index f91878e..328216b 100644 --- a/copienator/commands/page_splitter.py +++ b/copienator/commands/page_splitter.py @@ -16,7 +16,7 @@ import pymupdf # PyMuPDF from PIL import Image, ImageDraw, ImageTk from pypdf import PdfReader, PdfWriter -from copienator.configuration import PAGE_SPLITTER_KB +from copienator import configuration from copienator import ( CliError, EvaluationWorkspace, @@ -29,14 +29,6 @@ from copienator.platform import launch_pdf_arranger from copienator.copy_errors import marked_copy_paths, clear_copy_error from copienator.orientation import save_provenance, report_path -# Keep new shortcuts available with older personal configuration files. -PAGE_SPLITTER_KB = { - "reverse_pages": "i", - "add_secondary_splitter": "", - "delete_secondary_splitter": "", - **PAGE_SPLITTER_KB, -} - # --- Constants --- # Conversion factor: 1 cm to points (1 inch = 2.54 cm, 72 points = 1 inch) CM_TO_POINTS = (1 / 2.54) * 72 @@ -172,7 +164,7 @@ class PDFPreviewer: self.master.geometry("800x1000") def fmt(action): - k = PAGE_SPLITTER_KB.get(action, "") + k = configuration.PAGE_SPLITTER_KB.get(action, "") return k # Dynamic instructions text @@ -216,7 +208,7 @@ class PDFPreviewer: "prev_file": self.go_to_previous_file, } - for action, key in PAGE_SPLITTER_KB.items(): + for action, key in configuration.PAGE_SPLITTER_KB.items(): if action in action_map: self.master.bind(key, action_map[action]) diff --git a/copienator/commands/reading_annotations.py b/copienator/commands/reading_annotations.py index 1035250..b2dc188 100644 --- a/copienator/commands/reading_annotations.py +++ b/copienator/commands/reading_annotations.py @@ -420,19 +420,19 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument( "--return-answers-context", action=argparse.BooleanOptionalAction, - default=configuration.RETURN_ANSWERS_CONTEXT, + default=None, help="Include applicable context pages in individual answer exports", ) parser.add_argument( "--return-answers-question", action=argparse.BooleanOptionalAction, - default=configuration.RETURN_ANSWERS_QUESTION, + default=None, help="Include the current question PDF in individual answer exports", ) parser.add_argument( "--return-answers-solution", action=argparse.BooleanOptionalAction, - default=configuration.RETURN_ANSWERS_SOLUTION, + default=None, help="Include the current solution PDF in individual answer exports", ) return parser diff --git a/copienator/commands/reading_grouped_annotations.py b/copienator/commands/reading_grouped_annotations.py index ae4fe59..c4cec77 100644 --- a/copienator/commands/reading_grouped_annotations.py +++ b/copienator/commands/reading_grouped_annotations.py @@ -750,19 +750,19 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument( "--return-answers-context", action=argparse.BooleanOptionalAction, - default=configuration.RETURN_ANSWERS_CONTEXT, + default=None, help="Include applicable context pages in individual answer exports", ) parser.add_argument( "--return-answers-question", action=argparse.BooleanOptionalAction, - default=configuration.RETURN_ANSWERS_QUESTION, + default=None, help="Include the current question PDF in individual answer exports", ) parser.add_argument( "--return-answers-solution", action=argparse.BooleanOptionalAction, - default=configuration.RETURN_ANSWERS_SOLUTION, + default=None, help="Include the current solution PDF in individual answer exports", ) return parser diff --git a/copienator/commands/update_ods.py b/copienator/commands/update_ods.py index 45d86e9..05a72ef 100644 --- a/copienator/commands/update_ods.py +++ b/copienator/commands/update_ods.py @@ -6,11 +6,10 @@ from pathlib import Path import ezodf -from copienator.configuration import CURRENT_SCORE_ODS_PATH +from copienator import configuration from copienator.utils import read_all_labels # Configuration -ODS_PATH = Path(CURRENT_SCORE_ODS_PATH).expanduser() TARGET_DIR_NAME = "A Rendre" def main(argv=None): @@ -20,6 +19,8 @@ def main(argv=None): args = parser.parse_args(argv) work_dir = os.path.abspath(args.work_dir) + configuration.use_evaluation(work_dir) + ods_path = Path(configuration.CURRENT_SCORE_ODS_PATH).expanduser() all_labels = read_all_labels(Path(work_dir)) @@ -29,13 +30,13 @@ def main(argv=None): print(f"Error: Directory '{TARGET_DIR_NAME}' not found in {work_dir}") sys.exit(1) - if not os.path.exists(ODS_PATH): - print(f"Error: ODS file not found at {ODS_PATH}") + if not os.path.exists(ods_path): + print(f"Error: ODS file not found at {ods_path}") sys.exit(1) - print(f"Opening ODS file: {ODS_PATH}...") + print(f"Opening ODS file: {ods_path}...") try: - doc = ezodf.opendoc(str(ODS_PATH)) + doc = ezodf.opendoc(str(ods_path)) except Exception as e: print(f"Failed to open ODS: {e}") sys.exit(1) diff --git a/copienator/configuration.py b/copienator/configuration.py index ab7ac0c..cc98a20 100644 --- a/copienator/configuration.py +++ b/copienator/configuration.py @@ -25,32 +25,68 @@ def _load_user_config(path: Path) -> ModuleType: return module -CONFIG_PATH = _user_config_path() -if CONFIG_PATH is None: - import default_config as _configuration -else: - _configuration = _load_user_config(CONFIG_PATH) +def _settings(path: Path | None) -> dict[str, object]: + import default_config -# Keep new optional settings compatible with older personal configuration files. -THINKING_LEVEL_LITE = "low" -THINKING_LEVEL_FOR_LABEL = "medium" -MODEL_FOR_NAME_ID = getattr(_configuration, "MODEL_FOR_LABEL_ID", "gemini-3.8-flash") -THINKING_LEVEL_FOR_NAME = "low" -THINKING_LEVEL_FLASH = "medium" -THINKING_LEVEL_PRO = "high" -ALWAYS_CROP = False -ANNOTATION_MARGIN_LEFT = 300 -ANNOTATION_MARGIN_POINTS = [(10, 450), (20, 400), (35, 300)] -ANNOTATION_MAX_HEIGHT_PX = 33000 -ANNOTATION_MAX_PIXELS = 67_108_864 -SHOW_PERSONAL_STEPS = False -RETURN_JPEG_ENABLED = True -RETURN_PDF_ENABLED = True -RETURN_ANSWERS_ENABLED = False -RETURN_ANSWERS_CONTEXT = False -RETURN_ANSWERS_QUESTION = True -RETURN_ANSWERS_SOLUTION = False -FINAL_SCORE_HISTOGRAM_PATH = Path("histogramme.pdf") -for _name in dir(_configuration): - if not _name.startswith("_"): - globals()[_name] = getattr(_configuration, _name) + values = { + name: getattr(default_config, name) + for name in dir(default_config) + if not name.startswith("_") + } + if path is not None: + # Preserve compatibility defaults for older personal configurations. + values.update(THINKING_LEVEL_FOR_LABEL="medium", THINKING_LEVEL_PRO="high") + module = _load_user_config(path) + values.update({ + name: getattr(module, name) + for name in dir(module) + if not name.startswith("_") + }) + # Historically the name recognizer inherited the label model when omitted. + if not hasattr(module, "MODEL_FOR_NAME_ID"): + values["MODEL_FOR_NAME_ID"] = values["MODEL_FOR_LABEL_ID"] + values["PAGE_SPLITTER_KB"] = { + "reverse_pages": "i", + "add_secondary_splitter": "", + "delete_secondary_splitter": "", + **values["PAGE_SPLITTER_KB"], + } + return values + + +CONFIG_PATH = _user_config_path() +_global_settings = _settings(CONFIG_PATH) +globals().update(_global_settings) +LOCAL_CONFIG_PATH: Path | None = None +_evaluation_overrides: set[str] = set() + + +def use_evaluation(root: str | Path | None) -> None: + """Apply selected evaluation settings, resetting any previous overrides.""" + global LOCAL_CONFIG_PATH, _evaluation_overrides + path = Path(root).expanduser().resolve() / "config.py" if root is not None else None + values = dict(_global_settings) + overrides = {} + if path is not None and path.is_file() and path != CONFIG_PATH: + module = _load_user_config(path) + overrides = { + name: getattr(module, name) + for name in dir(module) + if name.isupper() and name in _global_settings + } + values.update(overrides) + else: + path = None + for name in ("ANNOTATION_MAX_HEIGHT_PX", "ANNOTATION_MAX_PIXELS"): + if type(values[name]) is not int or values[name] <= 0: + raise ValueError(f"{name} must be a positive integer (pixels)") + values["PAGE_SPLITTER_KB"] = { + "reverse_pages": "i", + "add_secondary_splitter": "", + "delete_secondary_splitter": "", + **values["PAGE_SPLITTER_KB"], + } + for name in _evaluation_overrides | overrides.keys(): + globals()[name] = values[name] + _evaluation_overrides = set(overrides) + LOCAL_CONFIG_PATH = path diff --git a/copienator/utils.py b/copienator/utils.py index 1c31009..f49a2e1 100644 --- a/copienator/utils.py +++ b/copienator/utils.py @@ -1,6 +1,7 @@ import re from pathlib import Path +from copienator import configuration from copienator import EvaluationWorkspace from .platform import validate_windows_labels @@ -177,7 +178,6 @@ import tempfile import shutil import subprocess -from .configuration import LATEX_AFTER, LATEX_BEFORE LATEX_WARNING_MARKER = "[Copienator:latex-warning] " @@ -190,19 +190,19 @@ def report_latex_warning(output_pdf_path) -> None: def fragment_latex_header(source_path=None): """Keep source packages/macros while using the configured fragment layout.""" if source_path is None: - return LATEX_BEFORE + return configuration.LATEX_BEFORE source = Path(source_path).read_text(encoding="utf-8") # Ignore commented document commands when locating the preamble. source = re.sub(r"(?m)(? Path: local = self.root / "names" - if local.exists() or self.repository is None: + if local.is_file(): return local - return self.repository / "names" + repository = self.repository if self.repository is not None else Path.cwd() + return repository / "names" def read_labels(self) -> list[str]: return [ diff --git a/copienator_gui/app.py b/copienator_gui/app.py index a5b5342..4679850 100644 --- a/copienator_gui/app.py +++ b/copienator_gui/app.py @@ -11,7 +11,7 @@ from pathlib import Path from tkinter import filedialog, messagebox, ttk from typing import Any -from copienator import CliError, EvaluationWorkspace, ExitCode, atomic_write_json, read_json +from copienator import configuration, CliError, EvaluationWorkspace, ExitCode, atomic_write_json, read_json from copienator.commands.resolve_manual import pending_manual_conflicts from copienator.copy_errors import copy_errors from copienator.orientation import reports as orientation_reports @@ -275,6 +275,7 @@ class CopienatorApp(tk.Tk): super().__init__() self.repository = repository.resolve() self.show_personal_steps = show_personal_steps + self._global_personal_steps = show_personal_steps self.steps = build_workflow(show_personal_steps) self.step_by_id = {step.id: step for step in self.steps} self.state_store = StateStore() @@ -739,10 +740,22 @@ class CopienatorApp(tk.Tk): if not evaluation or not evaluation.is_dir(): messagebox.showerror("Dossier invalide", "Choisissez un dossier d’évaluation existant.") return + try: + configuration.use_evaluation(evaluation) + except Exception as exc: + messagebox.showerror("Configuration invalide", str(exc)) + return self.batch_monitor.stop() self._reset_latex_warnings() self._save_current_form() self.state_store.load(evaluation) + self.show_personal_steps = ( + bool(configuration.SHOW_PERSONAL_STEPS) + if configuration.LOCAL_CONFIG_PATH is not None + else self._global_personal_steps + ) + self.steps = build_workflow(self.show_personal_steps) + self.step_by_id = {step.id: step for step in self.steps} for step in self.steps: if self.state_store.step(step.id).get("status") == "running": self.state_store.update_step(step.id, status="interrupted") diff --git a/copienator_gui/workflow.py b/copienator_gui/workflow.py index 7990a62..6521323 100644 --- a/copienator_gui/workflow.py +++ b/copienator_gui/workflow.py @@ -8,7 +8,6 @@ from dataclasses import dataclass from pathlib import Path from copienator import configuration -from copienator.configuration import ALWAYS_CROP EVALUATION = "${evaluation}" EVALUATION_NAME = "${evaluation_name}" @@ -295,7 +294,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: ), optional=True, requires=("Copies",), - auto_start_first_visit=ALWAYS_CROP, + auto_start_first_visit=configuration.ALWAYS_CROP, ), StepDefinition( "cutleft", @@ -435,7 +434,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: ), optional=True, requires=("Copies/Copie*/*.pdf",), - auto_start_first_visit=ALWAYS_CROP, + auto_start_first_visit=configuration.ALWAYS_CROP, ), StepDefinition( "grouping", diff --git a/tests/test_add_final_score.py b/tests/test_add_final_score.py index 8cf4744..89fb2b1 100644 --- a/tests/test_add_final_score.py +++ b/tests/test_add_final_score.py @@ -44,7 +44,7 @@ class AddFinalScoreTests(unittest.TestCase): merged.write_bytes(b'merged') output = root / 'output' output.mkdir() - with patch.object(add_final_score, 'HISTOGRAM_PATH', histogram): + with patch.object(add_final_score.configuration, 'FINAL_SCORE_HISTOGRAM_PATH', histogram): add_final_score.copy_histogram(output) self.assertFalse((output / merged.name).exists()) add_final_score.copy_histogram(output, copy_merged_histogramme=True) @@ -54,7 +54,7 @@ class AddFinalScoreTests(unittest.TestCase): def test_cli_forwards_merged_histogram_option(self): with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) - with patch.object(add_final_score, 'OUTPUT_DIR', root / 'output'), patch.object( + with patch.object(add_final_score.configuration, 'FINAL_SCORE_OUTPUT_DIR', root / 'output'), patch.object( add_final_score, 'process_images' ) as process: self.assertEqual(add_final_score.main([str(root), '--copy-merged-histogramme']), 0) @@ -83,7 +83,7 @@ class AddFinalScoreTests(unittest.TestCase): histogram = root / "histogramme.pdf" histogram.write_bytes(b"histogram") with patch.object(add_final_score.pd, "read_excel", return_value=scores), \ - patch.object(add_final_score, "HISTOGRAM_PATH", histogram), \ + patch.object(add_final_score.configuration, "FINAL_SCORE_HISTOGRAM_PATH", histogram), \ contextlib.redirect_stdout(io.StringIO()): add_final_score.process_images(root, output) @@ -137,7 +137,7 @@ class AddFinalScoreTests(unittest.TestCase): with patch.object( add_final_score.pd, "read_excel", return_value=scores ), patch.object( - add_final_score, "HISTOGRAM_PATH", root / "missing.pdf" + add_final_score.configuration, "FINAL_SCORE_HISTOGRAM_PATH", root / "missing.pdf" ), contextlib.redirect_stdout(messages): add_final_score.process_images(root, output) diff --git a/tests/test_annotation_height_config.py b/tests/test_annotation_height_config.py index 23987c6..82f9b86 100644 --- a/tests/test_annotation_height_config.py +++ b/tests/test_annotation_height_config.py @@ -16,7 +16,7 @@ class AnnotationHeightConfigTests(unittest.TestCase): [sys.executable, "-c", "from copienator.commands import annotating_by_label as grouped; " "from copienator.commands import annotating_with_checks as checks; " - f"print(grouped.{attribute}, checks.{attribute})"], + f"print(grouped.annotation_limits()[{0 if attribute == 'MAX_HEIGHT_PX' else 1}], checks.annotation_limits()[{0 if attribute == 'MAX_HEIGHT_PX' else 1}])"], cwd=Path(__file__).resolve().parents[1], env={**os.environ, "COPIENATOR_CONFIG": str(config)}, capture_output=True, text=True, check=False, diff --git a/tests/test_annotation_import_cleanup.py b/tests/test_annotation_import_cleanup.py index 0f2f11a..bbf2274 100644 --- a/tests/test_annotation_import_cleanup.py +++ b/tests/test_annotation_import_cleanup.py @@ -24,7 +24,7 @@ class AnnotationImportCleanupTests(unittest.TestCase): (self.workspace.root / "correction.json").write_text("{}") self.import_dir = self.root / "Annotées" self.import_dir.mkdir() - self.patch = patch.object(import_annotations, "IMPORT_DIR", self.import_dir) + self.patch = patch.object(import_annotations.configuration, "IMPORT_DIR", self.import_dir) self.patch.start() self.addCleanup(self.patch.stop) @@ -45,12 +45,12 @@ class AnnotationImportCleanupTests(unittest.TestCase): def test_cleanup_handles_unconfigured_or_missing_folder(self): for configured in (None, "", self.root / "absent"): with self.subTest(configured=configured), \ - patch.object(import_annotations, "IMPORT_DIR", configured): + patch.object(import_annotations.configuration, "IMPORT_DIR", configured): import_annotations.clear_import_directory(self.workspace) self.assertFalse((self.root / "absent").exists()) def test_cleanup_rejects_an_ancestor_of_evaluation(self): - with patch.object(import_annotations, "IMPORT_DIR", self.root), \ + with patch.object(import_annotations.configuration, "IMPORT_DIR", self.root), \ self.assertRaises(CliError): import_annotations.clear_import_directory(self.workspace) self.assertTrue(self.workspace.root.is_dir()) diff --git a/tests/test_checked_documents.py b/tests/test_checked_documents.py index e3b50e5..c84a520 100644 --- a/tests/test_checked_documents.py +++ b/tests/test_checked_documents.py @@ -6,9 +6,10 @@ from unittest.mock import patch from PIL import Image, ImageDraw +from copienator import configuration from copienator import CliError, EvaluationWorkspace, ExitCode, read_json from copienator.annotation_data import AnnotationLoadResult -from copienator.annotation_documents import MAX_HEIGHT_PX, copy_documents +from copienator.annotation_documents import annotation_limits, copy_documents from copienator.commands import annotating_with_checks as checks from copienator.commands import export, import_annotations, reading_annotations as reader from copienator.commands import reading_grouped_annotations as grouped_reader @@ -46,7 +47,7 @@ class CheckedDocumentTests(unittest.TestCase): def generate(self, overwrite=False): with ( - patch.object(checks, 'MAX_HEIGHT_PX', 100), + patch.object(configuration, 'ANNOTATION_MAX_HEIGHT_PX', 100), patch.object(checks.annotating, 'make_base_image', return_value=(None, 0, 0)), patch.object(checks.annotating, 'compose_label_image', side_effect=self.compose), ): @@ -54,7 +55,7 @@ class CheckedDocumentTests(unittest.TestCase): overwrite=overwrite, output_mode='checks') def test_splits_on_labels_and_resets_geometry_at_each_part(self): - self.assertEqual(MAX_HEIGHT_PX, 33000) + self.assertEqual(annotation_limits()[0], 33000) self.assertEqual(self.generate(), 'success') parts = copy_documents(self.output) self.assertEqual([part.name for part in parts], ['Part1', 'Part2']) @@ -76,7 +77,7 @@ class CheckedDocumentTests(unittest.TestCase): self.assertFalse((self.output / 'Part1').exists()) def test_pixel_limit_splits_documents_at_exact_boundary(self): - with patch.object(checks, 'MAX_PIXELS', 6000): + with patch.object(configuration, 'ANNOTATION_MAX_PIXELS', 6000): self.assertEqual(self.generate(), 'success') parts = copy_documents(self.output) self.assertEqual([read_json(p / 'bnote.json')['height'] for p in parts], [60, 40, 60]) @@ -88,13 +89,13 @@ class CheckedDocumentTests(unittest.TestCase): if label == 'Ex 2': image = image.resize((200, image.height)) return image, header - with patch.object(self, 'compose', side_effect=wider), patch.object(checks, 'MAX_PIXELS', 12000): + with patch.object(self, 'compose', side_effect=wider), patch.object(configuration, 'ANNOTATION_MAX_PIXELS', 12000): self.assertEqual(self.generate(), 'success') parts = copy_documents(self.output) self.assertEqual([read_json(p / 'bnote.json')['width'] for p in parts], [100, 200, 100]) sentinel = self.output / 'sentinel' sentinel.write_text('previous') - with patch.object(checks, 'MAX_PIXELS', 5999): + with patch.object(configuration, 'ANNOTATION_MAX_PIXELS', 5999): with self.assertRaisesRegex(CliError, 'Copie01.*Ex 1'): self.generate(overwrite=True) self.assertEqual(sentinel.read_text(), 'previous') @@ -121,7 +122,7 @@ class CheckedDocumentTests(unittest.TestCase): def returned_parts(self): self.generate() export_root = self.root / 'Export' - with patch.object(export, 'EXPORT_DIR', export_root): + with patch.object(export.configuration, 'EXPORT_DIR', export_root): self.assertEqual(export.run(self.workspace, annotation_dir='Bnot'), ExitCode.SUCCESS) self.assertEqual(sorted(p.name for p in (export_root / 'Exam').iterdir()), ['Copie01 Part1.pdf', 'Copie01 Part2.pdf']) diff --git a/tests/test_gemini_config.py b/tests/test_gemini_config.py index 5319260..5cf70fb 100644 --- a/tests/test_gemini_config.py +++ b/tests/test_gemini_config.py @@ -99,7 +99,7 @@ class GeminiConfigTests(unittest.TestCase): ] config = types.GenerateContentConfig(thinking_config=thinking_config("high")) with patch.object(correction, "client", client), patch.object( - correction, "MODEL_ID_pro", "pro-model" + configuration, "MODEL_PRO_ID", "pro-model" ), patch.object(correction, "pro_quota_exhausted", False), patch.object( configuration, "THINKING_LEVEL_FLASH", "low" ), patch.object(correction, "tprint"), patch.object(correction, "stop_requested") as stop: diff --git a/tests/test_grouped_refaire.py b/tests/test_grouped_refaire.py index 84d5a2b..e1858d4 100644 --- a/tests/test_grouped_refaire.py +++ b/tests/test_grouped_refaire.py @@ -88,7 +88,7 @@ class GroupedRedoTests(unittest.TestCase): [("01", "Ex 1"), ("02", "Ex 1")], ) export_root = Path(self.temp.name) / "Export" - with patch.object(export, "EXPORT_DIR", export_root): + with patch.object(export.configuration, "EXPORT_DIR", export_root): self.assertEqual(export.run(self.workspace, refaire=True), ExitCode.SUCCESS) self.assertEqual(len(list((export_root / "Exam").glob("*.pdf"))), 1) imported_root = Path(self.temp.name) / "Import" @@ -99,7 +99,7 @@ class GroupedRedoTests(unittest.TestCase): draw.rectangle((17, 17, 43, 43), fill="black") draw.rectangle((70, 170, 90, 190), fill="black") annotated.save(imported_root / f"{directory.name}.pdf", "PDF", resolution=72) - with patch.object(import_annotations, "IMPORT_DIR", imported_root): + with patch.object(import_annotations.configuration, "IMPORT_DIR", imported_root): self.assertEqual( import_annotations.run(self.workspace, refaire=True), ExitCode.SUCCESS ) diff --git a/tests/test_gui_core.py b/tests/test_gui_core.py index 2da7ff5..a356453 100644 --- a/tests/test_gui_core.py +++ b/tests/test_gui_core.py @@ -18,6 +18,7 @@ from unittest.mock import Mock, patch from PIL import Image from pypdf import PdfReader, PdfWriter +from copienator import configuration from copienator import ( CliError, EvaluationWorkspace, @@ -531,7 +532,7 @@ class StandardCliTests(unittest.TestCase): evaluation = base / "Exam" evaluation.mkdir() export_dir = base / "Export" - with patch.object(module, "EXPORT_DIR", export_dir), redirect_stderr( + with patch.object(module.configuration, "EXPORT_DIR", export_dir), redirect_stderr( io.StringIO() ): self.assertEqual(module.main([str(evaluation)]), 3) @@ -706,7 +707,7 @@ class StandardCliTests(unittest.TestCase): source = evaluation / "BGnot" / "Ex 1" source.mkdir(parents=True) (source / "Concat.pdf").write_bytes(b"annotated") - with patch.object(module, "EXPORT_DIR", base / "Export"): + with patch.object(module.configuration, "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") @@ -721,7 +722,7 @@ class StandardCliTests(unittest.TestCase): 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"): + with patch.object(module.configuration, "EXPORT_DIR", base / "Export"): self.assertEqual(module.main([str(evaluation), annotation_dir]), 0) self.assertEqual( (base / "Export" / "Exam" / f"Copie01{suffix}").read_bytes(), @@ -738,7 +739,7 @@ class StandardCliTests(unittest.TestCase): import_dir = base / "Import" import_dir.mkdir() (import_dir / "Ex 1.pdf").write_bytes(b"handwritten") - with patch.object(module, "IMPORT_DIR", import_dir): + with patch.object(module.configuration, "IMPORT_DIR", import_dir): self.assertEqual(module.main([str(evaluation)]), 0) self.assertEqual( (target / "Concat_annotated.pdf").read_bytes(), b"handwritten" @@ -756,7 +757,7 @@ class StandardCliTests(unittest.TestCase): 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): + with patch.object(module.configuration, "IMPORT_DIR", import_dir): self.assertEqual(module.main([str(evaluation), annotation_dir]), 0) self.assertEqual( (target / f"Concat_annotated{suffix}").read_bytes(), b"handwritten" @@ -2323,7 +2324,7 @@ class StandardCliTests(unittest.TestCase): ("02", "Ex 1", small, 0, []), ("02", "Ex 2", small, 0, []), ] - with patch.object(module, "MAX_HEIGHT_PX", 100): + with patch.object(configuration, "ANNOTATION_MAX_HEIGHT_PX", 100): batches = module.split_batches(rendered) self.assertEqual([len(batch) for batch in batches], [1, 2]) self.assertTrue( @@ -2338,14 +2339,14 @@ class StandardCliTests(unittest.TestCase): ("01", "Ex 2", image, 0, []), ("02", "Ex 1", image, 0, []), ] - with patch.object(module, "MAX_HEIGHT_PX", 100): + with patch.object(configuration, "ANNOTATION_MAX_HEIGHT_PX", 100): batches = module.split_batches(rendered) self.assertEqual(batches, [[rendered[0]], [rendered[1]], [rendered[2]]]) def test_grouped_oversized_answer_is_rejected(self) -> None: module = self.modules["annotating_by_label"] rendered = [("02", "Ex 1", Image.new("RGB", (10, 120)), 0, [])] - with tempfile.TemporaryDirectory() as directory, patch.object(module, "MAX_HEIGHT_PX", 100): + with tempfile.TemporaryDirectory() as directory, patch.object(configuration, "ANNOTATION_MAX_HEIGHT_PX", 100): with self.assertRaisesRegex(CliError, "Copie02.*Ex 1"): module.split_batches(rendered) with self.assertRaises(CliError): @@ -2359,7 +2360,7 @@ class StandardCliTests(unittest.TestCase): ("02", "Ex 1", Image.new("RGB", (30, 20)), 0, []), ("03", "Ex 1", Image.new("RGB", (10, 40)), 0, []), ] - with patch.object(module, "MAX_HEIGHT_PX", 100), patch.object(module, "MAX_PIXELS", 2000): + with patch.object(configuration, "ANNOTATION_MAX_HEIGHT_PX", 100), patch.object(configuration, "ANNOTATION_MAX_PIXELS", 2000): batches = module.split_batches(rendered) self.assertEqual(batches, [[rendered[0], rendered[2]], [rendered[1]]]) with tempfile.TemporaryDirectory() as directory: @@ -2372,9 +2373,9 @@ class StandardCliTests(unittest.TestCase): module = self.modules["annotating_by_label"] image = Image.new("RGB", (30, 40)) rendered = [("01", label, image, 0, []) for label in ("Ex 1", "Ex 2")] - with patch.object(module, "MAX_HEIGHT_PX", 100), patch.object(module, "MAX_PIXELS", 1200): + with patch.object(configuration, "ANNOTATION_MAX_HEIGHT_PX", 100), patch.object(configuration, "ANNOTATION_MAX_PIXELS", 1200): self.assertEqual(module.split_batches(rendered), [[rendered[0]], [rendered[1]]]) - with patch.object(module, "MAX_PIXELS", 1199): + with patch.object(configuration, "ANNOTATION_MAX_PIXELS", 1199): with self.assertRaisesRegex(CliError, "single answer cannot be split"): module.split_batches(rendered) @@ -2387,7 +2388,7 @@ class StandardCliTests(unittest.TestCase): ("04", "Ex 1", Image.new("RGB", (10, 15)), 0, []), ("04", "Ex 2", Image.new("RGB", (10, 15)), 0, []), ] - with patch.object(module, "MAX_HEIGHT_PX", 100): + with patch.object(configuration, "ANNOTATION_MAX_HEIGHT_PX", 100): batches = module.split_batches(rendered) self.assertEqual([[item[0] for item in batch] for batch in batches], [["01", "04", "04"], ["02", "03"]]) @@ -2397,7 +2398,7 @@ class StandardCliTests(unittest.TestCase): def test_grouped_annotation_height_limit_is_33000_pixels(self) -> None: module = self.modules["annotating_by_label"] - self.assertEqual(module.MAX_HEIGHT_PX, 33000) + self.assertEqual(module.annotation_limits()[0], 33000) class WorkflowTests(unittest.TestCase): @@ -2511,7 +2512,7 @@ class WorkflowTests(unittest.TestCase): 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 + "copienator_gui.workflow.configuration.ALWAYS_CROP", enabled ): steps = {step.id: step for step in build_workflow(False)} self.assertEqual( diff --git a/tests/test_local_configuration.py b/tests/test_local_configuration.py new file mode 100644 index 0000000..c37f88a --- /dev/null +++ b/tests/test_local_configuration.py @@ -0,0 +1,151 @@ +"""Evaluation files override shared settings without leaking across evaluations.""" +import contextlib +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +from copienator import EvaluationWorkspace + + +REPOSITORY = Path(__file__).resolve().parents[1] + + +class LocalConfigurationTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.evaluation = self.root / "Exam" + self.evaluation.mkdir() + (self.evaluation / "labels").write_text("Ex 1\n") + self.global_config = self.root / "config.py" + self.global_config.write_text("MODEL_FLASH_ID = 'global-model'\nRETURN_PDF_ENABLED = False\n") + + def run_python(self, source, *, explicit=True): + env = dict(os.environ, PYTHONPATH=str(REPOSITORY)) + env.pop("COPIENATOR_CONFIG", None) + if explicit: + env["COPIENATOR_CONFIG"] = str(self.global_config) + result = subprocess.run([sys.executable, "-c", source], cwd=self.root, env=env, + capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + return result.stdout + + def test_partial_local_config_inherits_global_and_defaults(self): + (self.evaluation / "config.py").write_text("MODEL_FLASH_ID = 'local-model'\n") + for explicit in (False, True): + with self.subTest(explicit=explicit): + self.run_python(""" +from copienator import configuration as c +c.use_evaluation('Exam') +assert c.MODEL_FLASH_ID == 'local-model' +assert c.RETURN_PDF_ENABLED is False +assert c.RETURN_JPEG_ENABLED is True +assert c.CONFIG_PATH.name == 'config.py' +assert c.LOCAL_CONFIG_PATH.parent.name == 'Exam' +c.use_evaluation(None) +assert c.MODEL_FLASH_ID == 'global-model' +""", explicit=explicit) + + def test_switching_evaluations_removes_previous_overrides(self): + (self.evaluation / "config.py").write_text("RETURN_JPEG_ENABLED = False\n") + (self.root / "Other").mkdir() + self.run_python(""" +from copienator import configuration as c +c.use_evaluation('Exam') +assert c.RETURN_JPEG_ENABLED is False +c.use_evaluation('Other') +assert c.RETURN_JPEG_ENABLED is True +assert c.RETURN_PDF_ENABLED is False +assert c.LOCAL_CONFIG_PATH is None +""") + + def test_command_loads_config_for_directory_and_nested_target(self): + (self.evaluation / "config.py").write_text("MODEL_FLASH_ID = 'local-model'\n") + self.run_python(""" +from copienator import configuration as c +from copienator.cli import execute, evaluation_parser, target_parser +from copienator.commands import correction + +def check(args): + assert correction.settings.MODEL_FLASH_ID == 'local-model' + return 0 +assert execute(evaluation_parser('test'), ['Exam'], check) == 0 +c.use_evaluation(None) +assert execute(target_parser('test'), ['Exam/labels'], check) == 0 +""") + + def test_annotation_limits_and_gui_defaults_follow_active_config(self): + (self.evaluation / "config.py").write_text( + "ANNOTATION_MAX_HEIGHT_PX = 12345\nALWAYS_CROP = True\n") + self.run_python(""" +from copienator import configuration as c +from copienator.commands import annotating_by_label, annotating_with_checks +from copienator_gui.workflow import build_workflow +c.use_evaluation('Exam') +assert annotating_by_label.annotation_limits()[0] == 12345 +assert annotating_with_checks.annotation_limits()[0] == 12345 +steps = {step.id: step for step in build_workflow(False)} +assert steps['crop_blank_margins'].auto_start_first_visit +""") + + @unittest.skipUnless(os.environ.get("DISPLAY"), "GUI display unavailable") + def test_gui_switches_evaluation_settings(self): + (self.evaluation / "config.py").write_text("ALWAYS_CROP = True\n") + (self.root / "Other").mkdir() + self.run_python(""" +from pathlib import Path +from copienator import configuration as c +from copienator_gui.app import CopienatorApp +app = CopienatorApp(Path.cwd(), False, Path('Exam').resolve()) +try: + assert app.step_by_id['crop_blank_margins'].auto_start_first_visit + app.evaluation_var.set(str(Path('Other').resolve())) + app._load_evaluation() + assert not app.step_by_id['crop_blank_margins'].auto_start_first_visit + assert c.LOCAL_CONFIG_PATH is None +finally: + app.destroy() +""") + + def test_explicit_command_option_overrides_local_config(self): + (self.evaluation / "config.py").write_text("RETURN_ANSWERS_CONTEXT = True\n") + self.run_python(""" +from unittest.mock import patch +from copienator.commands import reading_annotations as command +from copienator import configuration as c + +def check(workspace, **options): + assert c.RETURN_ANSWERS_CONTEXT is True + assert options['return_answers_context'] is False + return 0 +with patch.object(command, 'run', check): + assert command.main(['Exam', '--no-return-answers-context']) == 0 +""") + + def test_invalid_local_config_reports_error_before_handler(self): + (self.evaluation / "config.py").write_text("ANNOTATION_MAX_HEIGHT_PX = 0\n") + self.run_python(""" +from unittest.mock import Mock +from copienator.cli import execute, evaluation_parser +handler = Mock() +assert execute(evaluation_parser('test'), ['Exam'], handler) == 1 +handler.assert_not_called() +""") + + def test_local_names_replace_global_including_an_empty_roster(self): + (self.root / "names").write_text("Global\n") + workspace = EvaluationWorkspace(self.evaluation, self.root) + self.assertEqual(workspace.names_file(), self.root / "names") + local = self.evaluation / "names" + local.write_text("Local\n") + self.assertEqual(workspace.names_file(), local) + local.write_text("") + self.assertEqual(workspace.names_file().read_text(), "") + with contextlib.chdir(self.root): + self.assertEqual(EvaluationWorkspace(self.evaluation).names_file(), local) + local.unlink() + self.assertEqual(EvaluationWorkspace(self.evaluation).names_file(), self.root / "names") diff --git a/tests/test_marked_copies.py b/tests/test_marked_copies.py index ae90a6e..69a8e1a 100644 --- a/tests/test_marked_copies.py +++ b/tests/test_marked_copies.py @@ -116,7 +116,7 @@ class MarkedCopiesTests(unittest.TestCase): def test_personal_crop_binding_follows_personal_workflow_option(self): from copienator.configuration import SHOW_PERSONAL_STEPS - self.assertEqual(cutleft.personnal_binding, bool(SHOW_PERSONAL_STEPS)) + self.assertEqual(cutleft.personal_bindings_enabled(), bool(SHOW_PERSONAL_STEPS)) def test_process_single_pdf_enlarges_crop_and_caps_it_at_page_edge(self): page = Image.new("RGB", (900, 300), "white") diff --git a/tests/test_name_grids.py b/tests/test_name_grids.py index c941c6a..6ffe72f 100644 --- a/tests/test_name_grids.py +++ b/tests/test_name_grids.py @@ -153,7 +153,7 @@ class NameGridTests(unittest.TestCase): self.assertEqual(len(requests), 4) self.assertEqual(requests[0].kwargs['model'], 'custom-name-model') self.assertEqual(requests[0].kwargs['config'].thinking_config.thinking_level.value, 'MEDIUM') - self.assertEqual(requests[1].kwargs['model'], labels.MODEL_ID) + self.assertEqual(requests[1].kwargs['model'], labels.config.MODEL_FOR_LABEL_ID) for request in requests[1:3]: text = request.kwargs['contents'][0].parts[1].text self.assertNotIn('Alice', text) diff --git a/tests/test_page_splitter_reverse.py b/tests/test_page_splitter_reverse.py index a1ee969..dda15b3 100644 --- a/tests/test_page_splitter_reverse.py +++ b/tests/test_page_splitter_reverse.py @@ -5,7 +5,8 @@ from unittest.mock import Mock import pymupdf -from copienator.commands.page_splitter import PDFPreviewer, PAGE_SPLITTER_KB +from copienator.commands.page_splitter import PDFPreviewer +from copienator.configuration import PAGE_SPLITTER_KB class ReversePagesTests(unittest.TestCase): diff --git a/tests/test_refaire_sessions.py b/tests/test_refaire_sessions.py index 7347099..ea5bdd7 100644 --- a/tests/test_refaire_sessions.py +++ b/tests/test_refaire_sessions.py @@ -120,7 +120,7 @@ class RefaireSessionTests(unittest.TestCase): output = self.workspace.annotation_dir("refaire") / "Ex 1 G1" output.mkdir(parents=True) (output / "Concat.pdf").write_bytes(expected_id.encode()) - with patch.object(export, "EXPORT_DIR", export_root): + with patch.object(export.configuration, "EXPORT_DIR", export_root): self.assertEqual( export.run(self.workspace, refaire=True), ExitCode.SUCCESS ) @@ -131,7 +131,7 @@ class RefaireSessionTests(unittest.TestCase): if expected_id == first: (import_root / exported.name).write_bytes(b"old return") current = self.workspace.annotation_dir("refaire") / "Ex 1 G1" - with patch.object(import_annotations, "IMPORT_DIR", import_root): + with patch.object(import_annotations.configuration, "IMPORT_DIR", import_root): self.assertEqual( import_annotations.run(self.workspace, refaire=True), ExitCode.PARTIAL ) diff --git a/tests/test_statement_personal_flow.py b/tests/test_statement_personal_flow.py index 09c2910..d6f131e 100644 --- a/tests/test_statement_personal_flow.py +++ b/tests/test_statement_personal_flow.py @@ -325,7 +325,7 @@ class SelectiveGeminiTests(unittest.TestCase): for label in self.labels: (self.root / 'Persp' / label).write_text(label + '\nReviewed rubric') before = self.snapshot() - with patch.object(gemini, 'api_key', ''), patch.object(gemini.genai, 'Client') as client: + with patch.object(gemini.config, 'API_KEY', ''), patch.object(gemini.genai, 'Client') as client: self.assertEqual(gemini.refine_existing(self.workspace, 'persp'), ExitCode.SUCCESS) client.assert_not_called() self.assertEqual(self.snapshot(), before)