Compare commits

..
2 Commits
14 changed files with 146 additions and 48 deletions

No files matched your search

+4 -1
View File
@@ -109,7 +109,10 @@ def _return_artifacts(workspace: EvaluationWorkspace) -> list[Path]:
def _is_statement_text_file(workspace: EvaluationWorkspace, path: Path) -> bool: def _is_statement_text_file(workspace: EvaluationWorkspace, path: Path) -> bool:
relative = path.relative_to(workspace.root) relative = path.relative_to(workspace.root)
if len(relative.parts) == 1: if len(relative.parts) == 1:
return relative.name in STATEMENT_ROOT_FILES return relative.name in STATEMENT_ROOT_FILES or path in {
*workspace.input_candidates("enonce.tex"),
*workspace.input_candidates("correction.tex"),
}
top_level = relative.parts[0] top_level = relative.parts[0]
if top_level in STATEMENT_TEXT_DIRECTORIES: if top_level in STATEMENT_TEXT_DIRECTORIES:
return path.suffix.casefold() in {"", ".json", ".tex", ".txt"} return path.suffix.casefold() in {"", ".json", ".tex", ".txt"}
+1 -2
View File
@@ -24,7 +24,7 @@ def copy_pdfs(directory: Path) -> list[Path]:
( (
path path
for path in directory.glob("*.pdf") for path in directory.glob("*.pdf")
if path.name.casefold() not in {"enonce.pdf", "énoncé.pdf"} if not EvaluationWorkspace(directory).is_statement_pdf(path)
), ),
key=lambda path: path.name.casefold(), key=lambda path: path.name.casefold(),
) )
@@ -134,4 +134,3 @@ def main(argv: Sequence[str] | None = None) -> int:
if __name__ == "__main__": if __name__ == "__main__":
raise SystemExit(main()) raise SystemExit(main())
+1 -1
View File
@@ -143,7 +143,7 @@ def save_split_content(text, path, base_fname, problem):
def process_directory(workspace: EvaluationWorkspace) -> ExitCode: def process_directory(workspace: EvaluationWorkspace) -> ExitCode:
directory = str(workspace.root) directory = str(workspace.root)
# Find the first .tex file in the directory # Find the first .tex file in the directory
enonce = workspace.root / "enonce.tex" enonce = workspace.statement_source_file
tex_files = [str(enonce)] if enonce.is_file() else sorted(glob.glob(os.path.join(directory, "*.tex"))) tex_files = [str(enonce)] if enonce.is_file() else sorted(glob.glob(os.path.join(directory, "*.tex")))
if not tex_files: if not tex_files:
print(f"No .tex file found in {directory}. Looking in /Staging/Interro/") print(f"No .tex file found in {directory}. Looking in /Staging/Interro/")
+27 -13
View File
@@ -8,7 +8,7 @@ from pathlib import Path
from google import genai from google import genai
from google.genai import types from google.genai import types
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, field_validator
from copienator import configuration as config from copienator import configuration as config
from copienator import utils from copienator import utils
@@ -22,7 +22,7 @@ from copienator import (
workspace_from_args, workspace_from_args,
) )
from copienator.platform import validate_windows_labels from copienator.platform import validate_windows_labels
from copienator.latex_json import repair_latex_json from copienator.latex_json import repair_latex_json, normalize_literal_newlines
from copienator.filesystem import staged_directory from copienator.filesystem import staged_directory
from copienator.utils import compile_to_pdf from copienator.utils import compile_to_pdf
@@ -43,7 +43,15 @@ MODEL_ID = config.MODEL_LITE_ID
api_key = config.API_KEY api_key = config.API_KEY
# --- Modèles pour la Requête 1 --- # --- Modèles pour la Requête 1 ---
class QuestionOnlyItem(BaseModel): class StatementContent(BaseModel):
@field_validator("question_content", "solution_content", "context_content",
"rubric_content", "content", check_fields=False)
@classmethod
def normalize_newlines(cls, value: str) -> str:
return normalize_literal_newlines(value)
class QuestionOnlyItem(StatementContent):
label: str = Field(description="Label unique de la question (par exemple '1.a' ou 'Exercice 1').") label: str = Field(description="Label unique de la question (par exemple '1.a' ou 'Exercice 1').")
question_content: str = Field(description="Texte source de la question, extrait exactement du fichier d’énoncé, SANS le label lui-même.") question_content: str = Field(description="Texte source de la question, extrait exactement du fichier d’énoncé, SANS le label lui-même.")
@@ -51,7 +59,7 @@ class ExamQuestions(BaseModel):
questions: list[QuestionOnlyItem] questions: list[QuestionOnlyItem]
# --- Modèles pour la Requête 2 --- # --- Modèles pour la Requête 2 ---
class SolutionOnlyItem(BaseModel): class SolutionOnlyItem(StatementContent):
label: str = Field(description="Label exact de la question fourni en entrée, à conserver sans traduction.") label: str = Field(description="Label exact de la question fourni en entrée, à conserver sans traduction.")
solution_content: str = Field(description="Texte source de la solution, extrait exactement du fichier de correction.") solution_content: str = Field(description="Texte source de la solution, extrait exactement du fichier de correction.")
@@ -59,7 +67,7 @@ class ExamSolutions(BaseModel):
solutions: list[SolutionOnlyItem] solutions: list[SolutionOnlyItem]
# --- Modèles pour la Requête 3 --- # --- Modèles pour la Requête 3 ---
class ExtractedContext(BaseModel): class ExtractedContext(StatementContent):
target_question_label: str = Field(description="Label exact de la PREMIÈRE question située immédiatement APRÈS cette information dans l’énoncé.") target_question_label: str = Field(description="Label exact de la PREMIÈRE question située immédiatement APRÈS cette information dans l’énoncé.")
last_question_label: str = Field(description="Label exact de la DERNIÈRE question qui utilise cette information.") last_question_label: str = Field(description="Label exact de la DERNIÈRE question qui utilise cette information.")
context_content: str = Field(description="Texte source des définitions, notations ou hypothèses, extrait de l’énoncé.") context_content: str = Field(description="Texte source des définitions, notations ou hypothèses, extrait de l’énoncé.")
@@ -68,7 +76,7 @@ class ExamContext(BaseModel):
contexts: list[ExtractedContext] contexts: list[ExtractedContext]
# --- Modèles pour la Requête 4 (Barèmes) --- # --- Modèles pour la Requête 4 (Barèmes) ---
class RubricItem(BaseModel): class RubricItem(StatementContent):
label: str = Field(description="Label exact de la question, à conserver sans traduction.") label: str = Field(description="Label exact de la question, à conserver sans traduction.")
rubric_content: str = Field(description="Barème détaillé sur 4 points, en français, avec formules LaTeX. Échapper chaque antislash LaTeX conformément à la syntaxe JSON.") rubric_content: str = Field(description="Barème détaillé sur 4 points, en français, avec formules LaTeX. Échapper chaque antislash LaTeX conformément à la syntaxe JSON.")
@@ -110,6 +118,8 @@ Exemple d’échappement dans un barème (adapte les clés au schéma demandé)
Ne confonds pas les commandes LaTeX avec les échappements JSON : Ne confonds pas les commandes LaTeX avec les échappements JSON :
\\frac, \\rightarrow, \\not, \\theta et \\begin sont des commandes LaTeX ; \\frac, \\rightarrow, \\not, \\theta et \\begin sont des commandes LaTeX ;
\n est réservé à un véritable saut de ligne du texte. \n est réservé à un véritable saut de ligne du texte.
N'écris jamais \\n pour un saut de ligne : après décodage JSON, le texte
doit contenir un véritable saut de ligne, pas une commande LaTeX \n.
""" """
@@ -124,12 +134,12 @@ def validate_rubric_content(content: str) -> str:
return content return content
# --- Modèle fusionné (pour le reste du script) --- # --- Modèle fusionné (pour le reste du script) ---
class QuestionItem(BaseModel): class QuestionItem(StatementContent):
label: str label: str
question_content: str question_content: str
solution_content: str solution_content: str
class ContextItem(BaseModel): class ContextItem(StatementContent):
target_question_label: str target_question_label: str
last_question_label: str last_question_label: str
content: str # Juste une string encapsulée pour le différencier facilement content: str # Juste une string encapsulée pour le différencier facilement
@@ -331,14 +341,18 @@ def process_exam(
cache_c_file = cache_dir / "gemini_context.json" cache_c_file = cache_dir / "gemini_context.json"
# 1. Resolve files # 1. Resolve files
pdf_path = folder / "enonce.pdf" pdf_path = workspace.statement_pdf_file
enonce_path = find_file(folder, "enonce") enonce_path = find_file(folder, "enonce")
if enonce_path is None:
enonce_path = workspace.statement_source_file
correction_path = find_file(folder, "correction") correction_path = find_file(folder, "correction")
if correction_path is None:
correction_path = workspace.correction_source_file
missing = [] missing = []
if not pdf_path.is_file(): missing.append("enonce.pdf") if not pdf_path.is_file(): missing.append("enonce.pdf")
if not enonce_path: missing.append("enonce.org or enonce.tex") if not enonce_path.is_file(): missing.append("enonce.tex")
if not correction_path: missing.append("correction.org or correction.tex") if not correction_path.is_file(): missing.append("correction.tex")
if missing: if missing:
raise CliError( raise CliError(
@@ -911,7 +925,7 @@ def process_exam(
with open(text_dir / safe_group_filename, "w", encoding="utf-8") as f_text: with open(text_dir / safe_group_filename, "w", encoding="utf-8") as f_text:
f_text.write("\n".join(text_content_lines)) f_text.write("\n".join(text_content_lines))
print(f"Success! Processed {len(grouped_extraction.groups)} groups.") print(f"Wrote {len(grouped_extraction.groups)} groups; checking PDF compilation...")
# ========================================== # ==========================================
# PDF COMPILATION (4 Threads) # PDF COMPILATION (4 Threads)
# ========================================== # ==========================================
@@ -922,7 +936,7 @@ def process_exam(
try: try:
content = tex_file.read_text(encoding="utf-8") content = tex_file.read_text(encoding="utf-8")
pdf_path = tex_file.with_suffix(".pdf") pdf_path = tex_file.with_suffix(".pdf")
compile_to_pdf(content, pdf_path) compile_to_pdf(content, pdf_path, strict=True)
except Exception as e: # noqa: BLE001 - compiler worker boundary except Exception as e: # noqa: BLE001 - compiler worker boundary
return f"Error compiling {tex_file.name}: {e}" return f"Error compiling {tex_file.name}: {e}"
return None return None
+3 -9
View File
@@ -476,17 +476,11 @@ class ImageViewer:
def on_open_interro(self, event): def on_open_interro(self, event):
if self.is_viewing and self.current_json_path: if self.is_viewing and self.current_json_path:
# Check local directory first # Check local directory first
local_accent = self.base_dir / "énoncé.pdf" pdf_path = EvaluationWorkspace(self.base_dir).statement_pdf_file
local_plain = self.base_dir / "enonce.pdf" if not pdf_path.is_file():
if local_accent.exists():
pdf_path = str(local_accent)
elif local_plain.exists():
pdf_path = local_plain
else:
messagebox.showerror( messagebox.showerror(
"PDF not found", "PDF not found",
f"Neither {local_accent.name} nor {local_plain.name} exists in {self.base_dir}.", f"No statement PDF exists in {self.base_dir}.",
) )
return return
+12 -6
View File
@@ -142,7 +142,7 @@ import subprocess
from .configuration 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, *, strict=False):
"""Wraps text in standalone header/footer templates and compiles it to PDF.""" """Wraps text in standalone header/footer templates and compiles it to PDF."""
latex_template = f"{LATEX_BEFORE}{text}{LATEX_AFTER}" latex_template = f"{LATEX_BEFORE}{text}{LATEX_AFTER}"
@@ -166,6 +166,7 @@ def compile_to_pdf(text, output_pdf_path):
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
text=True, text=True,
encoding="utf-8", errors="replace",
check=False check=False
) )
if "minted" in text: if "minted" in text:
@@ -175,6 +176,7 @@ def compile_to_pdf(text, output_pdf_path):
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
text=True, text=True,
encoding="utf-8", errors="replace",
check=False) check=False)
generated_pdf = os.path.join(temp_dir, pdf_filename) generated_pdf = os.path.join(temp_dir, pdf_filename)
@@ -183,17 +185,21 @@ def compile_to_pdf(text, output_pdf_path):
shutil.move(generated_pdf, output_pdf_path) shutil.move(generated_pdf, output_pdf_path)
if result.returncode != 0: if result.returncode != 0:
error_lines = [ lines = result.stdout.splitlines()
line.strip() for line in result.stdout.splitlines() first = next((i for i, line in enumerate(lines) if line.lstrip().startswith("!")), None)
if line.lstrip().startswith("!") detail = "\n" + "\n".join(lines[first:first + 5]) if first is not None else ""
]
detail = f": {error_lines[0]}" if error_lines else ""
pdf_status = "a PDF was generated" if pdf_generated else "no PDF was generated" pdf_status = "a PDF was generated" if pdf_generated else "no PDF was generated"
print( print(
f"Warning: LaTeX compilation reported errors for {output_pdf_path} " f"Warning: LaTeX compilation reported errors for {output_pdf_path} "
f"(exit code {result.returncode}; {pdf_status}){detail}" f"(exit code {result.returncode}; {pdf_status}){detail}"
) )
if strict:
raise RuntimeError(f"LaTeX failed for {output_pdf_path}{detail}")
elif not pdf_generated: elif not pdf_generated:
print(f"Warning: LaTeX compilation produced no PDF for {output_pdf_path}") print(f"Warning: LaTeX compilation produced no PDF for {output_pdf_path}")
if strict:
raise RuntimeError(f"No PDF generated for {output_pdf_path}")
except Exception as e: except Exception as e:
if strict:
raise
print(f"Compilation error for {output_pdf_path}: {e}") print(f"Compilation error for {output_pdf_path}: {e}")
+40 -3
View File
@@ -63,12 +63,49 @@ class EvaluationWorkspace:
".copienator", ".copienator",
".copienator-gui.json", ".copienator-gui.json",
) )
return path.is_dir() and any((path / marker).exists() for marker in markers) if not path.is_dir():
return False
aliases = (f"{path.name}.pdf", f"{path.name}.tex", f"{path.name}c.tex")
return any((path / marker).exists() for marker in (*markers, *aliases))
@property @property
def name(self) -> str: def name(self) -> str:
return self.root.name return self.root.name
def input_candidates(self, canonical_name: str) -> tuple[Path, ...]:
"""Return preferred paths for one of the three statement inputs."""
aliases = {
"enonce.pdf": ("enonce.pdf", "énoncé.pdf", f"{self.name}.pdf"),
"enonce.tex": ("enonce.tex", f"{self.name}.tex"),
"correction.tex": ("correction.tex", f"{self.name}c.tex"),
}
names = aliases.get(canonical_name, (canonical_name,))
return tuple(self.root / name for name in names)
def resolve(self, relative_path: str) -> Path:
"""Resolve a logical workspace path, including statement aliases."""
candidates = self.input_candidates(relative_path)
return next((path for path in candidates if path.exists()), candidates[0])
def resolve_file(self, relative_path: str) -> Path:
candidates = self.input_candidates(relative_path)
return next((path for path in candidates if path.is_file()), candidates[0])
@property
def statement_pdf_file(self) -> Path:
return self.resolve_file("enonce.pdf")
@property
def statement_source_file(self) -> Path:
return self.resolve_file("enonce.tex")
@property
def correction_source_file(self) -> Path:
return self.resolve_file("correction.tex")
def is_statement_pdf(self, path: Path) -> bool:
return path in self.input_candidates("enonce.pdf")
@property @property
def metadata_dir(self) -> Path: def metadata_dir(self) -> Path:
return self.root / ".copienator" return self.root / ".copienator"
@@ -220,7 +257,7 @@ class EvaluationWorkspace:
missing = [ missing = [
relative_path relative_path
for relative_path in relative_paths for relative_path in relative_paths
if not (self.root / relative_path).exists() if not self.resolve(relative_path).exists()
] ]
if missing: if missing:
raise WorkspaceValidationError(self.root, missing) raise WorkspaceValidationError(self.root, missing)
@@ -229,7 +266,7 @@ class EvaluationWorkspace:
missing = [ missing = [
relative_path relative_path
for relative_path in relative_paths for relative_path in relative_paths
if not (self.root / relative_path).is_file() if not self.resolve_file(relative_path).is_file()
] ]
if missing: if missing:
raise WorkspaceValidationError(self.root, missing) raise WorkspaceValidationError(self.root, missing)
+5 -3
View File
@@ -173,7 +173,7 @@ def copy_pdf_paths(evaluation: Path) -> list[Path]:
( (
path path
for path in location.glob("*.pdf") for path in location.glob("*.pdf")
if path.name.casefold() not in {"enonce.pdf", "énoncé.pdf"} if not EvaluationWorkspace(evaluation).is_statement_pdf(path)
), ),
key=lambda path: path.name.casefold(), key=lambda path: path.name.casefold(),
) )
@@ -578,7 +578,8 @@ class CopienatorApp(tk.Tk):
if self.state_store.step(step.id).get("status") == "running": if self.state_store.step(step.id).get("status") == "running":
self.state_store.update_step(step.id, status="interrupted") self.state_store.update_step(step.id, status="interrupted")
self._populate_tree() self._populate_tree()
missing = [name for name in ("enonce.pdf", "enonce.tex", "correction.tex") if not (evaluation / name).exists()] workspace = EvaluationWorkspace(evaluation)
missing = [name for name in ("enonce.pdf", "enonce.tex", "correction.tex") if not workspace.resolve(name).exists()]
names_found = (evaluation / "names").exists() or (self.repository / "names").exists() names_found = (evaluation / "names").exists() or (self.repository / "names").exists()
details = [] details = []
if missing: if missing:
@@ -678,7 +679,8 @@ class CopienatorApp(tk.Tk):
if any(char in pattern for char in "*?["): if any(char in pattern for char in "*?["):
exists = next(evaluation.glob(pattern), None) is not None exists = next(evaluation.glob(pattern), None) is not None
else: else:
path = EvaluationWorkspace(evaluation).annotation_dir("refaire") if pattern == "BRnot" else evaluation / pattern workspace = EvaluationWorkspace(evaluation)
path = workspace.annotation_dir("refaire") if pattern == "BRnot" else workspace.resolve(pattern)
exists = path.exists() exists = path.exists()
if not exists: if not exists:
missing.append(pattern) missing.append(pattern)
+2 -1
View File
@@ -126,7 +126,8 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"inputs", "inputs",
"Préparation", "Préparation",
"Vérifier les fichiers d’entrée", "Vérifier les fichiers d’entrée",
"Le dossier doit contenir enonce.pdf, enonce.tex et correction.tex. " "Le dossier doit contenir enonce.pdf, enonce.tex et correction.tex, ou, "
"s’il s’appelle A, les fichiers A.pdf, A.tex et Ac.tex. "
"Le fichier names doit être présent dans l’évaluation ou à la racine du projet.", "Le fichier names doit être présent dans l’évaluation ou à la racine du projet.",
(manual("check"),), (manual("check"),),
requires=("enonce.pdf", "enonce.tex", "correction.tex"), requires=("enonce.pdf", "enonce.tex", "correction.tex"),
+2 -1
View File
@@ -28,7 +28,8 @@ FINAL_SCORE_HISTOGRAM_PATH = Path("histogramme.pdf")
FINAL_SCORE_FONT_PATH = None FINAL_SCORE_FONT_PATH = None
# Modèle pour des choses très légères # Modèle pour des choses très légères
MODEL_LITE_ID = "gemini-3.5-flash-lite" # MODEL_LITE_ID = "gemini-3.5-flash-lite"
MODEL_LITE_ID = "gemini-3.8-flash"
# Modèle pour identifier visuellement les labels # Modèle pour identifier visuellement les labels
# MODEL_FOR_LABEL_ID = "gemini-3.5-flash-lite" # 3.5 flash lite marche moyennement, il insiste pour mettre les labels dans l'ordre, sans considérer ce qu'il y a écrit # MODEL_FOR_LABEL_ID = "gemini-3.5-flash-lite" # 3.5 flash lite marche moyennement, il insiste pour mettre les labels dans l'ordre, sans considérer ce qu'il y a écrit
+2 -1
View File
@@ -8,13 +8,14 @@ fi
# Go to the directory # Go to the directory
cd "$1" || exit cd "$1" || exit
folder_name="$(basename "$PWD")"
count=1 count=1
for file in *.pdf; do for file in *.pdf; do
# Handle case where no pdfs exist # Handle case where no pdfs exist
[ -e "$file" ] || continue [ -e "$file" ] || continue
if [ "$file" = "enonce.pdf" ]; then if [ "$file" = "enonce.pdf" ] || [ "$file" = "énoncé.pdf" ] || [ "$file" = "$folder_name.pdf" ]; then
echo "Skipping: $file" echo "Skipping: $file"
continue continue
fi fi
+2 -1
View File
@@ -8,12 +8,13 @@ fi
# Try to change into the directory, exit on failure # Try to change into the directory, exit on failure
cd "$1" || { echo "Error: Cannot access directory '$1'"; exit 1; } cd "$1" || { echo "Error: Cannot access directory '$1'"; exit 1; }
folder_name="$(basename "$PWD")"
# Enable nullglob: if no pdfs exist, the loop won't run once with "*.pdf" # Enable nullglob: if no pdfs exist, the loop won't run once with "*.pdf"
shopt -s nullglob shopt -s nullglob
for file in *.pdf; do for file in *.pdf; do
if [ "$file" = "enonce.pdf" ]; then if [ "$file" = "enonce.pdf" ] || [ "$file" = "énoncé.pdf" ] || [ "$file" = "$folder_name.pdf" ]; then
echo "Skipping: $file" echo "Skipping: $file"
continue continue
fi fi
+35 -6
View File
@@ -108,6 +108,33 @@ class WorkspaceTests(unittest.TestCase):
workspace = EvaluationWorkspace.discover(artifact) workspace = EvaluationWorkspace.discover(artifact)
self.assertEqual(workspace.root, root) self.assertEqual(workspace.root, root)
def test_folder_named_statement_inputs_are_logical_canonical_files(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory) / "A"
root.mkdir()
for name in ("A.pdf", "A.tex", "Ac.tex"):
(root / name).touch()
workspace = EvaluationWorkspace.discover(root)
workspace.require_files("enonce.pdf", "enonce.tex", "correction.tex")
self.assertEqual(workspace.statement_pdf_file, root / "A.pdf")
self.assertEqual(workspace.statement_source_file, root / "A.tex")
self.assertEqual(workspace.correction_source_file, root / "Ac.tex")
def test_canonical_statement_inputs_take_precedence_over_folder_aliases(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory) / "A"
root.mkdir()
for name in ("enonce.pdf", "A.pdf", "enonce.tex", "A.tex",
"correction.tex", "Ac.tex"):
(root / name).touch()
workspace = EvaluationWorkspace(root)
self.assertEqual(workspace.statement_pdf_file, root / "enonce.pdf")
self.assertEqual(workspace.statement_source_file, root / "enonce.tex")
self.assertEqual(workspace.correction_source_file, root / "correction.tex")
def test_discover_and_require_report_clear_errors(self) -> None: def test_discover_and_require_report_clear_errors(self) -> None:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
root = Path(directory) root = Path(directory)
@@ -2574,8 +2601,9 @@ class WorkflowTests(unittest.TestCase):
class CrossPlatformFileTests(unittest.TestCase): class CrossPlatformFileTests(unittest.TestCase):
def test_rotate_all_skips_statement(self) -> None: def test_rotate_all_skips_statement(self) -> None:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
root = Path(directory) root = Path(directory) / "A"
for filename in ("Copie.pdf", "enonce.pdf"): root.mkdir()
for filename in ("Copie.pdf", "A.pdf"):
writer = PdfWriter() writer = PdfWriter()
writer.add_blank_page(width=100, height=200) writer.add_blank_page(width=100, height=200)
with (root / filename).open("wb") as output: with (root / filename).open("wb") as output:
@@ -2583,22 +2611,23 @@ class CrossPlatformFileTests(unittest.TestCase):
self.assertEqual(rotate_all(root), 1) self.assertEqual(rotate_all(root), 1)
self.assertEqual(PdfReader(root / "Copie.pdf").pages[0].rotation, 180) self.assertEqual(PdfReader(root / "Copie.pdf").pages[0].rotation, 180)
self.assertEqual(PdfReader(root / "enonce.pdf").pages[0].rotation, 0) self.assertEqual(PdfReader(root / "A.pdf").pages[0].rotation, 0)
def test_rename_all_is_collision_safe(self) -> None: def test_rename_all_is_collision_safe(self) -> None:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
root = Path(directory) root = Path(directory) / "A"
root.mkdir()
(root / "z.pdf").write_bytes(b"z") (root / "z.pdf").write_bytes(b"z")
(root / "a.pdf").write_bytes(b"a") (root / "a.pdf").write_bytes(b"a")
(root / "Copie01.pdf").write_bytes(b"existing") (root / "Copie01.pdf").write_bytes(b"existing")
(root / "enonce.pdf").write_bytes(b"statement") (root / "A.pdf").write_bytes(b"statement")
rename_all(root) rename_all(root)
self.assertEqual((root / "Copie01.pdf").read_bytes(), b"a") self.assertEqual((root / "Copie01.pdf").read_bytes(), b"a")
self.assertEqual((root / "Copie02.pdf").read_bytes(), b"existing") self.assertEqual((root / "Copie02.pdf").read_bytes(), b"existing")
self.assertEqual((root / "Copie03.pdf").read_bytes(), b"z") self.assertEqual((root / "Copie03.pdf").read_bytes(), b"z")
self.assertEqual((root / "enonce.pdf").read_bytes(), b"statement") self.assertEqual((root / "A.pdf").read_bytes(), b"statement")
def test_link_falls_back_to_copy(self) -> None: def test_link_falls_back_to_copy(self) -> None:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
+10
View File
@@ -10,6 +10,16 @@ from copienator import utils
class CompileToPdfTests(unittest.TestCase): class CompileToPdfTests(unittest.TestCase):
def test_strict_compilation_propagates_failure_with_context(self):
result = subprocess.CompletedProcess(args=[], returncode=1,
stdout="! Undefined control sequence.\nl.13 broken command\n")
with tempfile.TemporaryDirectory() as directory, \
patch.object(utils.subprocess, "run", return_value=result) as run:
with self.assertRaisesRegex(RuntimeError, "LaTeX failed") as error:
utils.compile_to_pdf("broken", Path(directory) / "out.pdf", strict=True)
self.assertIn("l.13 broken command", str(error.exception))
self.assertEqual(run.call_args.kwargs["errors"], "replace")
def test_warns_when_latex_fails_even_if_partial_pdf_exists(self): def test_warns_when_latex_fails_even_if_partial_pdf_exists(self):
with tempfile.TemporaryDirectory() as temporary: with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary) / "result.pdf" output = Path(temporary) / "result.pdf"