Améliorations GUI
This commit is contained in:
+24
-44
@@ -1,7 +1,7 @@
|
|||||||
#+title: Script
|
#+title: Script
|
||||||
#+author: Sébastien Miquel
|
#+author: Sébastien Miquel
|
||||||
#+date: 14-03-2026
|
#+date: 14-03-2026
|
||||||
# Time-stamp: <20-08-26 11:37>
|
# Time-stamp: <22-08-26 09:58>
|
||||||
#+OPTIONS:
|
#+OPTIONS:
|
||||||
|
|
||||||
* Méta
|
* Méta
|
||||||
@@ -21,23 +21,6 @@ pour faire corriger des copies par Gemini.
|
|||||||
4. Ces annotations manuscrites sont lues et recompilées en une
|
4. Ces annotations manuscrites sont lues et recompilées en une
|
||||||
version de la copie pour l'élève.
|
version de la copie pour l'élève.
|
||||||
|
|
||||||
** Disclaimer
|
|
||||||
|
|
||||||
J'utilise régulièrement cet outil et j'en suis satisfait, mais j'ai
|
|
||||||
fait peu d'efforts pour le rendre universel et simple à l'emploi.
|
|
||||||
Plusieurs parties de mon workflow sont spécifiques à mes
|
|
||||||
représentations internes des sujets d'examens et/ou à mon environment.
|
|
||||||
|
|
||||||
L'utilisation de ce système nécessite une familiarité avec python et
|
|
||||||
la ligne de commande unix ; cette familiarité n'est probablement pas
|
|
||||||
suffisante en l'état, mais en théorie il devrait être possible de le
|
|
||||||
faire tourner sur le dossier =Example= qui contient une copie
|
|
||||||
initiale, les fichiers correspondant au sujet de l'interro, et des
|
|
||||||
examples du rendu final (dans le sous dossier =BGnot=).
|
|
||||||
|
|
||||||
Cette situation s'améliorera peut-être, mais faciliter l'utilisation
|
|
||||||
de ce système n'est pas une priorité.
|
|
||||||
|
|
||||||
** Limitations
|
** Limitations
|
||||||
|
|
||||||
Pour l'instant, la correction est faite question par question : le LLM
|
Pour l'instant, la correction est faite question par question : le LLM
|
||||||
@@ -47,13 +30,8 @@ question précédente ou autre.
|
|||||||
|
|
||||||
*** Noms de labels sous Windows
|
*** Noms de labels sous Windows
|
||||||
|
|
||||||
Certains labels sont utilisés directement comme noms de fichiers et de
|
Les labels de questions sont utilisés comme noms de fichiers. Les
|
||||||
dossiers. Les labels contenant notamment =:= ne sont pas acceptés par
|
labels contenant notamment =:= ne sont pas acceptés par Windows.
|
||||||
Windows. Sous Windows, les scripts refusent ces labels avant de
|
|
||||||
poursuivre et affichent la liste des valeurs à corriger. Aucune
|
|
||||||
conversion silencieuse n'est appliquée ; utiliser par exemple
|
|
||||||
=Ex 1 - a)= au lieu de =Ex 1 : a)=. Le diagnostic du GUI permet de les
|
|
||||||
repérer avant de lancer une étape.
|
|
||||||
|
|
||||||
** Requirements
|
** Requirements
|
||||||
|
|
||||||
@@ -75,9 +53,6 @@ pip install numpy pandas matplotlib pillow pydantic pypdf pdf2image reportlab im
|
|||||||
|
|
||||||
Il faut créer une clef API pour Gemini (pas facile).
|
Il faut créer une clef API pour Gemini (pas facile).
|
||||||
|
|
||||||
NB : Lors de la création, google offre (offrait ?) 300€ d'utilisation,
|
|
||||||
mais seulement pendant les trois mois à venir.
|
|
||||||
|
|
||||||
Puis ajouter =GEMINI_API_KEY= à l'environnement avec :
|
Puis ajouter =GEMINI_API_KEY= à l'environnement avec :
|
||||||
|
|
||||||
#+BEGIN_SRC bash
|
#+BEGIN_SRC bash
|
||||||
@@ -91,7 +66,15 @@ ou éventuellement, la renseigner directement dans le fichier
|
|||||||
|
|
||||||
Copier `default_config.py` en `config.py`. Éventuellement le modifier.
|
Copier `default_config.py` en `config.py`. Éventuellement le modifier.
|
||||||
|
|
||||||
*** Interface graphique
|
** Correction d'un paquet de copies
|
||||||
|
|
||||||
|
1. Créer un fichier =names= dans le dossier courant, avec les
|
||||||
|
noms/prénoms des élèves, un par ligne
|
||||||
|
2. Créer un dossier correspondant à l'évaluation (=Interro= dans la
|
||||||
|
suite)
|
||||||
|
3. Suivre les instructions suivantes
|
||||||
|
|
||||||
|
** Interface graphique
|
||||||
|
|
||||||
Lancer l'assistant avec :
|
Lancer l'assistant avec :
|
||||||
|
|
||||||
@@ -99,15 +82,21 @@ Lancer l'assistant avec :
|
|||||||
python gui.py
|
python gui.py
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
On peut aussi ouvrir directement une évaluation avec
|
On peut aussi ouvrir directement une évaluation avec =python gui.py
|
||||||
=python gui.py Interro=. L'interface conserve l'état et l'historique
|
Interro=. L'interface conserve l'état et l'historique des étapes dans
|
||||||
des étapes dans =Interro/.copienator-gui.json=, et les sorties complètes
|
=Interro/.copienator-gui.json=, et les sorties complètes dans
|
||||||
dans =Interro/.copienator/logs/=. Une relance d'une étape antérieure ne
|
=Interro/.copienator/logs/=. Une relance d'une étape antérieure ne
|
||||||
supprime aucun résultat ; les étapes suivantes sont seulement marquées
|
supprime aucun résultat ; les étapes suivantes sont seulement marquées
|
||||||
comme étant à revalider.
|
comme étant à revalider.
|
||||||
|
|
||||||
La variable =SHOW_PERSONAL_STEPS= de =config.py= permet d'afficher ou de
|
Après la réussite d'un script, l'interface sélectionne automatiquement
|
||||||
masquer les étapes propres au workflow personnel.
|
l'étape suivante. =Découper les réponses par question= et =Regrouper
|
||||||
|
les réponses= démarrent automatiquement lors de leur première visite.
|
||||||
|
Avec le mode =Correction immédiate=, les trois étapes batch sont alors
|
||||||
|
marquées =Ignorée= à leur première visite. L'étape de résolution
|
||||||
|
manuelle est traitée de la même façon si =manual_resolutions.txt= est
|
||||||
|
absent, vide ou ne contient que des commentaires. Ces automatismes ne
|
||||||
|
se répètent pas lors d'un retour en arrière.
|
||||||
|
|
||||||
Le bouton =Diagnostic…= vérifie les modules Python, Poppler, LaTeX,
|
Le bouton =Diagnostic…= vérifie les modules Python, Poppler, LaTeX,
|
||||||
PDF Arranger et la configuration Gemini. Sous Windows, les exécutables
|
PDF Arranger et la configuration Gemini. Sous Windows, les exécutables
|
||||||
@@ -182,15 +171,6 @@ scripts migrés vers cette convention sont actuellement :
|
|||||||
- =reading_annotations.py= et =reading_grouped_annotations.py= ;
|
- =reading_annotations.py= et =reading_grouped_annotations.py= ;
|
||||||
- =export.py=, =import.py= et =giving_names.py=.
|
- =export.py=, =import.py= et =giving_names.py=.
|
||||||
|
|
||||||
** Correction d'un paquet de copies
|
|
||||||
|
|
||||||
1. Créer un fichier =names= dans le dossier courant, avec les
|
|
||||||
noms/prénoms des élèves, un par ligne
|
|
||||||
2. Créer un dossier correspondant à l'évaluation (=Interro= dans la
|
|
||||||
suite)
|
|
||||||
3. Suivre les instructions suivantes
|
|
||||||
|
|
||||||
|
|
||||||
* Étapes et Script
|
* Étapes et Script
|
||||||
|
|
||||||
Utiliser `python gui.py` ou `python gui.py Interro` pour lancer un GUI
|
Utiliser `python gui.py` ou `python gui.py Interro` pour lancer un GUI
|
||||||
|
|||||||
+84
-4
@@ -46,6 +46,19 @@ def process_status(return_code: int, interrupted: bool = False) -> str:
|
|||||||
return "failed"
|
return "failed"
|
||||||
|
|
||||||
|
|
||||||
|
def has_manual_conflicts(path: Path) -> bool:
|
||||||
|
"""Return whether a manual-resolution file contains an instruction."""
|
||||||
|
try:
|
||||||
|
lines = path.read_text(encoding="utf-8").splitlines()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return False
|
||||||
|
except (OSError, UnicodeError):
|
||||||
|
# If the file cannot be inspected, keep the step visible rather than
|
||||||
|
# silently claiming that there is nothing to resolve.
|
||||||
|
return True
|
||||||
|
return any(line.strip() and not line.lstrip().startswith("###") for line in lines)
|
||||||
|
|
||||||
|
|
||||||
class CopienatorApp(tk.Tk):
|
class CopienatorApp(tk.Tk):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -354,6 +367,54 @@ class CopienatorApp(tk.Tk):
|
|||||||
self._save_current_form()
|
self._save_current_form()
|
||||||
self.current_step = step
|
self.current_step = step
|
||||||
self._render_step()
|
self._render_step()
|
||||||
|
self._handle_first_visit(step)
|
||||||
|
|
||||||
|
def _handle_first_visit(self, step: StepDefinition) -> None:
|
||||||
|
if not self.state_store.evaluation:
|
||||||
|
return
|
||||||
|
entry = self.state_store.step(step.id)
|
||||||
|
if entry.get("visited"):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Persist this before scheduling an action so selection callbacks cannot
|
||||||
|
# trigger the same automatic behavior twice.
|
||||||
|
self.state_store.update_step(step.id, visited=True)
|
||||||
|
|
||||||
|
if step.skip_for_live_correction:
|
||||||
|
correction = self.state_store.step("correction")
|
||||||
|
if correction.get("variant", "live") == "live":
|
||||||
|
self.after_idle(
|
||||||
|
lambda step_id=step.id: self._automatic_skip(
|
||||||
|
step_id, "correction immédiate sélectionnée"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if step.skip_without_manual_conflicts:
|
||||||
|
evaluation = self.evaluation
|
||||||
|
conflicts = evaluation / "manual_resolutions.txt" if evaluation else None
|
||||||
|
if conflicts is None or not has_manual_conflicts(conflicts):
|
||||||
|
self.after_idle(
|
||||||
|
lambda step_id=step.id: self._automatic_skip(
|
||||||
|
step_id, "aucun conflit manuel détecté"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if step.auto_start_first_visit and not entry.get("status") and not self._artifacts_exist(step):
|
||||||
|
self.after_idle(lambda step_id=step.id: self._automatic_start(step_id))
|
||||||
|
|
||||||
|
def _automatic_start(self, step_id: str) -> None:
|
||||||
|
if self.runner.running or not self.current_step or self.current_step.id != step_id:
|
||||||
|
return
|
||||||
|
self.info_var.set(f"{self.current_step.title} : démarrage automatique.")
|
||||||
|
self._run_current_step()
|
||||||
|
|
||||||
|
def _automatic_skip(self, step_id: str, reason: str) -> None:
|
||||||
|
if self.runner.running or not self.current_step or self.current_step.id != step_id:
|
||||||
|
return
|
||||||
|
self._mark_step("skipped", automatic=True, reason=reason)
|
||||||
|
self._move_selection_from(step_id, 1)
|
||||||
|
|
||||||
def _render_step(self) -> None:
|
def _render_step(self) -> None:
|
||||||
step = self.current_step
|
step = self.current_step
|
||||||
@@ -601,15 +662,27 @@ class CopienatorApp(tk.Tk):
|
|||||||
self._populate_tree()
|
self._populate_tree()
|
||||||
self._update_controls()
|
self._update_controls()
|
||||||
|
|
||||||
def _mark_step(self, status: str) -> None:
|
def _mark_step(
|
||||||
|
self, status: str, *, automatic: bool = False, reason: str | None = None
|
||||||
|
) -> None:
|
||||||
if not self.current_step or not self.state_store.evaluation:
|
if not self.current_step or not self.state_store.evaluation:
|
||||||
return
|
return
|
||||||
self._save_current_form()
|
self._save_current_form()
|
||||||
self.state_store.invalidate_after([item.id for item in self.steps], self.current_step.id)
|
self.state_store.invalidate_after([item.id for item in self.steps], self.current_step.id)
|
||||||
self.state_store.update_step(self.current_step.id, status=status)
|
self.state_store.update_step(self.current_step.id, status=status)
|
||||||
self.state_store.add_history({"step": self.current_step.id, "status": status, "manual": True})
|
history: dict[str, object] = {
|
||||||
|
"step": self.current_step.id,
|
||||||
|
"status": status,
|
||||||
|
"manual": not automatic,
|
||||||
|
}
|
||||||
|
if reason:
|
||||||
|
history["reason"] = reason
|
||||||
|
self.state_store.add_history(history)
|
||||||
self._populate_tree()
|
self._populate_tree()
|
||||||
self.info_var.set(f"{self.current_step.title} : {STATUS_LABELS.get(status, status).lower()}.")
|
detail = f" ({reason})" if reason else ""
|
||||||
|
self.info_var.set(
|
||||||
|
f"{self.current_step.title} : {STATUS_LABELS.get(status, status).lower()}{detail}."
|
||||||
|
)
|
||||||
|
|
||||||
def _skip_step(self) -> None:
|
def _skip_step(self) -> None:
|
||||||
if self.current_step and self.current_step.optional:
|
if self.current_step and self.current_step.optional:
|
||||||
@@ -652,6 +725,8 @@ class CopienatorApp(tk.Tk):
|
|||||||
self.active_step_id = None
|
self.active_step_id = None
|
||||||
self._populate_tree()
|
self._populate_tree()
|
||||||
self._update_controls()
|
self._update_controls()
|
||||||
|
if status == "success":
|
||||||
|
self.after_idle(lambda completed_id=step_id: self._move_selection_from(completed_id, 1))
|
||||||
|
|
||||||
def _send_input(self) -> None:
|
def _send_input(self) -> None:
|
||||||
text = self.stdin_var.get()
|
text = self.stdin_var.get()
|
||||||
@@ -703,12 +778,17 @@ class CopienatorApp(tk.Tk):
|
|||||||
def _move_selection(self, delta: int) -> None:
|
def _move_selection(self, delta: int) -> None:
|
||||||
if not self.current_step:
|
if not self.current_step:
|
||||||
return
|
return
|
||||||
|
self._move_selection_from(self.current_step.id, delta)
|
||||||
|
|
||||||
|
def _move_selection_from(self, step_id: str, delta: int) -> None:
|
||||||
ids = [step.id for step in self.steps]
|
ids = [step.id for step in self.steps]
|
||||||
try:
|
try:
|
||||||
index = ids.index(self.current_step.id)
|
index = ids.index(step_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return
|
return
|
||||||
target = max(0, min(len(ids) - 1, index + delta))
|
target = max(0, min(len(ids) - 1, index + delta))
|
||||||
|
if target == index:
|
||||||
|
return
|
||||||
self.tree.selection_set(ids[target])
|
self.tree.selection_set(ids[target])
|
||||||
self.tree.see(ids[target])
|
self.tree.see(ids[target])
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ class StepDefinition:
|
|||||||
personal: bool = False
|
personal: bool = False
|
||||||
requires: tuple[str, ...] = ()
|
requires: tuple[str, ...] = ()
|
||||||
artifacts: tuple[str, ...] = ()
|
artifacts: tuple[str, ...] = ()
|
||||||
|
auto_start_first_visit: bool = False
|
||||||
|
skip_for_live_correction: bool = False
|
||||||
|
skip_without_manual_conflicts: bool = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_manual(self) -> bool:
|
def is_manual(self) -> bool:
|
||||||
@@ -201,6 +204,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
arguments=(arg_target(),),
|
arguments=(arg_target(),),
|
||||||
requires=("Copies",),
|
requires=("Copies",),
|
||||||
artifacts=("Copies/Copie*/*",),
|
artifacts=("Copies/Copie*/*",),
|
||||||
|
auto_start_first_visit=True,
|
||||||
),
|
),
|
||||||
StepDefinition(
|
StepDefinition(
|
||||||
"grouping",
|
"grouping",
|
||||||
@@ -211,6 +215,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
arguments=(arg_target("Dossier de l’évaluation"),),
|
arguments=(arg_target("Dossier de l’évaluation"),),
|
||||||
requires=("Copies",),
|
requires=("Copies",),
|
||||||
artifacts=("Par label",),
|
artifacts=("Par label",),
|
||||||
|
auto_start_first_visit=True,
|
||||||
),
|
),
|
||||||
StepDefinition(
|
StepDefinition(
|
||||||
"verify_groups",
|
"verify_groups",
|
||||||
@@ -258,6 +263,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
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",),
|
||||||
|
skip_for_live_correction=True,
|
||||||
),
|
),
|
||||||
StepDefinition(
|
StepDefinition(
|
||||||
"batch_status",
|
"batch_status",
|
||||||
@@ -267,6 +273,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
(python("default", "État des batchs", "batch_status.py"),),
|
(python("default", "État des batchs", "batch_status.py"),),
|
||||||
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,
|
||||||
),
|
),
|
||||||
StepDefinition(
|
StepDefinition(
|
||||||
"fetch_batches",
|
"fetch_batches",
|
||||||
@@ -276,6 +283,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
(python("default", "Récupération", "fetch_batched_results.py"),),
|
(python("default", "Récupération", "fetch_batched_results.py"),),
|
||||||
arguments=(arg_target("Dossier de l’évaluation"),),
|
arguments=(arg_target("Dossier de l’évaluation"),),
|
||||||
optional=True,
|
optional=True,
|
||||||
|
skip_for_live_correction=True,
|
||||||
),
|
),
|
||||||
StepDefinition(
|
StepDefinition(
|
||||||
"post_correction",
|
"post_correction",
|
||||||
@@ -295,6 +303,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
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"),
|
||||||
|
skip_without_manual_conflicts=True,
|
||||||
),
|
),
|
||||||
StepDefinition(
|
StepDefinition(
|
||||||
"annotation",
|
"annotation",
|
||||||
|
|||||||
+42
-1
@@ -31,7 +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_gui.app import process_status
|
from copienator_gui.app import has_manual_conflicts, process_status
|
||||||
from copienator_gui.diagnostics import collect_diagnostics
|
from copienator_gui.diagnostics import collect_diagnostics
|
||||||
from copienator_gui.runner import ProcessRunner
|
from copienator_gui.runner import ProcessRunner
|
||||||
from copienator_gui.state import StateStore
|
from copienator_gui.state import StateStore
|
||||||
@@ -169,6 +169,17 @@ class AtomicJsonTests(unittest.TestCase):
|
|||||||
persisted = read_json(root / ".copienator-gui.json")
|
persisted = read_json(root / ".copienator-gui.json")
|
||||||
self.assertEqual(persisted["steps"]["labels"]["status"], "success")
|
self.assertEqual(persisted["steps"]["labels"]["status"], "success")
|
||||||
|
|
||||||
|
def test_invalidation_preserves_first_visit_marker(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
store = StateStore()
|
||||||
|
store.load(Path(directory))
|
||||||
|
store.update_step("splitting", status="success", visited=True)
|
||||||
|
|
||||||
|
store.invalidate_after(["plotting", "splitting"], "plotting")
|
||||||
|
|
||||||
|
self.assertEqual(store.step("splitting")["status"], "stale")
|
||||||
|
self.assertTrue(store.step("splitting")["visited"])
|
||||||
|
|
||||||
|
|
||||||
class AnnotationDataTests(unittest.TestCase):
|
class AnnotationDataTests(unittest.TestCase):
|
||||||
def test_loader_indexes_coordinates_without_mutating_correction(self) -> None:
|
def test_loader_indexes_coordinates_without_mutating_correction(self) -> None:
|
||||||
@@ -1366,6 +1377,36 @@ class WorkflowTests(unittest.TestCase):
|
|||||||
self.assertNotIn("update_ods", standard_ids)
|
self.assertNotIn("update_ods", standard_ids)
|
||||||
self.assertIn("update_ods", personal_ids)
|
self.assertIn("update_ods", personal_ids)
|
||||||
|
|
||||||
|
def test_first_visit_automation_is_declared_on_expected_steps(self) -> None:
|
||||||
|
auto_start = {
|
||||||
|
step.id for step in self.steps.values() if step.auto_start_first_visit
|
||||||
|
}
|
||||||
|
skip_for_live = {
|
||||||
|
step.id for step in self.steps.values() if step.skip_for_live_correction
|
||||||
|
}
|
||||||
|
skip_without_conflicts = {
|
||||||
|
step.id
|
||||||
|
for step in self.steps.values()
|
||||||
|
if step.skip_without_manual_conflicts
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assertEqual(auto_start, {"splitting", "grouping"})
|
||||||
|
self.assertEqual(
|
||||||
|
skip_for_live, {"submit_batches", "batch_status", "fetch_batches"}
|
||||||
|
)
|
||||||
|
self.assertEqual(skip_without_conflicts, {"manual_resolution"})
|
||||||
|
|
||||||
|
def test_manual_conflicts_ignore_blank_and_comment_lines(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "manual_resolutions.txt"
|
||||||
|
self.assertFalse(has_manual_conflicts(path))
|
||||||
|
path.write_text("\n### Instructions\n ### exemple\n", encoding="utf-8")
|
||||||
|
self.assertFalse(has_manual_conflicts(path))
|
||||||
|
path.write_text(
|
||||||
|
"### Instructions\nCopie01 Ex 1 -> Ex 2\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
self.assertTrue(has_manual_conflicts(path))
|
||||||
|
|
||||||
def test_live_correction_arguments(self) -> None:
|
def test_live_correction_arguments(self) -> None:
|
||||||
command = self.command(
|
command = self.command(
|
||||||
"correction",
|
"correction",
|
||||||
|
|||||||
Reference in New Issue
Block a user