Restructuration de l'application

This commit is contained in:
2026-08-22 13:39:20 +02:00
parent dc25b5fefa
commit b9e9d00e7d
44 changed files with 561 additions and 8675 deletions
+7 -16
View File
@@ -56,21 +56,10 @@ Les codes de sortie communs sont :
| 4 | traitement partiel, avec avertissements |
| 130 | interruption par l'utilisateur |
Le GUI distingue notamment un traitement partiel d'un échec. Les
scripts migrés vers cette convention sont actuellement :
- =copies_tools.py=, =grouping.py= et =verify_groups.py= ;
- =post-correction.py= et =resolve_manual.py= ;
- =page_splitter.py=, =cutleft.py=, =plotting.py= et
=splitting_int.py= ;
- =gemini_for_labels.py= ;
- =gemini_for_enonce.py= et =enonce_info.py= ;
- =correction.py=, =submit_batches.py=, =batch_status.py= et
=fetch_batched_results.py= ;
- =annotating.py=, =annotating_with_checks.py= et
=annotating_by_label.py= ;
- =reading_annotations.py= et =reading_grouped_annotations.py= ;
- =export.py=, =import.py= et =giving_names.py=.
Le GUI distingue notamment un traitement partiel d'un échec. Toutes les
commandes du paquet suivent désormais cette convention. Elles sont
regroupées dans =copienator.commands= et exposées par le répartiteur
=python -m copienator=.
* Organisation du code
@@ -78,8 +67,10 @@ scripts migrés vers cette convention sont actuellement :
scripts, notamment =EvaluationWorkspace= et les écritures atomiques.
- Le paquet =copienator_gui= contient la définition du workflow, l'état
persistant, le lanceur de processus et l'application Tk.
- Les scripts situés à la racine restent des points d'entrée autonomes.
- Le sous-paquet =copienator.commands= contient les points d'entrée.
Leur import ne doit pas déclencher de traitement.
- =copienator.dispatcher= expose le CLI unifié et =copienator_gui=
contient l'application Tk.
* État et exécution du GUI
+7 -2
View File
@@ -41,8 +41,13 @@ Libraries :
#+BEGIN_SRC bash
pip install numpy pandas matplotlib pillow pydantic pypdf pdf2image reportlab img2pdf pymupdf ftfy ezodf google
pip install -e .
#+END_SRC
La seconde commande installe les exécutables =copienator= et
=copienator-gui=. Sans installation, les mêmes commandes restent
accessibles avec =python -m copienator=.
*** Poppler (for pdf2image)
+ Linux : install poppler-utils
@@ -79,10 +84,10 @@ Copier `default_config.py` en `config.py`. Éventuellement le modifier.
Lancer l'assistant avec :
#+BEGIN_SRC bash
python gui.py
python -m copienator gui
#+END_SRC
On peut aussi ouvrir directement une évaluation avec =python gui.py
On peut aussi ouvrir directement une évaluation avec =python -m copienator gui
Interro=. L'interface conserve l'état et l'historique des étapes dans
=Interro/.copienator-gui.json=, et les sorties complètes dans
=Interro/.copienator/logs/=. Une relance d'une étape antérieure ne
+60 -61
View File
@@ -11,7 +11,7 @@ les parcours alternatifs.
* Étapes et Script
Utiliser `python gui.py` ou `python gui.py Interro` pour lancer un GUI
Utiliser `python -m copienator gui` ou `python -m copienator gui Interro` pour lancer un GUI
qui suit automatiquement les étapes décrites ci-dessous.
** Prétraitement de l'énoncé
@@ -20,8 +20,8 @@ Dans le dossier de l'évaluation, mettre les fichiers suivants de l'évaluation
`enonce.pdf`, `enonce.tex`, `correction.tex`.
- `python gemini_for_enonce.py Interro` or
`python gemini_for_enonce.py Interro --restart`
- `python -m copienator statement Interro` or
`python -m copienator statement Interro --restart`
À partir des trois fichiers précédents, se charge de détecter les
labels des questions et leur contenu.
@@ -52,25 +52,25 @@ Dans le dossier de l'évaluation, mettre les fichiers suivants de l'évaluation
Éventuellement : vérifier et modifier les barèmes dans `Persp`.
- Alternative personnelle : `python enonce_info.py Interro`
- Alternative personnelle : `python -m copienator statement-personal Interro`
Ces deux commandes suivent la convention des scripts standardisés.
Leur import ne lance aucun traitement et les erreurs partielles sont
distinguées des échecs. Les réponses d'extraction mises en cache par
=gemini_for_enonce.py= et le fichier =labels= sont publiés
=copienator statement= et le fichier =labels= sont publiés
atomiquement.
** Prétraitement des copies
Mettre les copies scannées au format pdf dans =Interro=.
1. =python copies_tools.py rotate Interro= (facultatif)
1. =python -m copienator copies rotate Interro= (facultatif)
Retourne tous les pdf de 180°, si la photocopie a été faite à
l'envers.
2. =python copies_tools.py rename Interro=
2. =python -m copienator copies rename Interro=
change le nom des copies en =Copie{id}.pdf=
3. =python page_splitter.py Interro=
3. =python -m copienator page-split Interro=
Découpe des copies A3 en pages A4, en retirant les pages vides.
@@ -80,21 +80,21 @@ Mettre les copies scannées au format pdf dans =Interro=.
+ de jeter les deux pages
+ de déplacer la délimitation à droite/gauche
Fix issues with =python page_splitter.py Interro14/Copies/Copie01.pdf=
Fix issues with =python -m copienator page-split Interro14/Copies/Copie01.pdf=
Le PDF transformé est construit dans un dossier temporaire. La
copie produite et la sauvegarde dans =Copies Originales= sont
ensuite installées avec rollback : une erreur conserve les deux
versions précédentes. Une relance ciblée lit directement la
sauvegarde originale sans la déplacer au préalable.
4. =python cutleft.py Interro=
4. =python -m copienator crop-labels Interro=
Découpe la partie gauche des copies, là où il devrait y avoir les
labels des exercices/questions.
=python cutleft.py Interro --fullpage= to use the full page always.
=python -m copienator crop-labels Interro --fullpage= to use the full page always.
Rerun on a single file with =python cutleft.py Interro/Copies/Copie01.pdf=
Rerun on a single file with =python -m copienator crop-labels Interro/Copies/Copie01.pdf=
Les images et le fichier =_schema.json= d'une copie sont remplacés
ensemble. Fermer l'outil juste après la dernière validation ne peut
@@ -104,7 +104,7 @@ Mettre les copies scannées au format pdf dans =Interro=.
Optional : Set proxy with ~export HTTPS_PROXY="http://10.0.0.1:3128"~
1. =python gemini_for_labels.py Interro=, avec éventuellement =--overwrite=
1. =python -m copienator labels Interro=, avec éventuellement =--overwrite=
Fait des requêtes à Gemini pour identifier les labels des
questions dans images générées à partir des parties gauches des copies.
@@ -116,7 +116,7 @@ Optional : Set proxy with ~export HTTPS_PROXY="http://10.0.0.1:3128"~
copies différentes sont traitées en parallèle. Chaque réponse JSON
validée est écrite atomiquement. Une cible sans image correspondante
produit le code de sortie 4.
2. =python plotting.py Interro=
2. =python -m copienator review-labels Interro=
Permet de vérifier visuellement les labels trouvés.
+ Sous linux, on peut faire =e= pour ouvrir le fichier .json et
@@ -128,7 +128,7 @@ Optional : Set proxy with ~export HTTPS_PROXY="http://10.0.0.1:3128"~
+ `|…` n'est pas arrêté verticalement par son type opposé.
+ `…|` est stoppé horizontalement par le `|…` le plus proche.
Pour modifier une seule copie :
=python plotting.py Interro/Copies/Copie01.pdf=
=python -m copienator review-labels Interro/Copies/Copie01.pdf=
Les coordonnées agrégées sont écrites atomiquement dans le JSON de
la copie. Fermer la fenêtre avant la fin d'une copie ne remplace pas
@@ -137,9 +137,9 @@ Optional : Set proxy with ~export HTTPS_PROXY="http://10.0.0.1:3128"~
It also generates les =Copie01.json=, à partir des =Copie01_01.json=
En cas de soucis, (par exemple les pages ne sont pas dans le bon ordre)
- Réordonner les pages du fichier pdf
- Rerun =python cutleft.py Interro/Copie{id}=
- Rerun =python gemini_for_labels.py Interro/Copie{id}=
3. =python splitting_int.py Interro=
- Rerun =python -m copienator crop-labels Interro/Copie{id}=
- Rerun =python -m copienator labels Interro/Copie{id}=
3. =python -m copienator split-answers Interro=
Découpe les copies suivant les exercices
Peut-être appelé avec une seule copie.
@@ -148,11 +148,11 @@ Optional : Set proxy with ~export HTTPS_PROXY="http://10.0.0.1:3128"~
puis remplacent ensemble le dossier précédent. En cas d'erreur,
l'ancienne version est conservée. Les réponses devenues obsolètes
restent archivées dans le sous-dossier =Missing=.
4. =python grouping.py Interro=
4. =python -m copienator group-answers Interro=
Regroupe les mêmes questions de différentes copies en groupes de
tailles raisonnables.
5. Facultatif : =python verify_groups.py Interro=
5. Facultatif : =python -m copienator verify-groups Interro=
Vérifie que chaque réponse PDF apparaît bien dans les métadonnées
des groupes. La commande renvoie un code non nul si une réponse est
@@ -163,14 +163,14 @@ Optional : Set proxy with ~export HTTPS_PROXY="http://10.0.0.1:3128"~
Optional : Set proxy with ~export HTTPS_PROXY="http://10.0.0.1:3128"~
1. Il faut créer des persp, pour indication de comment corriger, et
relancer =enonce_info.py=
2. =python correction.py Interro --limit 240= OU
=python correction.py Interro/Par\ label/Ex\ 2/Group_1.jpg= OU
=python correction.py Interro --overwrite=
relancer =copienator statement-personal=
2. =python -m copienator correct Interro --limit 240= OU
=python -m copienator correct Interro/Par\ label/Ex\ 2/Group_1.jpg= OU
=python -m copienator correct Interro --overwrite=
Fais les requêtes de correction à Gemini.
=correction.py= peut être relancé sans supprimer son état. Les
=copienator correct= peut être relancé sans supprimer son état. Les
fichiers =correction.json= et =correction_progress.json= sont mis à
jour atomiquement. Avec =--overwrite=, leur version précédente reste
en place jusqu'à la première écriture réussie de la nouvelle
@@ -183,23 +183,23 @@ Optional : Set proxy with ~export HTTPS_PROXY="http://10.0.0.1:3128"~
Pour diminuer le coût, il est possible de batch les requêtes, qui
seront alors traitées sous au plus 24h.
+ =python correction.py Interro --batch=
+ OU =python correction.py Interro --batch-from 'Ex 4'=
+ =python submit_batches.py Interro=
+ =python batch_status.py=
+ =python fetch_batched_results.py Interro=
+ =python correction.py Interro --deal-with-batched=
+ =python -m copienator correct Interro --batch=
+ OU =python -m copienator correct Interro --batch-from 'Ex 4'=
+ =python -m copienator batch-submit Interro=
+ =python -m copienator batch-status=
+ =python -m copienator batch-fetch Interro=
+ =python -m copienator correct Interro --deal-with-batched=
Les quatre commandes de ce flux suivent la convention des scripts
standardisés. Les fichiers de requêtes et le résultat JSONL combiné
sont publiés atomiquement : une interruption ne laisse pas de fichier
final partiellement écrit. =submit_batches.py= conserve aussi les
final partiellement écrit. =copienator batch-submit= conserve aussi les
identifiants distants dans =batch_jobs.json= ; la récupération les
utilise en priorité et garde la recherche par nom pour les anciens
batchs. =batch_status.py --download JOB --output
batchs. =python -m copienator batch-status --download JOB --output
resultat.jsonl= permet aussi de télécharger atomiquement le résultat
d'un job particulier.
3. =python post-correction.py Interro=
3. =python -m copienator post-correction Interro=
- Essaye de corriger des erreurs d'encodage/d'accents dans
=correction.json=.
@@ -214,29 +214,29 @@ Optional : Set proxy with ~export HTTPS_PROXY="http://10.0.0.1:3128"~
+ `xx` : move to goal.
+ `xs` : remove old, keep goal.
Then call `python resolve_manual.py Interro`
5. Call `python correction.py Interro --refaire`.
Then call `python -m copienator resolve-manual Interro`
5. Call `python -m copienator correct Interro --refaire`.
** Génération des copies annotées
1. =python annotating.py Interro= (facultatif)
1. =python -m copienator annotate-simple Interro= (facultatif)
Ajoute les annotations Gemini aux copies, enregistrées dans le dossier =Anot=.
On peut passer l'argument =--overwrite=.
OU
2. =python annotating_with_checks.py Interro=
2. =python -m copienator annotate-checks Interro=
Ajoute les annotations Gemini, et des checkboxes à cocher.
Enregistrées dans le dossier =Bnot=,
=--overwrite=
Une seule copie peut être ciblée avec, par exemple,
=python annotating_with_checks.py Interro/Copies/Copie01.pdf=.
=python -m copienator annotate-checks Interro/Copies/Copie01.pdf=.
Le mode =--refaire= exige un fichier =refaire.json= et écrit dans
=BRnot=.
OU
2. =python annotating_by_label.py Interro= dans =BGnot=
2. =python -m copienator annotate-grouped Interro= dans =BGnot=
Ajoute les annotations Gemini, et des checkboxes, et regroupe les
réponses par question.
@@ -245,7 +245,7 @@ OU
_Needs_ : label_groups file (made automatically by this function),
qui dit quelles questions regrouper.
3. =python export.py Interro BGnot= (gestion perso)
3. =python -m copienator export Interro BGnot= (gestion perso)
Cela déplace les groupes dans le dossier configuré par =EXPORT_DIR=
(par défaut =Export=).
@@ -263,7 +263,7 @@ tablette graphique.
_Before_ : vider le dossier configuré par =IMPORT_DIR= (par défaut
=Import=), puis y copier ou synchroniser les fichiers depuis la tablette.
1. =python import.py Interro BGnot=
1. =python -m copienator import Interro BGnot=
Une fois les corrections manuelles appliquées aux fichiers
=Concat.pdf=, il faut enregistrer le fichier annoté au même endroit,
@@ -274,7 +274,7 @@ _Before_ : vider le dossier configuré par =IMPORT_DIR= (par défaut
export. Pour =Anot=, l'image est importée sous le nom
=Concat_annotated.jpg=.
2. =python reading_annotations.py Interro=
2. =python -m copienator read-annotations Interro=
Lit les =Concat_annotated= dans =Bnot=, regénère les copies avec
les modifications. Les fichiers générés (=score.json=,
@@ -282,12 +282,12 @@ _Before_ : vider le dossier configuré par =IMPORT_DIR= (par défaut
ensemble. Les entrées du dossier =Bnot= restent en place et une
erreur de génération conserve les anciennes sorties.
OU
2. =python reading_grouped_annotations.py Interro=
2. =python -m copienator read-grouped Interro=
Idem, mais pour =BGnot=. Les tâches parallèles remontent leurs
erreurs au processus principal au lieu de les ignorer.
3. =python giving_names.py Interro BGnot=
3. =python -m copienator giving-names Interro BGnot=
Crée un dossier =A Rendre= avec des liens symboliques vers
+ La copie à rendre
@@ -297,17 +297,17 @@ OU
4. Éventuellement, faire des modifications manuelles aux =score.json=.
Puis
- `python reading_annotations.py --update-score Interro`
- `python reading_grouped_annotations.py --update-score Interro`
- `python -m copienator read-annotations --update-score Interro`
- `python -m copienator read-grouped --update-score Interro`
pour mettre à jour les scores dans les images.
4. (gestion perso)
+ =gestion_classe ne= pour créer l'interro puis
+ =gestion_classe we= (set barème here)
+ =python update_ods.py Interro=
ou =python update_ods.py Interro --sum= (en l'absence de barème)
+ =python -m copienator update-ods Interro=
ou =python -m copienator update-ods Interro --sum= (en l'absence de barème)
+ =gestion_classe re=
+ =gestion_classe wsent=
+ =python add_final_score.py Interro21=
+ =python -m copienator add-final-score Interro21=
(this makes files in =Server/copies=)
5. (gestion perso)
+ Deploy =miqmacs-copies-assets=, and
@@ -321,8 +321,8 @@ OU
groupée into refaire !!
1. Redécoupage
+ =python plotting.py InterroTest/Copie01.pdf=
+ =python splitting_int.py InterroTest/Copie20.pdf=
+ =python -m copienator review-labels InterroTest/Copie01.pdf=
+ =python -m copienator split-answers InterroTest/Copie20.pdf=
2. Créer =refaire.json=, avec un contenu comme
: [["Copie02", []],
: ["Copie01", ["Ex 1 : 1)"]]]
@@ -333,10 +333,10 @@ groupée into refaire !!
Ou non, si tu veux le faire à la main.
4. ?? Si je fais refaire, avant d'avoir créer les annotating with
checks, que se passe-t-il ???
5. Appeler =annotating_with_checks.py --refaire --overwrite=
6. =python export.py --refaire Interro24=
6. =python import.py --refaire Interro24=
7. =python reading_grouped_annotations.py --refaire Interro24=
5. Appeler =python -m copienator annotate-checks --refaire --overwrite=
6. =python -m copienator export --refaire Interro24=
6. =python -m copienator import --refaire Interro24=
7. =python -m copienator read-grouped --refaire Interro24=
Avec =--refaire=, =refaire.json= et le dossier =BRnot= sont des
prérequis obligatoires ; leur absence produit le code de sortie 3.
@@ -344,11 +344,10 @@ groupée into refaire !!
** Exemple de replotting, refaire d'une copie
1. replot it.
2. `python splitting_int.py DS09VA/Copies/Copie25.pdf`
2. `python -m copienator split-answers DS09VA/Copies/Copie25.pdf`
this will get rid of old/new.
!! Attention, et si ça dégage un new : bad bad bad.
3. Make `refaire.json`, avec la copie, et les labels à refaire.
4. `python correction.py DS09VA --refaire`
5. `python annotating_with_checks.py DS09VA --refaire`
6. `python import.py Interro24 --refaire`
4. `python -m copienator correct DS09VA --refaire`
5. `python -m copienator annotate-checks DS09VA --refaire`
6. `python -m copienator import Interro24 --refaire`
+5
View File
@@ -0,0 +1,5 @@
from .dispatcher import main
if __name__ == "__main__":
raise SystemExit(main())
+5
View File
@@ -0,0 +1,5 @@
"""Executable Copienator commands.
Command modules expose a ``main(argv=None)`` entry point and may also expose
their processing functions for reuse and tests.
"""
@@ -7,7 +7,7 @@ from pathlib import Path
import pandas as pd
from PIL import Image, ImageDraw, ImageFont
from config import FINAL_SCORE_FONT_PATH, FINAL_SCORE_ODS_PATH, FINAL_SCORE_OUTPUT_DIR
from copienator.configuration import FINAL_SCORE_FONT_PATH, FINAL_SCORE_ODS_PATH, FINAL_SCORE_OUTPUT_DIR
# Configuration constants
ODS_PATH = Path(FINAL_SCORE_ODS_PATH).expanduser()
@@ -112,14 +112,17 @@ def process_images(base_dir, output_dir):
shutil.copy(str(pdf_path), str(save_path))
if __name__ == "__main__":
def main(argv=None):
parser = argparse.ArgumentParser(description="Stamp scores on exam copies.")
parser.add_argument("dir", type=Path, help="Root directory containing 'A Rendre' folder")
args = parser.parse_args()
args = parser.parse_args(argv)
base_dir = args.dir.expanduser().resolve()
output_dir = OUTPUT_DIR / base_dir.name
output_dir.mkdir(parents=True, exist_ok=True)
process_images(base_dir, output_dir)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -19,8 +19,8 @@ from matplotlib import pyplot as plt
from pdf2image import convert_from_path
from PIL import Image, ImageDraw, ImageFont
import utils
from config import LATEX_ANOT_AFTER, LATEX_ANOT_BEFORE
from copienator import utils
from copienator.configuration import LATEX_ANOT_AFTER, LATEX_ANOT_BEFORE
from copienator import (
EvaluationWorkspace,
ExitCode,
@@ -31,7 +31,7 @@ from copienator import (
)
from copienator.annotation_data import load_annotation_data
from copienator.filesystem import staged_directory
from utils import natural_key
from copienator.utils import natural_key
MARGIN_LEFT = 300
ANNOT_WIDTH = 600
@@ -10,9 +10,9 @@ from typing import Any
from PIL import Image, ImageDraw
from reportlab.pdfgen import canvas
import annotating
import annotating_with_checks
import utils
from copienator.commands import annotating
from copienator.commands import annotating_with_checks
from copienator import utils
from copienator import (
CliError,
EvaluationWorkspace,
@@ -26,7 +26,7 @@ from copienator import (
)
from copienator.annotation_data import load_annotation_data
from copienator.filesystem import staged_directory
from utils import natural_key
from copienator.utils import natural_key
MAX_HEIGHT_PX = 25000
@@ -14,8 +14,8 @@ matplotlib.use("Agg")
from PIL import Image, ImageFont
from reportlab.pdfgen import canvas
import annotating
import utils
from copienator.commands import annotating
from copienator import utils
from copienator import (
CliError,
EvaluationWorkspace,
@@ -28,7 +28,7 @@ from copienator import (
)
from copienator.annotation_data import load_annotation_data
from copienator.filesystem import staged_directory
from utils import natural_key
from copienator.utils import natural_key
BOX_SIZE = 30
SCORE_BOX_SIZE = 40
@@ -328,3 +328,4 @@ def main(argv: Sequence[str] | None = None) -> int:
if __name__ == "__main__":
raise SystemExit(main())
@@ -6,7 +6,7 @@ from pathlib import Path
from google import genai
import config
from copienator import configuration as config
from copienator import (
CliError,
ExitCode,
@@ -134,3 +134,4 @@ def main(argv: Sequence[str] | None = None) -> int:
if __name__ == "__main__":
raise SystemExit(main())
@@ -15,9 +15,9 @@ from pathlib import Path
from google import genai
import config
import grouping
import prompting
from copienator import configuration as config
from copienator.commands import grouping
from copienator import prompting
from copienator import (
CliError,
EvaluationWorkspace,
@@ -29,7 +29,7 @@ from copienator import (
target_parser,
workspace_from_target,
)
from utils import enonce_total, read_all_labels
from copienator.utils import enonce_total, read_all_labels
NB_THREADS = 12
@@ -874,7 +874,7 @@ def run_configured(args: argparse.Namespace) -> ExitCode:
+ "\n",
)
print(f"\n[!] Unresolved delayed tasks found! Wrote to {manual_path}.")
print(" Please edit it manually, then run `python resolve_manual.py <InputDir>`")
print(" Please edit it manually, then run `python -m copienator resolve-manual <InputDir>`")
end_time = time.time()
print("Time elapsed : ", end_time - start_time)
@@ -884,7 +884,7 @@ def run_configured(args: argparse.Namespace) -> ExitCode:
for (err, file) in errors_summary:
print(err, file=sys.stderr)
escaped_path = shlex.quote(str(file))
print(f"Run : python correction.py {escaped_path}")
print(f"Run : python -m copienator correct {escaped_path}")
return ExitCode.PARTIAL if errors_summary else ExitCode.SUCCESS
@@ -341,3 +341,4 @@ def main(argv: Sequence[str] | None = None) -> int:
if __name__ == "__main__":
raise SystemExit(main())
@@ -17,8 +17,8 @@ from copienator import (
execute,
workspace_from_args,
)
from platform_utils import WindowsLabelError, validate_windows_labels
from utils import compile_to_pdf
from copienator.platform import WindowsLabelError, validate_windows_labels
from copienator.utils import compile_to_pdf
def fetch_and_save_sub_text(ex_id, indices, label, text_path):
@@ -293,3 +293,4 @@ def main(argv: Sequence[str] | None = None) -> int:
if __name__ == "__main__":
raise SystemExit(main())
+2 -2
View File
@@ -3,7 +3,7 @@ import sys
from collections.abc import Sequence
from pathlib import Path
from config import EXPORT_DIR
from copienator.configuration import EXPORT_DIR
from copienator import (
EvaluationWorkspace,
ExitCode,
@@ -11,7 +11,7 @@ from copienator import (
execute,
workspace_from_args,
)
from platform_utils import replace_with_link_or_copy
from copienator.platform import replace_with_link_or_copy
ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot")
@@ -5,7 +5,7 @@ from collections.abc import Sequence
from google import genai
import config
from copienator import configuration as config
from copienator import (
CliError,
EvaluationWorkspace,
@@ -10,8 +10,8 @@ from google import genai
from google.genai import types
from pydantic import BaseModel, Field
import config
import utils
from copienator import configuration as config
from copienator import utils
from copienator import (
CliError,
EvaluationWorkspace,
@@ -21,8 +21,8 @@ from copienator import (
execute,
workspace_from_args,
)
from platform_utils import validate_windows_labels
from utils import compile_to_pdf
from copienator.platform import validate_windows_labels
from copienator.utils import compile_to_pdf
def get_lcp(s1: str, s2: str) -> str:
@@ -13,7 +13,7 @@ from google import genai
from google.genai import types
from pydantic import BaseModel, Field
import config
from copienator import configuration as config
from copienator import (
CliError,
EvaluationWorkspace,
@@ -24,7 +24,7 @@ from copienator import (
target_parser,
workspace_from_target,
)
from utils import natural_key, read_all_labels
from copienator.utils import natural_key, read_all_labels
MODEL_ID = config.MODEL_FOR_LABEL_ID
api_key = config.API_KEY
@@ -422,7 +422,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().parent
args, repository=Path(__file__).resolve().parents[2]
)
targets = [target]
for additional in args.additional_targets:
@@ -15,7 +15,7 @@ from copienator import (
read_json,
workspace_from_args,
)
from platform_utils import replace_with_link_or_copy, safe_filename
from copienator.platform import replace_with_link_or_copy, safe_filename
ANNOTATION_CHOICES = ("BGnot", "Bnot", "Anot")
@@ -163,3 +163,4 @@ def main(argv: Sequence[str] | None = None) -> int:
if __name__ == "__main__":
raise SystemExit(main())
@@ -219,7 +219,7 @@ def create_jpg(identifier, group_index, group, root_dir):
print(f"Saved {output_path} with {len(group)} ({os.path.getsize(output_path)/1024/1024:.2f} MB)")
from utils import natural_key
from copienator.utils import natural_key
def process_identifier(identifier, files_info, output_dir):
@@ -275,3 +275,4 @@ def main(argv: Sequence[str] | None = None) -> int:
if __name__ == "__main__":
raise SystemExit(main())
@@ -4,7 +4,7 @@ import sys
from collections.abc import Sequence
from pathlib import Path
from config import IMPORT_DIR
from copienator.configuration import IMPORT_DIR
from copienator import (
EvaluationWorkspace,
ExitCode,
+87
View File
@@ -0,0 +1,87 @@
import json
import sys
import concurrent.futures
from pathlib import Path
from copienator.commands import correction
def get_missing_tasks():
"""
Identifies tasks (groups) where NONE of the student IDs in that group
appear in the existing results for that label in correction.json.
"""
missing = []
# correction.results is already loaded from correction.json during 'from copienator.commands import correction'
# correction.tasks is populated with (filepath, label) during 'from copienator.commands import correction'
for task in correction.tasks:
file_path, label = task
# Find the group metadata file (Group_X.json) to know which IDs are inside
meta_path = Path(file_path).with_suffix('.json')
if not meta_path.exists():
print("Missing meta_path :", meta_path)
continue
with open(meta_path, 'r', encoding="utf-8") as f:
# group_data entries: [pid, ymin, ymax, width_ratio]
group_data = json.load(f)
pids_in_group = [str(item[0]) for item in group_data]
# Check correction.json results for this specific label
label_results = correction.results.get(label, [])
# Collect all student IDs that have already been processed for this label
covered_ids = set()
for result_list in label_results:
for entry in result_list:
covered_ids.add(str(entry.get('id')))
# Logic: Only process if EVERY ID in this group is missing from correction.json
if all(pid not in covered_ids for pid in pids_in_group):
missing.append(task)
return missing
def main(argv=None):
if argv:
print("missing-correction does not accept command-line arguments.", file=sys.stderr)
return 2
missing_tasks = get_missing_tasks()
print("\n Total nb of tasks : ", len(correction.tasks))
if not missing_tasks:
print("All groups are already present in correction.json. Nothing to do.")
return
print("\nThe following groups are missing from correction.json:")
for path, label in missing_tasks:
print(f" - [{label}] {path}")
confirm = input(f"\nFound {len(missing_tasks)} missing groups. Start processing? (y/N): ")
if confirm.lower() != 'y':
print("Aborted.")
return
print(f"Processing {len(missing_tasks)} tasks with {correction.NB_THREADS} threads...")
with concurrent.futures.ThreadPoolExecutor(max_workers=correction.NB_THREADS) as executor:
# Map tasks to the processing function defined in correction.py
futures = {executor.submit(correction.process_single_task, t): t for t in missing_tasks}
for future in concurrent.futures.as_completed(futures):
try:
# Handle potential sub-tasks (like label errors) generated during processing
new_generated_tasks = future.result()
if new_generated_tasks:
for nt in new_generated_tasks:
executor.submit(correction.process_single_task, nt)
except Exception as e:
t = futures[future]
print(f"Error processing {t[0]}: {e}")
print("\nProcessing complete.")
if __name__ == "__main__":
raise SystemExit(main())
@@ -16,7 +16,7 @@ import fitz # PyMuPDF
from PIL import Image, ImageDraw, ImageTk
from pypdf import PdfReader, PdfWriter
from config import PAGE_SPLITTER_KB
from copienator.configuration import PAGE_SPLITTER_KB
from copienator import (
CliError,
EvaluationWorkspace,
@@ -25,7 +25,7 @@ from copienator import (
target_parser,
workspace_from_target,
)
from platform_utils import launch_pdf_arranger
from copienator.platform import launch_pdf_arranger
# --- Constants ---
# Conversion factor: 1 cm to points (1 inch = 2.54 cm, 72 points = 1 inch)
@@ -19,8 +19,8 @@ from copienator import (
target_parser,
workspace_from_target,
)
from platform_utils import open_path
from utils import natural_key, read_all_labels
from copienator.platform import open_path
from copienator.utils import natural_key, read_all_labels
# --- Configuration & Globals ---
padding = 60
@@ -472,3 +472,4 @@ def main(argv: Sequence[str] | None = None) -> int:
if __name__ == "__main__":
raise SystemExit(main())
@@ -16,7 +16,7 @@ from copienator import (
workspace_from_args,
)
WORD_LIST_FILE = Path(__file__).with_name("liste_francais.txt")
WORD_LIST_FILE = Path(__file__).resolve().parents[1] / "data" / "liste_francais.txt"
ACCENT_PATTERN = re.compile(r"[éèêëàâäîïôöùûüçœÉÈÊËÀÂÄÎÏÔÖÙÛÜÇŒ]")
@@ -9,8 +9,8 @@ import numpy as np
from pdf2image import convert_from_path
from PIL import Image, ImageChops, ImageDraw, ImageFilter
import annotating
import utils
from copienator.commands import annotating
from copienator import utils
from copienator import (
EvaluationWorkspace,
ExitCode,
@@ -284,3 +284,4 @@ def main(argv: Sequence[str] | None = None) -> int:
if __name__ == "__main__":
raise SystemExit(main())
@@ -9,8 +9,8 @@ from typing import Any
from PIL import Image, ImageDraw
import annotating
import utils
from copienator.commands import annotating
from copienator import utils
from copienator import (
EvaluationWorkspace,
ExitCode,
@@ -23,7 +23,7 @@ from copienator import (
from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides
from copienator.annotation_data import AnnotationData, RefaireList, load_annotation_data
from copienator.filesystem import staged_files
from reading_annotations import (
from copienator.commands.reading_annotations import (
concatenate,
detect_checks_and_notes,
has_significant_notes,
@@ -398,3 +398,4 @@ def main(argv: Sequence[str] | None = None) -> int:
if __name__ == "__main__":
raise SystemExit(main())
@@ -259,7 +259,7 @@ def resolve_manual(workspace: EvaluationWorkspace) -> ExitCode:
if refaire_tasks:
print(
f"File {workspace.refaire_file.name} generated. Run "
f'`python correction.py "{workspace.command_argument()}" --refaire` '
f'`python -m copienator correct "{workspace.command_argument()}" --refaire` '
"to process updates."
)
else:
@@ -10,7 +10,7 @@ from pathlib import Path
import fitz
from pypdf import PdfReader, PdfWriter
import utils
from copienator import utils
from copienator import (
CliError,
EvaluationWorkspace,
@@ -264,3 +264,4 @@ def main(argv: Sequence[str] | None = None) -> int:
if __name__ == "__main__":
raise SystemExit(main())
@@ -6,7 +6,7 @@ from collections.abc import Sequence
from google import genai
from google.genai import types
import config
from copienator import configuration as config
from copienator import (
CliError,
EvaluationWorkspace,
@@ -6,18 +6,18 @@ from pathlib import Path
import ezodf
from config import CURRENT_SCORE_ODS_PATH
from utils import read_all_labels
from copienator.configuration import CURRENT_SCORE_ODS_PATH
from copienator.utils import read_all_labels
# Configuration
ODS_PATH = Path(CURRENT_SCORE_ODS_PATH).expanduser()
TARGET_DIR_NAME = "A Rendre"
def main():
def main(argv=None):
parser = argparse.ArgumentParser(description="Update ODS with student scores.")
parser.add_argument("work_dir", nargs="?", default=os.getcwd(), help="Directory to process")
parser.add_argument("--sum", action="store_true", help="Write only the total sum per student")
args = parser.parse_args()
args = parser.parse_args(argv)
work_dir = os.path.abspath(args.work_dir)
@@ -169,4 +169,4 @@ def main():
print("Done.")
if __name__ == "__main__":
main()
raise SystemExit(main())
+82
View File
@@ -0,0 +1,82 @@
from __future__ import annotations
import argparse
import re
import sys
from collections.abc import Sequence
from pathlib import Path
from copienator import (
EvaluationWorkspace,
ExitCode,
evaluation_parser,
execute,
read_json,
workspace_from_args,
)
COPY_PATTERN = re.compile(r"Copie(\d+)")
def build_parser() -> argparse.ArgumentParser:
return evaluation_parser("Verify that every answer PDF appears in group metadata.")
def collect_source_pdfs(copies_dir: Path) -> set[tuple[str, str]]:
source_pdfs: set[tuple[str, str]] = set()
for copy_dir in copies_dir.iterdir():
match = COPY_PATTERN.fullmatch(copy_dir.name)
if not match or not copy_dir.is_dir():
continue
copy_id = match.group(1)
for pdf_path in copy_dir.glob("*.pdf"):
source_pdfs.add((pdf_path.stem, copy_id))
return source_pdfs
def collect_grouped_pdfs(groups_dir: Path) -> tuple[set[tuple[str, str]], int]:
grouped: set[tuple[str, str]] = set()
read_errors = 0
for json_path in groups_dir.glob("*/Group_*.json"):
try:
data = read_json(json_path)
if not isinstance(data, list):
raise TypeError("expected a JSON array")
for entry in data:
grouped.add((str(entry[4]), str(entry[0])))
except (IndexError, OSError, TypeError, ValueError) as exc:
print(f"Error reading {json_path}: {exc}", file=sys.stderr)
read_errors += 1
return grouped, read_errors
def verify_groups(workspace: EvaluationWorkspace) -> ExitCode:
workspace.require_directories("Copies", "Par label")
source_pdfs = collect_source_pdfs(workspace.copies_dir)
grouped_pdfs, read_errors = collect_grouped_pdfs(workspace.groups_dir)
missing = source_pdfs - grouped_pdfs
if missing:
print(f"Verification failed: {len(missing)} files missing from groups:")
for label, copy_id in sorted(missing):
print(f"Copie{copy_id}/{label}.pdf")
return ExitCode.FAILURE
if read_errors:
print("Verification incomplete because some metadata could not be read.")
return ExitCode.PARTIAL
print("Verification successful: all files accounted for.")
return ExitCode.SUCCESS
def run(workspace: EvaluationWorkspace) -> ExitCode:
return verify_groups(workspace)
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__":
raise SystemExit(main())
+36
View File
@@ -0,0 +1,36 @@
from __future__ import annotations
import importlib.util
import os
from pathlib import Path
from types import ModuleType
def _user_config_path() -> Path | None:
explicit = os.environ.get("COPIENATOR_CONFIG", "").strip()
if explicit:
return Path(explicit).expanduser().resolve()
local = Path.cwd() / "config.py"
return local.resolve() if local.is_file() else None
def _load_user_config(path: Path) -> ModuleType:
if not path.is_file():
raise FileNotFoundError(f"Copienator configuration not found: {path}")
spec = importlib.util.spec_from_file_location("_copienator_user_config", path)
if spec is None or spec.loader is None:
raise ImportError(f"Could not load Copienator configuration: {path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
CONFIG_PATH = _user_config_path()
if CONFIG_PATH is None:
import default_config as _configuration
else:
_configuration = _load_user_config(CONFIG_PATH)
for _name in dir(_configuration):
if not _name.startswith("_"):
globals()[_name] = getattr(_configuration, _name)
File diff suppressed because it is too large Load Diff
+76
View File
@@ -0,0 +1,76 @@
from __future__ import annotations
import importlib
import sys
from collections.abc import Sequence
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class Command:
module: str
description: str
COMMANDS: dict[str, Command] = {
"statement": Command("gemini_for_enonce", "Analyse the exam statement with Gemini"),
"statement-personal": Command("enonce_info", "Generate personal statement metadata"),
"copies": Command("copies_tools", "Rotate or rename scanned copies"),
"page-split": Command("page_splitter", "Split and reorder scanned PDF pages"),
"crop-labels": Command("cutleft", "Crop the label margin from copies"),
"labels": Command("gemini_for_labels", "Detect question labels with Gemini"),
"review-labels": Command("plotting", "Review detected labels interactively"),
"split-answers": Command("splitting_int", "Split copies into answers"),
"group-answers": Command("grouping", "Group answers by question"),
"verify-groups": Command("verify_groups", "Verify grouped answer metadata"),
"correct": Command("correction", "Generate or integrate corrections"),
"batch-submit": Command("submit_batches", "Submit Gemini batch jobs"),
"batch-status": Command("batch_status", "Inspect Gemini batch jobs"),
"batch-fetch": Command("fetch_batched_results", "Fetch Gemini batch results"),
"post-correction": Command("post_correction", "Clean generated correction text"),
"resolve-manual": Command("resolve_manual", "Resolve manual label conflicts"),
"annotate-simple": Command("annotating", "Generate simple annotations"),
"annotate-checks": Command("annotating_with_checks", "Generate checkable annotations"),
"annotate-grouped": Command("annotating_by_label", "Generate grouped annotations"),
"export": Command("export", "Export annotations"),
"import": Command("import_annotations", "Import handwritten annotations"),
"read-annotations": Command("reading_annotations", "Read checkable annotations"),
"read-grouped": Command("reading_grouped_annotations", "Read grouped annotations"),
"giving-names": Command("giving_names", "Name copies and prepare A Rendre"),
"update-ods": Command("update_ods", "Update the configured score spreadsheet"),
"add-final-score": Command("add_final_score", "Stamp final scores on copies"),
"gui": Command("@gui", "Launch the graphical workflow assistant"),
}
def _print_help() -> None:
print("Usage: copienator COMMAND [ARGUMENTS]")
print(" python -m copienator COMMAND [ARGUMENTS]")
print("\nCommands:")
width = max(len(name) for name in COMMANDS)
for name, command in COMMANDS.items():
print(f" {name:<{width}} {command.description}")
print("\nUse 'copienator COMMAND --help' for command-specific help.")
def main(argv: Sequence[str] | None = None) -> int:
arguments = list(sys.argv[1:] if argv is None else argv)
if not arguments or arguments[0] in {"-h", "--help"}:
_print_help()
return 0
command_name = arguments.pop(0)
command = COMMANDS.get(command_name)
if command is None:
print(f"Unknown command: {command_name}", file=sys.stderr)
print("Use 'copienator --help' to list commands.", file=sys.stderr)
return 2
module_name = (
"copienator_gui.__main__"
if command.module == "@gui"
else f"copienator.commands.{command.module}"
)
module = importlib.import_module(module_name)
result = module.main(arguments)
return int(result or 0)
@@ -118,3 +118,4 @@ def replace_with_link_or_copy(
shutil.copy2(source_path, destination_path)
return "copy"
+3 -2
View File
@@ -1,6 +1,6 @@
from pathlib import Path
import io
import utils
from . import utils
main_prompt = """I'm giving you an image of several written answers to an exam.
@@ -86,7 +86,7 @@ Here is a possible correct answer :
You are asked to score the question or exercice labeled `<<label>>`,
do not score or give feedback to any other question."""
from utils import get_label_text_content, get_label_sol_content, get_label_persp_content
from .utils import get_label_text_content, get_label_sol_content, get_label_persp_content
def make_prompt(input_dir,full_label):
text = get_label_text_content(input_dir, full_label) or ""
@@ -298,3 +298,4 @@ Here is a list of all possible labels. You need to answer with a list one of the
])]
config = types.GenerateContentConfig(temperature=1.0, response_mime_type="application/json")
return contents, config
+3 -3
View File
@@ -2,7 +2,7 @@ import re
from pathlib import Path
from copienator import EvaluationWorkspace
from platform_utils import validate_windows_labels
from .platform import validate_windows_labels
def natural_key(text):
return [int(c) if c.isdigit() else c.lower() for c in re.split(r'(\d+)', str(text))]
@@ -30,7 +30,7 @@ def enonce_total(base_dir):
import os
import shlex
from platform_utils import open_path
from .platform import open_path
def edit_file_and_enter(file):
editor = os.environ.get("EDITOR")
@@ -139,7 +139,7 @@ import tempfile
import shutil
import subprocess
from config import LATEX_AFTER, LATEX_BEFORE
from .configuration import LATEX_AFTER, LATEX_BEFORE
def compile_to_pdf(text, output_pdf_path):
+6 -8
View File
@@ -8,25 +8,23 @@ from copienator_gui.app import CopienatorApp
def personal_steps_enabled() -> bool:
try:
from config import SHOW_PERSONAL_STEPS
except (ImportError, AttributeError):
try:
from default_config import SHOW_PERSONAL_STEPS
from copienator.configuration import SHOW_PERSONAL_STEPS
except (ImportError, AttributeError):
return False
return bool(SHOW_PERSONAL_STEPS)
def main() -> None:
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description="Interface graphique du workflow Copienator")
parser.add_argument("evaluation", nargs="?", type=Path, help="Dossier d’évaluation à ouvrir")
args = parser.parse_args()
args = parser.parse_args(argv)
repository = Path(__file__).resolve().parent
repository = Path.cwd().resolve()
evaluation = args.evaluation.resolve() if args.evaluation else None
app = CopienatorApp(repository, personal_steps_enabled(), evaluation)
app.mainloop()
return 0
if __name__ == "__main__":
main()
raise SystemExit(main())
+2 -2
View File
@@ -8,7 +8,7 @@ from tkinter import filedialog, messagebox, ttk
from typing import Any
from copienator import ExitCode
from platform_utils import WindowsLabelError, open_path, validate_windows_labels
from copienator.platform import WindowsLabelError, open_path, validate_windows_labels
from .diagnostics import collect_diagnostics
from .runner import ProcessRunner
@@ -71,7 +71,7 @@ def detected_annotation_directories(evaluation: Path) -> tuple[str, ...]:
def plotting_shortcut_lines() -> list[str]:
try:
from config import PLOTTING_KB
from copienator.configuration import PLOTTING_KB
except (ImportError, AttributeError):
from default_config import PLOTTING_KB
+2 -2
View File
@@ -7,7 +7,7 @@ import sys
from dataclasses import dataclass
from pathlib import Path
from platform_utils import windows_filename_problems
from copienator.platform import windows_filename_problems
@dataclass(frozen=True)
@@ -111,7 +111,7 @@ def collect_diagnostics(
)
)
try:
from config import CURRENT_SCORE_ODS_PATH, FINAL_SCORE_ODS_PATH
from copienator.configuration import CURRENT_SCORE_ODS_PATH, FINAL_SCORE_ODS_PATH
for label, configured_path in (
("ODS courant", CURRENT_SCORE_ODS_PATH),
+33 -33
View File
@@ -90,8 +90,8 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Analyser l’énoncé",
"Détecte les questions, leurs groupes, le corrigé et les indications de barème.",
(
python("gemini", "Analyse avec Gemini", "gemini_for_enonce.py"),
python("personal", "Alternative enonce_info.py", "enonce_info.py"),
python("gemini", "Analyse avec Gemini", "statement"),
python("personal", "Alternative personnelle", "statement-personal"),
),
arguments=(
arg_target("Dossier de l’évaluation"),
@@ -124,7 +124,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
CommandVariant(
"rotate",
"Rotation",
"copies_tools.py",
"copies",
"python",
("rotate",),
fixed_args_before_positionals=True,
@@ -142,7 +142,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
CommandVariant(
"rename",
"Renommage",
"copies_tools.py",
"copies",
"python",
("rename",),
fixed_args_before_positionals=True,
@@ -155,7 +155,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Prétraitement des copies",
"Séparer et réordonner les pages",
"Ouvre loutil interactif de découpage A3 vers A4. La cible peut être un dossier ou un PDF.",
(python("default", "Séparation des pages", "page_splitter.py"),),
(python("default", "Séparation des pages", "page-split"),),
arguments=(arg_target(),),
artifacts=("Copies", "Copies Originales"),
),
@@ -164,7 +164,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Prétraitement des copies",
"Découper la marge des labels",
"Produit les images de la partie gauche des copies. Une copie précise peut être ciblée.",
(python("default", "Découpe", "cutleft.py"),),
(python("default", "Découpe", "crop-labels"),),
arguments=(
arg_target(),
ArgumentSpec("fullpage", "Toujours utiliser la page entière", "bool", "--fullpage"),
@@ -177,7 +177,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Labels et regroupement",
"Détecter les labels avec Gemini",
"Identifie les labels dans les images produites par la découpe gauche.",
(python("default", "Détection des labels", "gemini_for_labels.py"),),
(python("default", "Détection des labels", "labels"),),
arguments=(
arg_target(),
ArgumentSpec("overwrite", "Régénérer les résultats", "bool", "--overwrite"),
@@ -190,7 +190,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Labels et regroupement",
"Vérifier visuellement les labels",
"Ouvre la fenêtre de vérification. La cible peut être toute l’évaluation ou une copie.",
(python("default", "Vérification", "plotting.py"),),
(python("default", "Vérification", "review-labels"),),
arguments=(arg_target(),),
requires=("labels", "Cutleft"),
artifacts=("Copies/Copie*.json",),
@@ -200,7 +200,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Labels et regroupement",
"Découper les réponses par question",
"Découpe les copies à partir des coordonnées de labels vérifiées.",
(python("default", "Découpage", "splitting_int.py"),),
(python("default", "Découpage", "split-answers"),),
arguments=(arg_target(),),
requires=("Copies",),
artifacts=("Copies/Copie*/*",),
@@ -211,7 +211,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Labels et regroupement",
"Regrouper les réponses",
"Regroupe les réponses portant le même label pour préparer les requêtes.",
(python("default", "Regroupement", "grouping.py"),),
(python("default", "Regroupement", "group-answers"),),
arguments=(arg_target("Dossier de l’évaluation"),),
requires=("Copies",),
artifacts=("Par label",),
@@ -222,7 +222,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Labels et regroupement",
"Vérifier les groupes produits",
"Vérifie que chaque réponse PDF apparaît dans les métadonnées des groupes.",
(python("default", "Vérification des groupes", "verify_groups.py"),),
(python("default", "Vérification des groupes", "verify-groups"),),
arguments=(arg_target("Dossier de l’évaluation"),),
optional=True,
requires=("Copies", "Par label"),
@@ -233,17 +233,17 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Lancer ou intégrer la correction",
"Choisir une correction immédiate, batch, hybride, une recorrection, ou lintégration dun batch.",
(
python("live", "Correction immédiate", "correction.py"),
python("batch", "Préparer toutes les requêtes batch", "correction.py", fixed_args=("--batch",)),
python("hybrid", "Batch à partir dun label", "correction.py"),
python("refaire", "Recorrection depuis refaire.json", "correction.py", fixed_args=("--refaire",)),
python("live", "Correction immédiate", "correct"),
python("batch", "Préparer toutes les requêtes batch", "correct", fixed_args=("--batch",)),
python("hybrid", "Batch à partir dun label", "correct"),
python("refaire", "Recorrection depuis refaire.json", "correct", fixed_args=("--refaire",)),
python(
"integrate",
"Intégrer les résultats batch",
"correction.py",
"correct",
fixed_args=("--deal-with-batched",),
),
python("reset", "Réinitialiser les corrections", "correction.py", fixed_args=("--reset",), dangerous=True),
python("reset", "Réinitialiser les corrections", "correct", fixed_args=("--reset",), dangerous=True),
),
arguments=(
arg_target("Évaluation ou image Group_X.jpg"),
@@ -259,7 +259,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Correction",
"Envoyer les batchs",
"Envoie à Gemini les fichiers JSONL produits par le mode batch.",
(python("default", "Envoi", "submit_batches.py"),),
(python("default", "Envoi", "batch-submit"),),
arguments=(arg_target("Dossier de l’évaluation"),),
optional=True,
artifacts=("batch_jobs.json",),
@@ -270,7 +270,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Correction",
"Consulter l’état des batchs",
"Affiche les jobs Gemini en cours. Lidentifiant de téléchargement est facultatif.",
(python("default", "État des batchs", "batch_status.py"),),
(python("default", "État des batchs", "batch-status"),),
arguments=(ArgumentSpec("download", "Télécharger le job", "text", "--download"),),
optional=True,
skip_for_live_correction=True,
@@ -280,7 +280,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Correction",
"Récupérer les résultats batch",
"Télécharge et rassemble les réponses des jobs terminés.",
(python("default", "Récupération", "fetch_batched_results.py"),),
(python("default", "Récupération", "batch-fetch"),),
arguments=(arg_target("Dossier de l’évaluation"),),
optional=True,
skip_for_live_correction=True,
@@ -290,7 +290,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Correction",
"Nettoyer la correction",
"Corrige certains problèmes dencodage et prépare le texte pour LaTeX.",
(python("default", "Post-correction", "post-correction.py"),),
(python("default", "Post-correction", "post-correction"),),
arguments=(arg_target("Dossier de l’évaluation"),),
requires=("correction.json",),
),
@@ -299,7 +299,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Correction",
"Résoudre les conflits manuels",
"Étape conditionnelle, uniquement si manual_resolutions.txt contient des conflits à traiter.",
(python("default", "Résolution", "resolve_manual.py"),),
(python("default", "Résolution", "resolve-manual"),),
arguments=(arg_target("Dossier de l’évaluation"),),
optional=True,
requires=("manual_resolutions.txt", "correction.json"),
@@ -311,9 +311,9 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Générer les copies annotées",
"Les trois modes sont exclusifs pour un parcours donné.",
(
python("simple", "Annotations simples (Anot)", "annotating.py"),
python("checks", "Annotations avec cases (Bnot)", "annotating_with_checks.py"),
python("grouped", "Annotations groupées (BGnot)", "annotating_by_label.py"),
python("simple", "Annotations simples (Anot)", "annotate-simple"),
python("checks", "Annotations avec cases (Bnot)", "annotate-checks"),
python("grouped", "Annotations groupées (BGnot)", "annotate-grouped"),
),
arguments=(
arg_target("Dossier de l’évaluation"),
@@ -328,7 +328,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Génération des annotations",
"Exporter",
"Exporte les annotations vers le dossier EXPORT_DIR défini dans config.py.",
(python("default", "Export", "export.py"),),
(python("default", "Export", "export"),),
arguments=(
arg_target("Dossier de l’évaluation"),
ArgumentSpec(
@@ -355,7 +355,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Correction manuscrite",
"Importer les annotations manuscrites",
"Copie les PDF présents dans IMPORT_DIR vers l’évaluation.",
(python("default", "Import", "import.py"),),
(python("default", "Import", "import"),),
arguments=(
arg_target("Dossier de l’évaluation"),
ArgumentSpec(
@@ -375,8 +375,8 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Lire les annotations manuscrites",
"Le mode doit correspondre au mode choisi lors de la génération des annotations.",
(
python("standard", "Lecture Bnot", "reading_annotations.py"),
python("grouped", "Lecture BGnot", "reading_grouped_annotations.py"),
python("standard", "Lecture Bnot", "read-annotations"),
python("grouped", "Lecture BGnot", "read-grouped"),
),
arguments=(
arg_target("Dossier de l’évaluation"),
@@ -389,7 +389,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Finalisation",
"Attribuer les noms et préparer A Rendre",
"Crée le dossier A Rendre à partir du dossier dannotations choisi.",
(python("default", "Attribution des noms", "giving_names.py"),),
(python("default", "Attribution des noms", "giving-names"),),
arguments=(
arg_target("Dossier de l’évaluation"),
ArgumentSpec(
@@ -424,7 +424,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Étapes personnelles",
"Mettre à jour le fichier ODS",
"Transfère les scores, avec la possibilité de n’écrire que leur somme.",
(python("default", "Mise à jour ODS", "update_ods.py"),),
(python("default", "Mise à jour ODS", "update-ods"),),
arguments=(
arg_target("Dossier de l’évaluation"),
ArgumentSpec("sum", "Écrire seulement la somme", "bool", "--sum"),
@@ -452,7 +452,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Étapes personnelles",
"Ajouter le score final",
"Génère les fichiers de diffusion avec le score final.",
(python("default", "Score final", "add_final_score.py"),),
(python("default", "Score final", "add-final-score"),),
arguments=(arg_target("Dossier de l’évaluation"),),
personal=True,
),
@@ -494,7 +494,7 @@ def build_command(
program_path = repository / variant.program
if variant.kind == "python":
command = [sys.executable, "-u", str(program_path)]
command = [sys.executable, "-u", "-m", "copienator", variant.program]
elif variant.kind == "shell":
command = [str(program_path)]
else:
+22
View File
@@ -0,0 +1,22 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "copienator"
version = "0.1.0"
description = "Workflow assistant for preparing and correcting scanned exams"
requires-python = ">=3.11"
[project.scripts]
copienator = "copienator.dispatcher:main"
copienator-gui = "copienator_gui.__main__:main"
[tool.setuptools]
py-modules = ["default_config"]
[tool.setuptools.packages.find]
include = ["copienator*", "copienator_gui*"]
[tool.setuptools.package-data]
copienator = ["data/*.txt"]
+50 -27
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
import importlib.util
import importlib
import io
import json
import os
@@ -31,6 +31,7 @@ from copienator import (
from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides
from copienator.annotation_data import AnnotationLoadResult, load_annotation_data
from copienator.filesystem import staged_directory, staged_files
from copienator.dispatcher import COMMANDS, main as dispatcher_main
from copienator_gui.app import (
CopienatorApp,
DEFAULT_HTTPS_PROXY,
@@ -45,8 +46,8 @@ from copienator_gui.diagnostics import collect_diagnostics
from copienator_gui.runner import ProcessRunner
from copienator_gui.state import StateStore
from copienator_gui.workflow import build_command, build_workflow, evaluation_argument
from copies_tools import rename_all, rotate_all
from platform_utils import (
from copienator.commands.copies_tools import rename_all, rotate_all
from copienator.platform import (
WindowsLabelError,
replace_with_link_or_copy,
safe_filename,
@@ -57,18 +58,16 @@ from platform_utils import (
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)
sys.modules[module_name] = module
try:
spec.loader.exec_module(module)
except Exception:
sys.modules.pop(module_name, None)
raise
return module
def load_script_module(filename: str, _module_name: str):
stem = Path(filename).stem.replace("-", "_")
if stem == "import":
stem = "import_annotations"
return importlib.import_module(f"copienator.commands.{stem}")
def command_arguments(command: list[str]) -> list[str]:
assert command[2:4] == ["-m", "copienator"]
return command[5:]
class WorkspaceTests(unittest.TestCase):
@@ -129,6 +128,24 @@ class WorkspaceTests(unittest.TestCase):
self.assertEqual(workspace.names_file(), root / "names")
class DispatcherTests(unittest.TestCase):
def test_unknown_command_has_argument_error_code(self) -> None:
with redirect_stderr(io.StringIO()):
self.assertEqual(dispatcher_main(["does-not-exist"]), 2)
def test_every_gui_python_command_is_registered(self) -> None:
for step in build_workflow(True):
for variant in step.variants:
if variant.kind == "python":
with self.subTest(step=step.id, command=variant.program):
self.assertIn(variant.program, COMMANDS)
def test_root_contains_only_configuration_python_modules(self) -> None:
root_modules = {path.name for path in REPOSITORY.glob("*.py")}
self.assertIn("default_config.py", root_modules)
self.assertLessEqual(root_modules, {"config.py", "default_config.py"})
class AtomicJsonTests(unittest.TestCase):
def test_atomic_binary_round_trip(self) -> None:
with tempfile.TemporaryDirectory() as directory:
@@ -561,7 +578,9 @@ class StandardCliTests(unittest.TestCase):
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:])
parsed = self.modules[module_name].build_parser().parse_args(
command_arguments(command)
)
parsed_path = getattr(parsed, "evaluation", None) or parsed.target
self.assertEqual(str(parsed_path), evaluation)
@@ -577,7 +596,7 @@ class StandardCliTests(unittest.TestCase):
)
with self.subTest(script=f"copies_tools:{step_id}"):
parsed = self.modules["copies_tools"].build_parser().parse_args(
command[3:]
command_arguments(command)
)
self.assertEqual(parsed.operation, step_id)
self.assertEqual(str(parsed.evaluation), evaluation)
@@ -1521,7 +1540,7 @@ class WorkflowTests(unittest.TestCase):
"default",
{"target": self.evaluation, "annotation_dir": "Bnot"},
)
self.assertEqual(command[3:], [self.evaluation, "Bnot"])
self.assertEqual(command_arguments(command), [self.evaluation, "Bnot"])
def test_copy_listing_prefers_processed_copies(self) -> None:
with tempfile.TemporaryDirectory() as directory:
@@ -1607,27 +1626,31 @@ class WorkflowTests(unittest.TestCase):
"live",
{"target": self.evaluation, "overwrite": True, "limit": "0"},
)
self.assertEqual(command[3:], [self.evaluation, "--overwrite", "--limit", "0"])
self.assertIn("correction.py", command[2])
self.assertEqual(
command_arguments(command),
[self.evaluation, "--overwrite", "--limit", "0"],
)
self.assertEqual(command[4], "correct")
def test_hybrid_correction_arguments(self) -> None:
command = self.command(
"correction", "hybrid", {"target": self.evaluation, "batch_from": "Ex 4"}
)
self.assertEqual(command[3:], [self.evaluation, "--batch-from", "Ex 4"])
self.assertEqual(
command_arguments(command), [self.evaluation, "--batch-from", "Ex 4"]
)
def test_annotation_variants_are_exclusive_commands(self) -> None:
command = self.command(
"annotation", "grouped", {"target": self.evaluation, "overwrite": True}
)
self.assertIn("annotating_by_label.py", command[2])
self.assertNotIn("annotating.py", command[2])
self.assertEqual(command[4], "annotate-grouped")
def test_copy_preparation_commands_are_python(self) -> None:
rotate = self.command("rotate", "rotate", {"target": self.evaluation})
rename = self.command("rename", "rename", {"target": self.evaluation})
self.assertEqual(rotate[2:], [str(REPOSITORY / "copies_tools.py"), "rotate", self.evaluation])
self.assertEqual(rename[2:], [str(REPOSITORY / "copies_tools.py"), "rename", self.evaluation])
self.assertEqual(rotate[2:], ["-m", "copienator", "copies", "rotate", self.evaluation])
self.assertEqual(rename[2:], ["-m", "copienator", "copies", "rename", self.evaluation])
class CrossPlatformFileTests(unittest.TestCase):
@@ -1665,8 +1688,8 @@ class CrossPlatformFileTests(unittest.TestCase):
source = root / "source.txt"
destination = root / "destination.txt"
source.write_text("content", encoding="utf-8")
with patch("platform_utils.os.link", side_effect=OSError), patch(
"platform_utils.os.symlink", side_effect=OSError
with patch("copienator.platform.os.link", side_effect=OSError), patch(
"copienator.platform.os.symlink", side_effect=OSError
):
method = replace_with_link_or_copy(source, destination)
self.assertEqual(method, "copy")