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 | | 4 | traitement partiel, avec avertissements |
| 130 | interruption par l'utilisateur | | 130 | interruption par l'utilisateur |
Le GUI distingue notamment un traitement partiel d'un échec. Les Le GUI distingue notamment un traitement partiel d'un échec. Toutes les
scripts migrés vers cette convention sont actuellement : commandes du paquet suivent désormais cette convention. Elles sont
regroupées dans =copienator.commands= et exposées par le répartiteur
- =copies_tools.py=, =grouping.py= et =verify_groups.py= ; =python -m copienator=.
- =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=.
* Organisation du code * Organisation du code
@@ -78,8 +67,10 @@ scripts migrés vers cette convention sont actuellement :
scripts, notamment =EvaluationWorkspace= et les écritures atomiques. scripts, notamment =EvaluationWorkspace= et les écritures atomiques.
- Le paquet =copienator_gui= contient la définition du workflow, l'état - Le paquet =copienator_gui= contient la définition du workflow, l'état
persistant, le lanceur de processus et l'application Tk. 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. 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 * État et exécution du GUI
+7 -2
View File
@@ -41,8 +41,13 @@ Libraries :
#+BEGIN_SRC bash #+BEGIN_SRC bash
pip install numpy pandas matplotlib pillow pydantic pypdf pdf2image reportlab img2pdf pymupdf ftfy ezodf google pip install numpy pandas matplotlib pillow pydantic pypdf pdf2image reportlab img2pdf pymupdf ftfy ezodf google
pip install -e .
#+END_SRC #+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) *** Poppler (for pdf2image)
+ Linux : install poppler-utils + Linux : install poppler-utils
@@ -79,10 +84,10 @@ Copier `default_config.py` en `config.py`. Éventuellement le modifier.
Lancer l'assistant avec : Lancer l'assistant avec :
#+BEGIN_SRC bash #+BEGIN_SRC bash
python gui.py python -m copienator gui
#+END_SRC #+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=. L'interface conserve l'état et l'historique des étapes dans
=Interro/.copienator-gui.json=, et les sorties complètes dans =Interro/.copienator-gui.json=, et les sorties complètes dans
=Interro/.copienator/logs/=. Une relance d'une étape antérieure ne =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 * É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. qui suit automatiquement les étapes décrites ci-dessous.
** Prétraitement de l'énoncé ** 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`. `enonce.pdf`, `enonce.tex`, `correction.tex`.
- `python gemini_for_enonce.py Interro` or - `python -m copienator statement Interro` or
`python gemini_for_enonce.py Interro --restart` `python -m copienator statement Interro --restart`
À partir des trois fichiers précédents, se charge de détecter les À partir des trois fichiers précédents, se charge de détecter les
labels des questions et leur contenu. 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`. É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. Ces deux commandes suivent la convention des scripts standardisés.
Leur import ne lance aucun traitement et les erreurs partielles sont Leur import ne lance aucun traitement et les erreurs partielles sont
distinguées des échecs. Les réponses d'extraction mises en cache par 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. atomiquement.
** Prétraitement des copies ** Prétraitement des copies
Mettre les copies scannées au format pdf dans =Interro=. 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 à Retourne tous les pdf de 180°, si la photocopie a été faite à
l'envers. 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= 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. 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 jeter les deux pages
+ de déplacer la délimitation à droite/gauche + 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 Le PDF transformé est construit dans un dossier temporaire. La
copie produite et la sauvegarde dans =Copies Originales= sont copie produite et la sauvegarde dans =Copies Originales= sont
ensuite installées avec rollback : une erreur conserve les deux ensuite installées avec rollback : une erreur conserve les deux
versions précédentes. Une relance ciblée lit directement la versions précédentes. Une relance ciblée lit directement la
sauvegarde originale sans la déplacer au préalable. 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 Découpe la partie gauche des copies, là où il devrait y avoir les
labels des exercices/questions. 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 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 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"~ 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 Fait des requêtes à Gemini pour identifier les labels des
questions dans images générées à partir des parties gauches des copies. 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 copies différentes sont traitées en parallèle. Chaque réponse JSON
validée est écrite atomiquement. Une cible sans image correspondante validée est écrite atomiquement. Une cible sans image correspondante
produit le code de sortie 4. 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. Permet de vérifier visuellement les labels trouvés.
+ Sous linux, on peut faire =e= pour ouvrir le fichier .json et + 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é. + `|…` n'est pas arrêté verticalement par son type opposé.
+ `…|` est stoppé horizontalement par le `|…` le plus proche. + `…|` est stoppé horizontalement par le `|…` le plus proche.
Pour modifier une seule copie : 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 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 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= 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) En cas de soucis, (par exemple les pages ne sont pas dans le bon ordre)
- Réordonner les pages du fichier pdf - Réordonner les pages du fichier pdf
- Rerun =python cutleft.py Interro/Copie{id}= - Rerun =python -m copienator crop-labels Interro/Copie{id}=
- Rerun =python gemini_for_labels.py Interro/Copie{id}= - Rerun =python -m copienator labels Interro/Copie{id}=
3. =python splitting_int.py Interro= 3. =python -m copienator split-answers Interro=
Découpe les copies suivant les exercices Découpe les copies suivant les exercices
Peut-être appelé avec une seule copie. 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, puis remplacent ensemble le dossier précédent. En cas d'erreur,
l'ancienne version est conservée. Les réponses devenues obsolètes l'ancienne version est conservée. Les réponses devenues obsolètes
restent archivées dans le sous-dossier =Missing=. 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 Regroupe les mêmes questions de différentes copies en groupes de
tailles raisonnables. 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 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 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"~ 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 1. Il faut créer des persp, pour indication de comment corriger, et
relancer =enonce_info.py= relancer =copienator statement-personal=
2. =python correction.py Interro --limit 240= OU 2. =python -m copienator correct Interro --limit 240= OU
=python correction.py Interro/Par\ label/Ex\ 2/Group_1.jpg= OU =python -m copienator correct Interro/Par\ label/Ex\ 2/Group_1.jpg= OU
=python correction.py Interro --overwrite= =python -m copienator correct Interro --overwrite=
Fais les requêtes de correction à Gemini. 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 à fichiers =correction.json= et =correction_progress.json= sont mis à
jour atomiquement. Avec =--overwrite=, leur version précédente reste jour atomiquement. Avec =--overwrite=, leur version précédente reste
en place jusqu'à la première écriture réussie de la nouvelle 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 Pour diminuer le coût, il est possible de batch les requêtes, qui
seront alors traitées sous au plus 24h. seront alors traitées sous au plus 24h.
+ =python correction.py Interro --batch= + =python -m copienator correct Interro --batch=
+ OU =python correction.py Interro --batch-from 'Ex 4'= + OU =python -m copienator correct Interro --batch-from 'Ex 4'=
+ =python submit_batches.py Interro= + =python -m copienator batch-submit Interro=
+ =python batch_status.py= + =python -m copienator batch-status=
+ =python fetch_batched_results.py Interro= + =python -m copienator batch-fetch Interro=
+ =python correction.py Interro --deal-with-batched= + =python -m copienator correct Interro --deal-with-batched=
Les quatre commandes de ce flux suivent la convention des scripts Les quatre commandes de ce flux suivent la convention des scripts
standardisés. Les fichiers de requêtes et le résultat JSONL combiné standardisés. Les fichiers de requêtes et le résultat JSONL combiné
sont publiés atomiquement : une interruption ne laisse pas de fichier 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 identifiants distants dans =batch_jobs.json= ; la récupération les
utilise en priorité et garde la recherche par nom pour les anciens 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 resultat.jsonl= permet aussi de télécharger atomiquement le résultat
d'un job particulier. 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 - Essaye de corriger des erreurs d'encodage/d'accents dans
=correction.json=. =correction.json=.
@@ -214,29 +214,29 @@ Optional : Set proxy with ~export HTTPS_PROXY="http://10.0.0.1:3128"~
+ `xx` : move to goal. + `xx` : move to goal.
+ `xs` : remove old, keep goal. + `xs` : remove old, keep goal.
Then call `python resolve_manual.py Interro` Then call `python -m copienator resolve-manual Interro`
5. Call `python correction.py Interro --refaire`. 5. Call `python -m copienator correct Interro --refaire`.
** Génération des copies annotées ** 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=. Ajoute les annotations Gemini aux copies, enregistrées dans le dossier =Anot=.
On peut passer l'argument =--overwrite=. On peut passer l'argument =--overwrite=.
OU OU
2. =python annotating_with_checks.py Interro= 2. =python -m copienator annotate-checks Interro=
Ajoute les annotations Gemini, et des checkboxes à cocher. Ajoute les annotations Gemini, et des checkboxes à cocher.
Enregistrées dans le dossier =Bnot=, Enregistrées dans le dossier =Bnot=,
=--overwrite= =--overwrite=
Une seule copie peut être ciblée avec, par exemple, 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 Le mode =--refaire= exige un fichier =refaire.json= et écrit dans
=BRnot=. =BRnot=.
OU 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 Ajoute les annotations Gemini, et des checkboxes, et regroupe les
réponses par question. réponses par question.
@@ -245,7 +245,7 @@ OU
_Needs_ : label_groups file (made automatically by this function), _Needs_ : label_groups file (made automatically by this function),
qui dit quelles questions regrouper. 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= Cela déplace les groupes dans le dossier configuré par =EXPORT_DIR=
(par défaut =Export=). (par défaut =Export=).
@@ -263,7 +263,7 @@ tablette graphique.
_Before_ : vider le dossier configuré par =IMPORT_DIR= (par défaut _Before_ : vider le dossier configuré par =IMPORT_DIR= (par défaut
=Import=), puis y copier ou synchroniser les fichiers depuis la tablette. =Import=), puis y copier ou synchroniser les fichiers depuis la tablette.
1. =python import.py Interro BGnot= 1. =python -m copienator import Interro BGnot=
Une fois les corrections manuelles appliquées aux fichiers Une fois les corrections manuelles appliquées aux fichiers
=Concat.pdf=, il faut enregistrer le fichier annoté au même endroit, =Concat.pdf=, il faut enregistrer le fichier annoté au même endroit,
@@ -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 export. Pour =Anot=, l'image est importée sous le nom
=Concat_annotated.jpg=. =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 Lit les =Concat_annotated= dans =Bnot=, regénère les copies avec
les modifications. Les fichiers générés (=score.json=, 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 ensemble. Les entrées du dossier =Bnot= restent en place et une
erreur de génération conserve les anciennes sorties. erreur de génération conserve les anciennes sorties.
OU 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 Idem, mais pour =BGnot=. Les tâches parallèles remontent leurs
erreurs au processus principal au lieu de les ignorer. 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 Crée un dossier =A Rendre= avec des liens symboliques vers
+ La copie à rendre + La copie à rendre
@@ -297,17 +297,17 @@ OU
4. Éventuellement, faire des modifications manuelles aux =score.json=. 4. Éventuellement, faire des modifications manuelles aux =score.json=.
Puis Puis
- `python reading_annotations.py --update-score Interro` - `python -m copienator read-annotations --update-score Interro`
- `python reading_grouped_annotations.py --update-score Interro` - `python -m copienator read-grouped --update-score Interro`
pour mettre à jour les scores dans les images. pour mettre à jour les scores dans les images.
4. (gestion perso) 4. (gestion perso)
+ =gestion_classe ne= pour créer l'interro puis + =gestion_classe ne= pour créer l'interro puis
+ =gestion_classe we= (set barème here) + =gestion_classe we= (set barème here)
+ =python update_ods.py Interro= + =python -m copienator update-ods Interro=
ou =python update_ods.py Interro --sum= (en l'absence de barème) ou =python -m copienator update-ods Interro --sum= (en l'absence de barème)
+ =gestion_classe re= + =gestion_classe re=
+ =gestion_classe wsent= + =gestion_classe wsent=
+ =python add_final_score.py Interro21= + =python -m copienator add-final-score Interro21=
(this makes files in =Server/copies=) (this makes files in =Server/copies=)
5. (gestion perso) 5. (gestion perso)
+ Deploy =miqmacs-copies-assets=, and + Deploy =miqmacs-copies-assets=, and
@@ -321,8 +321,8 @@ OU
groupée into refaire !! groupée into refaire !!
1. Redécoupage 1. Redécoupage
+ =python plotting.py InterroTest/Copie01.pdf= + =python -m copienator review-labels InterroTest/Copie01.pdf=
+ =python splitting_int.py InterroTest/Copie20.pdf= + =python -m copienator split-answers InterroTest/Copie20.pdf=
2. Créer =refaire.json=, avec un contenu comme 2. Créer =refaire.json=, avec un contenu comme
: [["Copie02", []], : [["Copie02", []],
: ["Copie01", ["Ex 1 : 1)"]]] : ["Copie01", ["Ex 1 : 1)"]]]
@@ -333,10 +333,10 @@ groupée into refaire !!
Ou non, si tu veux le faire à la main. Ou non, si tu veux le faire à la main.
4. ?? Si je fais refaire, avant d'avoir créer les annotating with 4. ?? Si je fais refaire, avant d'avoir créer les annotating with
checks, que se passe-t-il ??? checks, que se passe-t-il ???
5. Appeler =annotating_with_checks.py --refaire --overwrite= 5. Appeler =python -m copienator annotate-checks --refaire --overwrite=
6. =python export.py --refaire Interro24= 6. =python -m copienator export --refaire Interro24=
6. =python import.py --refaire Interro24= 6. =python -m copienator import --refaire Interro24=
7. =python reading_grouped_annotations.py --refaire Interro24= 7. =python -m copienator read-grouped --refaire Interro24=
Avec =--refaire=, =refaire.json= et le dossier =BRnot= sont des Avec =--refaire=, =refaire.json= et le dossier =BRnot= sont des
prérequis obligatoires ; leur absence produit le code de sortie 3. 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 ** Exemple de replotting, refaire d'une copie
1. replot it. 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. this will get rid of old/new.
!! Attention, et si ça dégage un new : bad bad bad. !! Attention, et si ça dégage un new : bad bad bad.
3. Make `refaire.json`, avec la copie, et les labels à refaire. 3. Make `refaire.json`, avec la copie, et les labels à refaire.
4. `python correction.py DS09VA --refaire` 4. `python -m copienator correct DS09VA --refaire`
5. `python annotating_with_checks.py DS09VA --refaire` 5. `python -m copienator annotate-checks DS09VA --refaire`
6. `python import.py Interro24 --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 import pandas as pd
from PIL import Image, ImageDraw, ImageFont 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 # Configuration constants
ODS_PATH = Path(FINAL_SCORE_ODS_PATH).expanduser() 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)) 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 = argparse.ArgumentParser(description="Stamp scores on exam copies.")
parser.add_argument("dir", type=Path, help="Root directory containing 'A Rendre' folder") parser.add_argument("dir", type=Path, help="Root directory containing 'A Rendre' folder")
args = parser.parse_args(argv)
args = parser.parse_args()
base_dir = args.dir.expanduser().resolve() base_dir = args.dir.expanduser().resolve()
output_dir = OUTPUT_DIR / base_dir.name output_dir = OUTPUT_DIR / base_dir.name
output_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
process_images(base_dir, output_dir) 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 pdf2image import convert_from_path
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
import utils from copienator import utils
from config import LATEX_ANOT_AFTER, LATEX_ANOT_BEFORE from copienator.configuration import LATEX_ANOT_AFTER, LATEX_ANOT_BEFORE
from copienator import ( from copienator import (
EvaluationWorkspace, EvaluationWorkspace,
ExitCode, ExitCode,
@@ -31,7 +31,7 @@ from copienator import (
) )
from copienator.annotation_data import load_annotation_data from copienator.annotation_data import load_annotation_data
from copienator.filesystem import staged_directory from copienator.filesystem import staged_directory
from utils import natural_key from copienator.utils import natural_key
MARGIN_LEFT = 300 MARGIN_LEFT = 300
ANNOT_WIDTH = 600 ANNOT_WIDTH = 600
@@ -10,9 +10,9 @@ from typing import Any
from PIL import Image, ImageDraw from PIL import Image, ImageDraw
from reportlab.pdfgen import canvas from reportlab.pdfgen import canvas
import annotating from copienator.commands import annotating
import annotating_with_checks from copienator.commands import annotating_with_checks
import utils from copienator import utils
from copienator import ( from copienator import (
CliError, CliError,
EvaluationWorkspace, EvaluationWorkspace,
@@ -26,7 +26,7 @@ from copienator import (
) )
from copienator.annotation_data import load_annotation_data from copienator.annotation_data import load_annotation_data
from copienator.filesystem import staged_directory from copienator.filesystem import staged_directory
from utils import natural_key from copienator.utils import natural_key
MAX_HEIGHT_PX = 25000 MAX_HEIGHT_PX = 25000
@@ -14,8 +14,8 @@ matplotlib.use("Agg")
from PIL import Image, ImageFont from PIL import Image, ImageFont
from reportlab.pdfgen import canvas from reportlab.pdfgen import canvas
import annotating from copienator.commands import annotating
import utils from copienator import utils
from copienator import ( from copienator import (
CliError, CliError,
EvaluationWorkspace, EvaluationWorkspace,
@@ -28,7 +28,7 @@ from copienator import (
) )
from copienator.annotation_data import load_annotation_data from copienator.annotation_data import load_annotation_data
from copienator.filesystem import staged_directory from copienator.filesystem import staged_directory
from utils import natural_key from copienator.utils import natural_key
BOX_SIZE = 30 BOX_SIZE = 30
SCORE_BOX_SIZE = 40 SCORE_BOX_SIZE = 40
@@ -328,3 +328,4 @@ def main(argv: Sequence[str] | None = None) -> int:
if __name__ == "__main__": if __name__ == "__main__":
raise SystemExit(main()) raise SystemExit(main())
@@ -6,7 +6,7 @@ from pathlib import Path
from google import genai from google import genai
import config from copienator import configuration as config
from copienator import ( from copienator import (
CliError, CliError,
ExitCode, ExitCode,
@@ -134,3 +134,4 @@ def main(argv: Sequence[str] | None = None) -> int:
if __name__ == "__main__": if __name__ == "__main__":
raise SystemExit(main()) raise SystemExit(main())
@@ -15,9 +15,9 @@ from pathlib import Path
from google import genai from google import genai
import config from copienator import configuration as config
import grouping from copienator.commands import grouping
import prompting from copienator import prompting
from copienator import ( from copienator import (
CliError, CliError,
EvaluationWorkspace, EvaluationWorkspace,
@@ -29,7 +29,7 @@ from copienator import (
target_parser, target_parser,
workspace_from_target, workspace_from_target,
) )
from utils import enonce_total, read_all_labels from copienator.utils import enonce_total, read_all_labels
NB_THREADS = 12 NB_THREADS = 12
@@ -874,7 +874,7 @@ def run_configured(args: argparse.Namespace) -> ExitCode:
+ "\n", + "\n",
) )
print(f"\n[!] Unresolved delayed tasks found! Wrote to {manual_path}.") 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() end_time = time.time()
print("Time elapsed : ", end_time - start_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: for (err, file) in errors_summary:
print(err, file=sys.stderr) print(err, file=sys.stderr)
escaped_path = shlex.quote(str(file)) 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 return ExitCode.PARTIAL if errors_summary else ExitCode.SUCCESS
@@ -341,3 +341,4 @@ def main(argv: Sequence[str] | None = None) -> int:
if __name__ == "__main__": if __name__ == "__main__":
raise SystemExit(main()) raise SystemExit(main())
@@ -17,8 +17,8 @@ from copienator import (
execute, execute,
workspace_from_args, workspace_from_args,
) )
from platform_utils import WindowsLabelError, validate_windows_labels from copienator.platform import WindowsLabelError, validate_windows_labels
from utils import compile_to_pdf from copienator.utils import compile_to_pdf
def fetch_and_save_sub_text(ex_id, indices, label, text_path): 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__": if __name__ == "__main__":
raise SystemExit(main()) raise SystemExit(main())
+2 -2
View File
@@ -3,7 +3,7 @@ import sys
from collections.abc import Sequence from collections.abc import Sequence
from pathlib import Path from pathlib import Path
from config import EXPORT_DIR from copienator.configuration import EXPORT_DIR
from copienator import ( from copienator import (
EvaluationWorkspace, EvaluationWorkspace,
ExitCode, ExitCode,
@@ -11,7 +11,7 @@ from copienator import (
execute, execute,
workspace_from_args, 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") ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot")
@@ -5,7 +5,7 @@ from collections.abc import Sequence
from google import genai from google import genai
import config from copienator import configuration as config
from copienator import ( from copienator import (
CliError, CliError,
EvaluationWorkspace, EvaluationWorkspace,
@@ -10,8 +10,8 @@ from google import genai
from google.genai import types from google.genai import types
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
import config from copienator import configuration as config
import utils from copienator import utils
from copienator import ( from copienator import (
CliError, CliError,
EvaluationWorkspace, EvaluationWorkspace,
@@ -21,8 +21,8 @@ from copienator import (
execute, execute,
workspace_from_args, workspace_from_args,
) )
from platform_utils import validate_windows_labels from copienator.platform import validate_windows_labels
from utils import compile_to_pdf from copienator.utils import compile_to_pdf
def get_lcp(s1: str, s2: str) -> str: def get_lcp(s1: str, s2: str) -> str:
@@ -13,7 +13,7 @@ from google import genai
from google.genai import types from google.genai import types
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
import config from copienator import configuration as config
from copienator import ( from copienator import (
CliError, CliError,
EvaluationWorkspace, EvaluationWorkspace,
@@ -24,7 +24,7 @@ from copienator import (
target_parser, target_parser,
workspace_from_target, 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 MODEL_ID = config.MODEL_FOR_LABEL_ID
api_key = config.API_KEY api_key = config.API_KEY
@@ -422,7 +422,7 @@ def main(argv: Sequence[str] | None = None) -> int:
def handle(args: argparse.Namespace) -> ExitCode: def handle(args: argparse.Namespace) -> ExitCode:
workspace, target = workspace_from_target( workspace, target = workspace_from_target(
args, repository=Path(__file__).resolve().parent args, repository=Path(__file__).resolve().parents[2]
) )
targets = [target] targets = [target]
for additional in args.additional_targets: for additional in args.additional_targets:
@@ -15,7 +15,7 @@ from copienator import (
read_json, read_json,
workspace_from_args, 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") ANNOTATION_CHOICES = ("BGnot", "Bnot", "Anot")
@@ -163,3 +163,4 @@ def main(argv: Sequence[str] | None = None) -> int:
if __name__ == "__main__": if __name__ == "__main__":
raise SystemExit(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)") 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): def process_identifier(identifier, files_info, output_dir):
@@ -275,3 +275,4 @@ def main(argv: Sequence[str] | None = None) -> int:
if __name__ == "__main__": if __name__ == "__main__":
raise SystemExit(main()) raise SystemExit(main())
@@ -4,7 +4,7 @@ import sys
from collections.abc import Sequence from collections.abc import Sequence
from pathlib import Path from pathlib import Path
from config import IMPORT_DIR from copienator.configuration import IMPORT_DIR
from copienator import ( from copienator import (
EvaluationWorkspace, EvaluationWorkspace,
ExitCode, 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 PIL import Image, ImageDraw, ImageTk
from pypdf import PdfReader, PdfWriter from pypdf import PdfReader, PdfWriter
from config import PAGE_SPLITTER_KB from copienator.configuration import PAGE_SPLITTER_KB
from copienator import ( from copienator import (
CliError, CliError,
EvaluationWorkspace, EvaluationWorkspace,
@@ -25,7 +25,7 @@ from copienator import (
target_parser, target_parser,
workspace_from_target, workspace_from_target,
) )
from platform_utils import launch_pdf_arranger from copienator.platform import launch_pdf_arranger
# --- Constants --- # --- Constants ---
# Conversion factor: 1 cm to points (1 inch = 2.54 cm, 72 points = 1 inch) # Conversion factor: 1 cm to points (1 inch = 2.54 cm, 72 points = 1 inch)
@@ -19,8 +19,8 @@ from copienator import (
target_parser, target_parser,
workspace_from_target, workspace_from_target,
) )
from platform_utils import open_path from copienator.platform import open_path
from utils import natural_key, read_all_labels from copienator.utils import natural_key, read_all_labels
# --- Configuration & Globals --- # --- Configuration & Globals ---
padding = 60 padding = 60
@@ -472,3 +472,4 @@ def main(argv: Sequence[str] | None = None) -> int:
if __name__ == "__main__": if __name__ == "__main__":
raise SystemExit(main()) raise SystemExit(main())
@@ -16,7 +16,7 @@ from copienator import (
workspace_from_args, 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"[éèêëàâäîïôöùûüçœÉÈÊËÀÂÄÎÏÔÖÙÛÜÇŒ]") ACCENT_PATTERN = re.compile(r"[éèêëàâäîïôöùûüçœÉÈÊËÀÂÄÎÏÔÖÙÛÜÇŒ]")
@@ -9,8 +9,8 @@ import numpy as np
from pdf2image import convert_from_path from pdf2image import convert_from_path
from PIL import Image, ImageChops, ImageDraw, ImageFilter from PIL import Image, ImageChops, ImageDraw, ImageFilter
import annotating from copienator.commands import annotating
import utils from copienator import utils
from copienator import ( from copienator import (
EvaluationWorkspace, EvaluationWorkspace,
ExitCode, ExitCode,
@@ -284,3 +284,4 @@ def main(argv: Sequence[str] | None = None) -> int:
if __name__ == "__main__": if __name__ == "__main__":
raise SystemExit(main()) raise SystemExit(main())
@@ -9,8 +9,8 @@ from typing import Any
from PIL import Image, ImageDraw from PIL import Image, ImageDraw
import annotating from copienator.commands import annotating
import utils from copienator import utils
from copienator import ( from copienator import (
EvaluationWorkspace, EvaluationWorkspace,
ExitCode, ExitCode,
@@ -23,7 +23,7 @@ from copienator import (
from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides
from copienator.annotation_data import AnnotationData, RefaireList, load_annotation_data from copienator.annotation_data import AnnotationData, RefaireList, load_annotation_data
from copienator.filesystem import staged_files from copienator.filesystem import staged_files
from reading_annotations import ( from copienator.commands.reading_annotations import (
concatenate, concatenate,
detect_checks_and_notes, detect_checks_and_notes,
has_significant_notes, has_significant_notes,
@@ -398,3 +398,4 @@ def main(argv: Sequence[str] | None = None) -> int:
if __name__ == "__main__": if __name__ == "__main__":
raise SystemExit(main()) raise SystemExit(main())
@@ -259,7 +259,7 @@ def resolve_manual(workspace: EvaluationWorkspace) -> ExitCode:
if refaire_tasks: if refaire_tasks:
print( print(
f"File {workspace.refaire_file.name} generated. Run " 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." "to process updates."
) )
else: else:
@@ -10,7 +10,7 @@ from pathlib import Path
import fitz import fitz
from pypdf import PdfReader, PdfWriter from pypdf import PdfReader, PdfWriter
import utils from copienator import utils
from copienator import ( from copienator import (
CliError, CliError,
EvaluationWorkspace, EvaluationWorkspace,
@@ -264,3 +264,4 @@ def main(argv: Sequence[str] | None = None) -> int:
if __name__ == "__main__": if __name__ == "__main__":
raise SystemExit(main()) raise SystemExit(main())
@@ -6,7 +6,7 @@ from collections.abc import Sequence
from google import genai from google import genai
from google.genai import types from google.genai import types
import config from copienator import configuration as config
from copienator import ( from copienator import (
CliError, CliError,
EvaluationWorkspace, EvaluationWorkspace,
@@ -6,18 +6,18 @@ from pathlib import Path
import ezodf import ezodf
from config import CURRENT_SCORE_ODS_PATH from copienator.configuration import CURRENT_SCORE_ODS_PATH
from utils import read_all_labels from copienator.utils import read_all_labels
# Configuration # Configuration
ODS_PATH = Path(CURRENT_SCORE_ODS_PATH).expanduser() ODS_PATH = Path(CURRENT_SCORE_ODS_PATH).expanduser()
TARGET_DIR_NAME = "A Rendre" TARGET_DIR_NAME = "A Rendre"
def main(): def main(argv=None):
parser = argparse.ArgumentParser(description="Update ODS with student scores.") 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("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") 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) work_dir = os.path.abspath(args.work_dir)
@@ -169,4 +169,4 @@ def main():
print("Done.") print("Done.")
if __name__ == "__main__": 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) shutil.copy2(source_path, destination_path)
return "copy" return "copy"
+3 -2
View File
@@ -1,6 +1,6 @@
from pathlib import Path from pathlib import Path
import io import io
import utils from . import utils
main_prompt = """I'm giving you an image of several written answers to an exam. 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>>`, You are asked to score the question or exercice labeled `<<label>>`,
do not score or give feedback to any other question.""" 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): def make_prompt(input_dir,full_label):
text = get_label_text_content(input_dir, full_label) or "" 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") config = types.GenerateContentConfig(temperature=1.0, response_mime_type="application/json")
return contents, config return contents, config
+3 -3
View File
@@ -2,7 +2,7 @@ import re
from pathlib import Path from pathlib import Path
from copienator import EvaluationWorkspace from copienator import EvaluationWorkspace
from platform_utils import validate_windows_labels from .platform import validate_windows_labels
def natural_key(text): def natural_key(text):
return [int(c) if c.isdigit() else c.lower() for c in re.split(r'(\d+)', str(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 os
import shlex import shlex
from platform_utils import open_path from .platform import open_path
def edit_file_and_enter(file): def edit_file_and_enter(file):
editor = os.environ.get("EDITOR") editor = os.environ.get("EDITOR")
@@ -139,7 +139,7 @@ import tempfile
import shutil import shutil
import subprocess import subprocess
from config import LATEX_AFTER, LATEX_BEFORE from .configuration import LATEX_AFTER, LATEX_BEFORE
def compile_to_pdf(text, output_pdf_path): 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: def personal_steps_enabled() -> bool:
try: try:
from config import SHOW_PERSONAL_STEPS from copienator.configuration import SHOW_PERSONAL_STEPS
except (ImportError, AttributeError):
try:
from default_config import SHOW_PERSONAL_STEPS
except (ImportError, AttributeError): except (ImportError, AttributeError):
return False return False
return bool(SHOW_PERSONAL_STEPS) return bool(SHOW_PERSONAL_STEPS)
def main() -> None: def main(argv=None) -> int:
parser = argparse.ArgumentParser(description="Interface graphique du workflow Copienator") parser = argparse.ArgumentParser(description="Interface graphique du workflow Copienator")
parser.add_argument("evaluation", nargs="?", type=Path, help="Dossier d’évaluation à ouvrir") 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 evaluation = args.evaluation.resolve() if args.evaluation else None
app = CopienatorApp(repository, personal_steps_enabled(), evaluation) app = CopienatorApp(repository, personal_steps_enabled(), evaluation)
app.mainloop() app.mainloop()
return 0
if __name__ == "__main__": 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 typing import Any
from copienator import ExitCode 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 .diagnostics import collect_diagnostics
from .runner import ProcessRunner from .runner import ProcessRunner
@@ -71,7 +71,7 @@ def detected_annotation_directories(evaluation: Path) -> tuple[str, ...]:
def plotting_shortcut_lines() -> list[str]: def plotting_shortcut_lines() -> list[str]:
try: try:
from config import PLOTTING_KB from copienator.configuration import PLOTTING_KB
except (ImportError, AttributeError): except (ImportError, AttributeError):
from default_config import PLOTTING_KB from default_config import PLOTTING_KB
+2 -2
View File
@@ -7,7 +7,7 @@ import sys
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from platform_utils import windows_filename_problems from copienator.platform import windows_filename_problems
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -111,7 +111,7 @@ def collect_diagnostics(
) )
) )
try: 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 ( for label, configured_path in (
("ODS courant", CURRENT_SCORE_ODS_PATH), ("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é", "Analyser l’énoncé",
"Détecte les questions, leurs groupes, le corrigé et les indications de barème.", "Détecte les questions, leurs groupes, le corrigé et les indications de barème.",
( (
python("gemini", "Analyse avec Gemini", "gemini_for_enonce.py"), python("gemini", "Analyse avec Gemini", "statement"),
python("personal", "Alternative enonce_info.py", "enonce_info.py"), python("personal", "Alternative personnelle", "statement-personal"),
), ),
arguments=( arguments=(
arg_target("Dossier de l’évaluation"), arg_target("Dossier de l’évaluation"),
@@ -124,7 +124,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
CommandVariant( CommandVariant(
"rotate", "rotate",
"Rotation", "Rotation",
"copies_tools.py", "copies",
"python", "python",
("rotate",), ("rotate",),
fixed_args_before_positionals=True, fixed_args_before_positionals=True,
@@ -142,7 +142,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
CommandVariant( CommandVariant(
"rename", "rename",
"Renommage", "Renommage",
"copies_tools.py", "copies",
"python", "python",
("rename",), ("rename",),
fixed_args_before_positionals=True, fixed_args_before_positionals=True,
@@ -155,7 +155,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Prétraitement des copies", "Prétraitement des copies",
"Séparer et réordonner les pages", "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.", "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(),), arguments=(arg_target(),),
artifacts=("Copies", "Copies Originales"), artifacts=("Copies", "Copies Originales"),
), ),
@@ -164,7 +164,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Prétraitement des copies", "Prétraitement des copies",
"Découper la marge des labels", "Découper la marge des labels",
"Produit les images de la partie gauche des copies. Une copie précise peut être ciblée.", "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=( arguments=(
arg_target(), arg_target(),
ArgumentSpec("fullpage", "Toujours utiliser la page entière", "bool", "--fullpage"), 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", "Labels et regroupement",
"Détecter les labels avec Gemini", "Détecter les labels avec Gemini",
"Identifie les labels dans les images produites par la découpe gauche.", "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=( arguments=(
arg_target(), arg_target(),
ArgumentSpec("overwrite", "Régénérer les résultats", "bool", "--overwrite"), 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", "Labels et regroupement",
"Vérifier visuellement les labels", "Vérifier visuellement les labels",
"Ouvre la fenêtre de vérification. La cible peut être toute l’évaluation ou une copie.", "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(),), arguments=(arg_target(),),
requires=("labels", "Cutleft"), requires=("labels", "Cutleft"),
artifacts=("Copies/Copie*.json",), artifacts=("Copies/Copie*.json",),
@@ -200,7 +200,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Labels et regroupement", "Labels et regroupement",
"Découper les réponses par question", "Découper les réponses par question",
"Découpe les copies à partir des coordonnées de labels vérifiées.", "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(),), arguments=(arg_target(),),
requires=("Copies",), requires=("Copies",),
artifacts=("Copies/Copie*/*",), artifacts=("Copies/Copie*/*",),
@@ -211,7 +211,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Labels et regroupement", "Labels et regroupement",
"Regrouper les réponses", "Regrouper les réponses",
"Regroupe les réponses portant le même label pour préparer les requêtes.", "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"),), arguments=(arg_target("Dossier de l’évaluation"),),
requires=("Copies",), requires=("Copies",),
artifacts=("Par label",), artifacts=("Par label",),
@@ -222,7 +222,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Labels et regroupement", "Labels et regroupement",
"Vérifier les groupes produits", "Vérifier les groupes produits",
"Vérifie que chaque réponse PDF apparaît dans les métadonnées des groupes.", "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"),), arguments=(arg_target("Dossier de l’évaluation"),),
optional=True, optional=True,
requires=("Copies", "Par label"), requires=("Copies", "Par label"),
@@ -233,17 +233,17 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Lancer ou intégrer la correction", "Lancer ou intégrer la correction",
"Choisir une correction immédiate, batch, hybride, une recorrection, ou lintégration dun batch.", "Choisir une correction immédiate, batch, hybride, une recorrection, ou lintégration dun batch.",
( (
python("live", "Correction immédiate", "correction.py"), python("live", "Correction immédiate", "correct"),
python("batch", "Préparer toutes les requêtes batch", "correction.py", fixed_args=("--batch",)), python("batch", "Préparer toutes les requêtes batch", "correct", fixed_args=("--batch",)),
python("hybrid", "Batch à partir dun label", "correction.py"), python("hybrid", "Batch à partir dun label", "correct"),
python("refaire", "Recorrection depuis refaire.json", "correction.py", fixed_args=("--refaire",)), python("refaire", "Recorrection depuis refaire.json", "correct", fixed_args=("--refaire",)),
python( python(
"integrate", "integrate",
"Intégrer les résultats batch", "Intégrer les résultats batch",
"correction.py", "correct",
fixed_args=("--deal-with-batched",), 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=( arguments=(
arg_target("Évaluation ou image Group_X.jpg"), arg_target("Évaluation ou image Group_X.jpg"),
@@ -259,7 +259,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Correction", "Correction",
"Envoyer les batchs", "Envoyer les batchs",
"Envoie à Gemini les fichiers JSONL produits par le mode batch.", "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"),), arguments=(arg_target("Dossier de l’évaluation"),),
optional=True, optional=True,
artifacts=("batch_jobs.json",), artifacts=("batch_jobs.json",),
@@ -270,7 +270,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Correction", "Correction",
"Consulter l’état des batchs", "Consulter l’état des batchs",
"Affiche les jobs Gemini en cours. Lidentifiant de téléchargement est facultatif.", "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"),), arguments=(ArgumentSpec("download", "Télécharger le job", "text", "--download"),),
optional=True, optional=True,
skip_for_live_correction=True, skip_for_live_correction=True,
@@ -280,7 +280,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Correction", "Correction",
"Récupérer les résultats batch", "Récupérer les résultats batch",
"Télécharge et rassemble les réponses des jobs terminés.", "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"),), arguments=(arg_target("Dossier de l’évaluation"),),
optional=True, optional=True,
skip_for_live_correction=True, skip_for_live_correction=True,
@@ -290,7 +290,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Correction", "Correction",
"Nettoyer la correction", "Nettoyer la correction",
"Corrige certains problèmes dencodage et prépare le texte pour LaTeX.", "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"),), arguments=(arg_target("Dossier de l’évaluation"),),
requires=("correction.json",), requires=("correction.json",),
), ),
@@ -299,7 +299,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Correction", "Correction",
"Résoudre les conflits manuels", "Résoudre les conflits manuels",
"Étape conditionnelle, uniquement si manual_resolutions.txt contient des conflits à traiter.", "É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"),), arguments=(arg_target("Dossier de l’évaluation"),),
optional=True, optional=True,
requires=("manual_resolutions.txt", "correction.json"), 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", "Générer les copies annotées",
"Les trois modes sont exclusifs pour un parcours donné.", "Les trois modes sont exclusifs pour un parcours donné.",
( (
python("simple", "Annotations simples (Anot)", "annotating.py"), python("simple", "Annotations simples (Anot)", "annotate-simple"),
python("checks", "Annotations avec cases (Bnot)", "annotating_with_checks.py"), python("checks", "Annotations avec cases (Bnot)", "annotate-checks"),
python("grouped", "Annotations groupées (BGnot)", "annotating_by_label.py"), python("grouped", "Annotations groupées (BGnot)", "annotate-grouped"),
), ),
arguments=( arguments=(
arg_target("Dossier de l’évaluation"), arg_target("Dossier de l’évaluation"),
@@ -328,7 +328,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Génération des annotations", "Génération des annotations",
"Exporter", "Exporter",
"Exporte les annotations vers le dossier EXPORT_DIR défini dans config.py.", "Exporte les annotations vers le dossier EXPORT_DIR défini dans config.py.",
(python("default", "Export", "export.py"),), (python("default", "Export", "export"),),
arguments=( arguments=(
arg_target("Dossier de l’évaluation"), arg_target("Dossier de l’évaluation"),
ArgumentSpec( ArgumentSpec(
@@ -355,7 +355,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Correction manuscrite", "Correction manuscrite",
"Importer les annotations manuscrites", "Importer les annotations manuscrites",
"Copie les PDF présents dans IMPORT_DIR vers l’évaluation.", "Copie les PDF présents dans IMPORT_DIR vers l’évaluation.",
(python("default", "Import", "import.py"),), (python("default", "Import", "import"),),
arguments=( arguments=(
arg_target("Dossier de l’évaluation"), arg_target("Dossier de l’évaluation"),
ArgumentSpec( ArgumentSpec(
@@ -375,8 +375,8 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Lire les annotations manuscrites", "Lire les annotations manuscrites",
"Le mode doit correspondre au mode choisi lors de la génération des annotations.", "Le mode doit correspondre au mode choisi lors de la génération des annotations.",
( (
python("standard", "Lecture Bnot", "reading_annotations.py"), python("standard", "Lecture Bnot", "read-annotations"),
python("grouped", "Lecture BGnot", "reading_grouped_annotations.py"), python("grouped", "Lecture BGnot", "read-grouped"),
), ),
arguments=( arguments=(
arg_target("Dossier de l’évaluation"), arg_target("Dossier de l’évaluation"),
@@ -389,7 +389,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Finalisation", "Finalisation",
"Attribuer les noms et préparer A Rendre", "Attribuer les noms et préparer A Rendre",
"Crée le dossier A Rendre à partir du dossier dannotations choisi.", "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=( arguments=(
arg_target("Dossier de l’évaluation"), arg_target("Dossier de l’évaluation"),
ArgumentSpec( ArgumentSpec(
@@ -424,7 +424,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Étapes personnelles", "Étapes personnelles",
"Mettre à jour le fichier ODS", "Mettre à jour le fichier ODS",
"Transfère les scores, avec la possibilité de n’écrire que leur somme.", "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=( arguments=(
arg_target("Dossier de l’évaluation"), arg_target("Dossier de l’évaluation"),
ArgumentSpec("sum", "Écrire seulement la somme", "bool", "--sum"), ArgumentSpec("sum", "Écrire seulement la somme", "bool", "--sum"),
@@ -452,7 +452,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Étapes personnelles", "Étapes personnelles",
"Ajouter le score final", "Ajouter le score final",
"Génère les fichiers de diffusion avec 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"),), arguments=(arg_target("Dossier de l’évaluation"),),
personal=True, personal=True,
), ),
@@ -494,7 +494,7 @@ def build_command(
program_path = repository / variant.program program_path = repository / variant.program
if variant.kind == "python": if variant.kind == "python":
command = [sys.executable, "-u", str(program_path)] command = [sys.executable, "-u", "-m", "copienator", variant.program]
elif variant.kind == "shell": elif variant.kind == "shell":
command = [str(program_path)] command = [str(program_path)]
else: 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 from __future__ import annotations
import importlib.util import importlib
import io import io
import json import json
import os import os
@@ -31,6 +31,7 @@ from copienator import (
from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides
from copienator.annotation_data import AnnotationLoadResult, load_annotation_data from copienator.annotation_data import AnnotationLoadResult, load_annotation_data
from copienator.filesystem import staged_directory, staged_files from copienator.filesystem import staged_directory, staged_files
from copienator.dispatcher import COMMANDS, main as dispatcher_main
from copienator_gui.app import ( from copienator_gui.app import (
CopienatorApp, CopienatorApp,
DEFAULT_HTTPS_PROXY, DEFAULT_HTTPS_PROXY,
@@ -45,8 +46,8 @@ from copienator_gui.diagnostics import collect_diagnostics
from copienator_gui.runner import ProcessRunner from copienator_gui.runner import ProcessRunner
from copienator_gui.state import StateStore from copienator_gui.state import StateStore
from copienator_gui.workflow import build_command, build_workflow, evaluation_argument from copienator_gui.workflow import build_command, build_workflow, evaluation_argument
from copies_tools import rename_all, rotate_all from copienator.commands.copies_tools import rename_all, rotate_all
from platform_utils import ( from copienator.platform import (
WindowsLabelError, WindowsLabelError,
replace_with_link_or_copy, replace_with_link_or_copy,
safe_filename, safe_filename,
@@ -57,18 +58,16 @@ from platform_utils import (
REPOSITORY = Path(__file__).resolve().parents[1] REPOSITORY = Path(__file__).resolve().parents[1]
def load_script_module(filename: str, module_name: str): def load_script_module(filename: str, _module_name: str):
spec = importlib.util.spec_from_file_location(module_name, REPOSITORY / filename) stem = Path(filename).stem.replace("-", "_")
if spec is None or spec.loader is None: if stem == "import":
raise RuntimeError(f"Could not load {filename}") stem = "import_annotations"
module = importlib.util.module_from_spec(spec) return importlib.import_module(f"copienator.commands.{stem}")
sys.modules[module_name] = module
try:
spec.loader.exec_module(module) def command_arguments(command: list[str]) -> list[str]:
except Exception: assert command[2:4] == ["-m", "copienator"]
sys.modules.pop(module_name, None) return command[5:]
raise
return module
class WorkspaceTests(unittest.TestCase): class WorkspaceTests(unittest.TestCase):
@@ -129,6 +128,24 @@ class WorkspaceTests(unittest.TestCase):
self.assertEqual(workspace.names_file(), root / "names") 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): class AtomicJsonTests(unittest.TestCase):
def test_atomic_binary_round_trip(self) -> None: def test_atomic_binary_round_trip(self) -> None:
with tempfile.TemporaryDirectory() as directory: 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) variant = next(item for item in step.variants if item.id == variant_id)
command = build_command(REPOSITORY, step, variant, values, evaluation) command = build_command(REPOSITORY, step, variant, values, evaluation)
with self.subTest(script=module_name): 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 parsed_path = getattr(parsed, "evaluation", None) or parsed.target
self.assertEqual(str(parsed_path), evaluation) self.assertEqual(str(parsed_path), evaluation)
@@ -577,7 +596,7 @@ class StandardCliTests(unittest.TestCase):
) )
with self.subTest(script=f"copies_tools:{step_id}"): with self.subTest(script=f"copies_tools:{step_id}"):
parsed = self.modules["copies_tools"].build_parser().parse_args( parsed = self.modules["copies_tools"].build_parser().parse_args(
command[3:] command_arguments(command)
) )
self.assertEqual(parsed.operation, step_id) self.assertEqual(parsed.operation, step_id)
self.assertEqual(str(parsed.evaluation), evaluation) self.assertEqual(str(parsed.evaluation), evaluation)
@@ -1521,7 +1540,7 @@ class WorkflowTests(unittest.TestCase):
"default", "default",
{"target": self.evaluation, "annotation_dir": "Bnot"}, {"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: def test_copy_listing_prefers_processed_copies(self) -> None:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
@@ -1607,27 +1626,31 @@ class WorkflowTests(unittest.TestCase):
"live", "live",
{"target": self.evaluation, "overwrite": True, "limit": "0"}, {"target": self.evaluation, "overwrite": True, "limit": "0"},
) )
self.assertEqual(command[3:], [self.evaluation, "--overwrite", "--limit", "0"]) self.assertEqual(
self.assertIn("correction.py", command[2]) command_arguments(command),
[self.evaluation, "--overwrite", "--limit", "0"],
)
self.assertEqual(command[4], "correct")
def test_hybrid_correction_arguments(self) -> None: def test_hybrid_correction_arguments(self) -> None:
command = self.command( command = self.command(
"correction", "hybrid", {"target": self.evaluation, "batch_from": "Ex 4"} "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: def test_annotation_variants_are_exclusive_commands(self) -> None:
command = self.command( command = self.command(
"annotation", "grouped", {"target": self.evaluation, "overwrite": True} "annotation", "grouped", {"target": self.evaluation, "overwrite": True}
) )
self.assertIn("annotating_by_label.py", command[2]) self.assertEqual(command[4], "annotate-grouped")
self.assertNotIn("annotating.py", command[2])
def test_copy_preparation_commands_are_python(self) -> None: def test_copy_preparation_commands_are_python(self) -> None:
rotate = self.command("rotate", "rotate", {"target": self.evaluation}) rotate = self.command("rotate", "rotate", {"target": self.evaluation})
rename = self.command("rename", "rename", {"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(rotate[2:], ["-m", "copienator", "copies", "rotate", self.evaluation])
self.assertEqual(rename[2:], [str(REPOSITORY / "copies_tools.py"), "rename", self.evaluation]) self.assertEqual(rename[2:], ["-m", "copienator", "copies", "rename", self.evaluation])
class CrossPlatformFileTests(unittest.TestCase): class CrossPlatformFileTests(unittest.TestCase):
@@ -1665,8 +1688,8 @@ class CrossPlatformFileTests(unittest.TestCase):
source = root / "source.txt" source = root / "source.txt"
destination = root / "destination.txt" destination = root / "destination.txt"
source.write_text("content", encoding="utf-8") source.write_text("content", encoding="utf-8")
with patch("platform_utils.os.link", side_effect=OSError), patch( with patch("copienator.platform.os.link", side_effect=OSError), patch(
"platform_utils.os.symlink", side_effect=OSError "copienator.platform.os.symlink", side_effect=OSError
): ):
method = replace_with_link_or_copy(source, destination) method = replace_with_link_or_copy(source, destination)
self.assertEqual(method, "copy") self.assertEqual(method, "copy")