Personal tex compilation fixes
This commit is contained in:
1 parent
ec6ac9580b
commit
705db1068f
8 files changed
+217
-21
No files matched your search
+18
@@ -26,6 +26,24 @@ pour faire corriger des copies par Gemini.
|
|||||||
Le dossier `Example` contient deux exemples de copies pour illustrer
|
Le dossier `Example` contient deux exemples de copies pour illustrer
|
||||||
le résultat final.
|
le résultat final.
|
||||||
|
|
||||||
|
** Reprendre une compilation LaTeX après l'analyse
|
||||||
|
|
||||||
|
Les PDF des questions et solutions reprennent automatiquement les packages et
|
||||||
|
macros de =enonce.tex= et du corrigé, avec le format recadré de Copienator.
|
||||||
|
La géométrie des pages du document original est exclue pour préserver le recadrage.
|
||||||
|
Les packages et fichiers locaux doivent rester accessibles à LaTeX.
|
||||||
|
|
||||||
|
Pour recompiler les fragments déjà extraits sans aucune requête Gemini :
|
||||||
|
|
||||||
|
#+BEGIN_SRC bash
|
||||||
|
python -m copienator statement DSI01 --compile-only
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
Dans l'interface, choisir « Recompiler les PDF sans Gemini » à l'étape
|
||||||
|
« Analyser l’énoncé ». Cette action conserve les sources, les groupes et les
|
||||||
|
barèmes. Elle reprend les échecs et recompile si les fragments ou leur préambule
|
||||||
|
ont changé. Une compilation en erreur ne remplace pas le PDF précédent.
|
||||||
|
|
||||||
** Avertissements
|
** Avertissements
|
||||||
|
|
||||||
- 90% du code du repertoire a été vibe codé, sans vérification autre
|
- 90% du code du repertoire a été vibe codé, sans vérification autre
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import hashlib
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
@@ -1016,24 +1017,35 @@ def write_exam_outputs(workspace: EvaluationWorkspace, grouped_extraction: Group
|
|||||||
save_missing(path, '\n\n'.join(contents))
|
save_missing(path, '\n\n'.join(contents))
|
||||||
|
|
||||||
print(f"Wrote {len(grouped_extraction.groups)} groups; checking PDF compilation...")
|
print(f"Wrote {len(grouped_extraction.groups)} groups; checking PDF compilation...")
|
||||||
# ==========================================
|
compilation_status = compile_existing_pdfs(workspace)
|
||||||
# PDF COMPILATION (4 Threads)
|
return ExitCode.PARTIAL if processing_errors or compilation_status != ExitCode.SUCCESS else ExitCode.SUCCESS
|
||||||
# ==========================================
|
|
||||||
all_tex_files = list(text2_dir.glob("*.tex")) + list(sol2_dir.glob("*.tex"))
|
|
||||||
|
def compile_existing_pdfs(workspace: EvaluationWorkspace) -> ExitCode:
|
||||||
|
"""Compile existing fragments without reading checkpoints or calling Gemini."""
|
||||||
|
folder = workspace.root
|
||||||
|
all_tex_files = list(workspace.rendered_text_dir.glob("*.tex")) + list(workspace.rendered_solution_dir.glob("*.tex"))
|
||||||
|
|
||||||
def compile_worker(tex_file: Path) -> str | None:
|
def compile_worker(tex_file: Path) -> str | None:
|
||||||
"""Helper to read content and call the utility function."""
|
"""Helper to read content and call the utility function."""
|
||||||
try:
|
try:
|
||||||
pdf_path = tex_file.with_suffix('.pdf')
|
pdf_path = tex_file.with_suffix('.pdf')
|
||||||
status_path = folder / 'Cache' / 'statement_pdf_status' / tex_file.parent.name / (tex_file.stem + '.json')
|
status_path = folder / 'Cache' / 'statement_pdf_status' / tex_file.parent.name / (tex_file.stem + '.json')
|
||||||
failed_before = status_path.exists() and not json.loads(status_path.read_text()).get('complete')
|
source_path = (workspace.correction_source_file if tex_file.parent.name == 'Sol2'
|
||||||
|
else workspace.statement_source_file)
|
||||||
|
if not source_path.is_file():
|
||||||
|
source_path = None
|
||||||
|
fingerprint = hashlib.sha256((utils.fragment_latex_header(source_path)
|
||||||
|
+ utils.LATEX_AFTER + str(source_path)).encode()).hexdigest()
|
||||||
|
status = json.loads(status_path.read_text()) if status_path.exists() else {}
|
||||||
|
failed_before = not status.get('complete') or status.get('preamble_hash') != fingerprint
|
||||||
if (pdf_path.is_file() and pdf_path.stat().st_size > 0
|
if (pdf_path.is_file() and pdf_path.stat().st_size > 0
|
||||||
and pdf_path.stat().st_mtime_ns >= tex_file.stat().st_mtime_ns and not failed_before):
|
and pdf_path.stat().st_mtime_ns >= tex_file.stat().st_mtime_ns and not failed_before):
|
||||||
return None
|
return None
|
||||||
atomic_write_json(status_path, {'complete': False})
|
atomic_write_json(status_path, {'complete': False, 'preamble_hash': fingerprint})
|
||||||
content = tex_file.read_text(encoding='utf-8')
|
content = tex_file.read_text(encoding='utf-8')
|
||||||
compile_to_pdf(content, pdf_path, strict=True)
|
compile_to_pdf(content, pdf_path, strict=True, source_path=source_path)
|
||||||
atomic_write_json(status_path, {'complete': True})
|
atomic_write_json(status_path, {'complete': True, 'preamble_hash': fingerprint})
|
||||||
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
|
||||||
@@ -1045,8 +1057,7 @@ def write_exam_outputs(workspace: EvaluationWorkspace, grouped_extraction: Group
|
|||||||
]
|
]
|
||||||
for error in compile_errors:
|
for error in compile_errors:
|
||||||
report_error(folder, 'PDF compilation', error)
|
report_error(folder, 'PDF compilation', error)
|
||||||
processing_errors.extend(compile_errors)
|
return ExitCode.PARTIAL if compile_errors else ExitCode.SUCCESS
|
||||||
return ExitCode.PARTIAL if processing_errors else ExitCode.SUCCESS
|
|
||||||
|
|
||||||
|
|
||||||
def build_parser() -> argparse.ArgumentParser:
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
@@ -1056,6 +1067,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
help="Regroup existing questions with Gemini; update only label_groups")
|
help="Regroup existing questions with Gemini; update only label_groups")
|
||||||
actions.add_argument("--persp-only", action="store_true",
|
actions.add_argument("--persp-only", action="store_true",
|
||||||
help="Generate only missing Persp rubrics for existing groups")
|
help="Generate only missing Persp rubrics for existing groups")
|
||||||
|
actions.add_argument("--compile-only", action="store_true",
|
||||||
|
help="Retry existing fragment PDFs using source preambles; no Gemini requests")
|
||||||
parser.add_argument('--delete-persp', action='store_true',
|
parser.add_argument('--delete-persp', action='store_true',
|
||||||
help='Delete current Persp before generation (requires --persp-only)')
|
help='Delete current Persp before generation (requires --persp-only)')
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -1071,6 +1084,10 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
def run(args):
|
def run(args):
|
||||||
if args.delete_persp and not args.persp_only:
|
if args.delete_persp and not args.persp_only:
|
||||||
raise CliError('--delete-persp requires --persp-only', ExitCode.INVALID_ARGUMENTS)
|
raise CliError('--delete-persp requires --persp-only', ExitCode.INVALID_ARGUMENTS)
|
||||||
|
if args.restart and (args.compile_only or args.groups_only or args.persp_only):
|
||||||
|
raise CliError('--restart cannot be combined with a partial action', ExitCode.INVALID_ARGUMENTS)
|
||||||
|
if args.compile_only:
|
||||||
|
return compile_existing_pdfs(workspace_from_args(args))
|
||||||
if args.groups_only or args.persp_only:
|
if args.groups_only or args.persp_only:
|
||||||
kwargs = {'delete_persp': True} if args.delete_persp else {}
|
kwargs = {'delete_persp': True} if args.delete_persp else {}
|
||||||
return refine_existing(workspace_from_args(args),
|
return refine_existing(workspace_from_args(args),
|
||||||
|
|||||||
+39
-3
@@ -149,9 +149,43 @@ def report_latex_warning(output_pdf_path) -> None:
|
|||||||
print(f"{LATEX_WARNING_MARKER}{Path(output_pdf_path).resolve()}")
|
print(f"{LATEX_WARNING_MARKER}{Path(output_pdf_path).resolve()}")
|
||||||
|
|
||||||
|
|
||||||
def compile_to_pdf(text, output_pdf_path, *, strict=False):
|
def fragment_latex_header(source_path=None):
|
||||||
|
"""Keep source packages/macros while using the configured fragment layout."""
|
||||||
|
if source_path is None:
|
||||||
|
return LATEX_BEFORE
|
||||||
|
source = Path(source_path).read_text(encoding="utf-8")
|
||||||
|
# Ignore commented document commands when locating the preamble.
|
||||||
|
source = re.sub(r"(?m)(?<!\\)%[^\n]*", "", source)
|
||||||
|
begin = re.search(r"\\begin\s*\{document\}", source)
|
||||||
|
document_class = r"\\documentclass(?:\s*\[[^\]]*\])?\s*\{[^}]+\}"
|
||||||
|
configured_class = re.search(document_class, LATEX_BEFORE)
|
||||||
|
configured_begin = re.search(r"\\begin\s*\{document\}", LATEX_BEFORE)
|
||||||
|
if begin is None or configured_class is None or configured_begin is None:
|
||||||
|
return LATEX_BEFORE
|
||||||
|
preamble = source[:begin.start()]
|
||||||
|
if not re.search(document_class, preamble):
|
||||||
|
return LATEX_BEFORE
|
||||||
|
preamble = re.sub(document_class, lambda _: configured_class.group(), preamble, count=1)
|
||||||
|
# geometry overrides standalone's crop coordinates and can produce blank or
|
||||||
|
# clipped PDFs even when LaTeX succeeds. Keep other source packages intact.
|
||||||
|
def without_geometry(match):
|
||||||
|
packages = [name.strip() for name in match.group(2).split(',')
|
||||||
|
if name.strip() != 'geometry']
|
||||||
|
return (r'\usepackage' + (match.group(1) or '') + '{' + ','.join(packages) + '}'
|
||||||
|
if packages else '')
|
||||||
|
preamble = re.sub(r'\\usepackage\s*(\[[^\]]*\])?\s*\{([^}]+)\}',
|
||||||
|
without_geometry, preamble)
|
||||||
|
preamble = re.sub(r'\\(?:geometry|newgeometry)\s*\{[^}]*\}', '', preamble)
|
||||||
|
return preamble + LATEX_BEFORE[configured_begin.start():]
|
||||||
|
|
||||||
|
|
||||||
|
def compile_to_pdf(text, output_pdf_path, *, strict=False, source_path=None):
|
||||||
"""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"{fragment_latex_header(source_path)}{text}{LATEX_AFTER}"
|
||||||
|
env = os.environ.copy()
|
||||||
|
if source_path is not None:
|
||||||
|
source_dir = str(Path(source_path).resolve().parent)
|
||||||
|
env['TEXINPUTS'] = source_dir + os.pathsep + env.get('TEXINPUTS', '')
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
tex_filename = 'text.tex'
|
tex_filename = 'text.tex'
|
||||||
@@ -170,6 +204,7 @@ def compile_to_pdf(text, output_pdf_path, *, strict=False):
|
|||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
['pdflatex', '-interaction=nonstopmode', tex_filename],
|
['pdflatex', '-interaction=nonstopmode', tex_filename],
|
||||||
cwd=temp_dir,
|
cwd=temp_dir,
|
||||||
|
env=env,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
text=True,
|
text=True,
|
||||||
@@ -180,6 +215,7 @@ def compile_to_pdf(text, output_pdf_path, *, strict=False):
|
|||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
['pdflatex', '-interaction=nonstopmode', tex_filename],
|
['pdflatex', '-interaction=nonstopmode', tex_filename],
|
||||||
cwd=temp_dir,
|
cwd=temp_dir,
|
||||||
|
env=env,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
text=True,
|
text=True,
|
||||||
@@ -188,7 +224,7 @@ def compile_to_pdf(text, output_pdf_path, *, strict=False):
|
|||||||
|
|
||||||
generated_pdf = os.path.join(temp_dir, pdf_filename)
|
generated_pdf = os.path.join(temp_dir, pdf_filename)
|
||||||
pdf_generated = os.path.exists(generated_pdf)
|
pdf_generated = os.path.exists(generated_pdf)
|
||||||
if pdf_generated:
|
if pdf_generated and (not strict or result.returncode == 0):
|
||||||
shutil.move(generated_pdf, output_pdf_path)
|
shutil.move(generated_pdf, output_pdf_path)
|
||||||
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
|
|||||||
+12
-5
@@ -65,8 +65,15 @@ class EvaluationWorkspace:
|
|||||||
)
|
)
|
||||||
if not path.is_dir():
|
if not path.is_dir():
|
||||||
return False
|
return False
|
||||||
aliases = (f"{path.name}.pdf", f"{path.name}.tex", f"{path.name}c.tex")
|
workspace = EvaluationWorkspace(path)
|
||||||
return any((path / marker).exists() for marker in (*markers, *aliases))
|
inputs = (
|
||||||
|
candidate
|
||||||
|
for name in ("enonce.pdf", "enonce.tex", "correction.tex")
|
||||||
|
for candidate in workspace.input_candidates(name)
|
||||||
|
)
|
||||||
|
return any((path / marker).exists() for marker in markers) or any(
|
||||||
|
candidate.exists() for candidate in inputs
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
@@ -75,9 +82,9 @@ class EvaluationWorkspace:
|
|||||||
def input_candidates(self, canonical_name: str) -> tuple[Path, ...]:
|
def input_candidates(self, canonical_name: str) -> tuple[Path, ...]:
|
||||||
"""Return preferred paths for one of the three statement inputs."""
|
"""Return preferred paths for one of the three statement inputs."""
|
||||||
aliases = {
|
aliases = {
|
||||||
"enonce.pdf": ("enonce.pdf", "énoncé.pdf", f"{self.name}.pdf"),
|
"enonce.pdf": ("enonce.pdf", "énonce.pdf", "énoncé.pdf", f"{self.name}.pdf"),
|
||||||
"enonce.tex": ("enonce.tex", f"{self.name}.tex"),
|
"enonce.tex": ("enonce.tex", "énonce.tex", "énoncé.tex", f"{self.name}.tex"),
|
||||||
"correction.tex": ("correction.tex", f"{self.name}c.tex"),
|
"correction.tex": ("correction.tex", "corrige.tex", "corrigé.tex", f"{self.name}c.tex"),
|
||||||
}
|
}
|
||||||
names = aliases.get(canonical_name, (canonical_name,))
|
names = aliases.get(canonical_name, (canonical_name,))
|
||||||
return tuple(self.root / name for name in names)
|
return tuple(self.root / name for name in names)
|
||||||
|
|||||||
@@ -143,6 +143,8 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
"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, ou, "
|
"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. "
|
"s’il s’appelle A, les fichiers A.pdf, A.tex et Ac.tex. "
|
||||||
|
"Les noms énonce.pdf/tex, énoncé.pdf/tex et corrige.tex/corrigé.tex "
|
||||||
|
"sont également acceptés. "
|
||||||
"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"),
|
||||||
@@ -156,7 +158,9 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
"Génère aussi des barèmes détaillés de chaque question. ",
|
"Génère aussi des barèmes détaillés de chaque question. ",
|
||||||
((python("personal", "Énoncés et solutions personnels (SHEETINFO)", "statement-personal"),)
|
((python("personal", "Énoncés et solutions personnels (SHEETINFO)", "statement-personal"),)
|
||||||
if show_personal_steps else ())
|
if show_personal_steps else ())
|
||||||
+ (python("gemini", "Analyse avec Gemini", "statement"),),
|
+ (python("gemini", "Analyse avec Gemini", "statement"),
|
||||||
|
python("compile", "Recompiler les PDF sans Gemini", "statement",
|
||||||
|
fixed_args=("--compile-only",))),
|
||||||
arguments=(
|
arguments=(
|
||||||
arg_target("le dossier de l’évaluation"),
|
arg_target("le dossier de l’évaluation"),
|
||||||
ArgumentSpec(
|
ArgumentSpec(
|
||||||
|
|||||||
+33
-2
@@ -127,8 +127,9 @@ class WorkspaceTests(unittest.TestCase):
|
|||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
root = Path(directory) / "A"
|
root = Path(directory) / "A"
|
||||||
root.mkdir()
|
root.mkdir()
|
||||||
for name in ("enonce.pdf", "A.pdf", "enonce.tex", "A.tex",
|
for name in ("enonce.pdf", "énonce.pdf", "énoncé.pdf", "A.pdf",
|
||||||
"correction.tex", "Ac.tex"):
|
"enonce.tex", "énonce.tex", "énoncé.tex", "A.tex",
|
||||||
|
"correction.tex", "corrige.tex", "corrigé.tex", "Ac.tex"):
|
||||||
(root / name).touch()
|
(root / name).touch()
|
||||||
|
|
||||||
workspace = EvaluationWorkspace(root)
|
workspace = EvaluationWorkspace(root)
|
||||||
@@ -136,6 +137,30 @@ class WorkspaceTests(unittest.TestCase):
|
|||||||
self.assertEqual(workspace.statement_source_file, root / "enonce.tex")
|
self.assertEqual(workspace.statement_source_file, root / "enonce.tex")
|
||||||
self.assertEqual(workspace.correction_source_file, root / "correction.tex")
|
self.assertEqual(workspace.correction_source_file, root / "correction.tex")
|
||||||
|
|
||||||
|
def test_alternative_statement_inputs_are_detected_and_validated(self) -> None:
|
||||||
|
for statement in ("énonce", "énoncé"):
|
||||||
|
for correction in ("corrige", "corrigé"):
|
||||||
|
with self.subTest(statement=statement, correction=correction), tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory) / "Exam"
|
||||||
|
root.mkdir()
|
||||||
|
for name in (f"{statement}.pdf", f"{statement}.tex", f"{correction}.tex"):
|
||||||
|
(root / name).touch()
|
||||||
|
workspace = EvaluationWorkspace.discover(root)
|
||||||
|
workspace.require_files("enonce.pdf", "enonce.tex", "correction.tex")
|
||||||
|
self.assertEqual(workspace.statement_pdf_file, root / f"{statement}.pdf")
|
||||||
|
self.assertEqual(workspace.statement_source_file, root / f"{statement}.tex")
|
||||||
|
self.assertEqual(workspace.correction_source_file, root / f"{correction}.tex")
|
||||||
|
self.assertTrue(workspace.is_statement_pdf(root / f"{statement}.pdf"))
|
||||||
|
self.assertEqual(workspace.resolve("correction.tex"), root / f"{correction}.tex")
|
||||||
|
|
||||||
|
def test_discovery_recognizes_each_alternative_input_on_its_own(self) -> None:
|
||||||
|
for name in ("énonce.pdf", "énonce.tex", "énoncé.pdf", "énoncé.tex", "corrige.tex", "corrigé.tex"):
|
||||||
|
with self.subTest(name=name), tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory) / "Exam"
|
||||||
|
root.mkdir()
|
||||||
|
(root / name).touch()
|
||||||
|
self.assertEqual(EvaluationWorkspace.discover(root).root, root)
|
||||||
|
|
||||||
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)
|
||||||
@@ -2259,6 +2284,12 @@ class StandardCliTests(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class WorkflowTests(unittest.TestCase):
|
class WorkflowTests(unittest.TestCase):
|
||||||
|
def test_statement_compile_variant_avoids_gemini_and_restart(self) -> None:
|
||||||
|
command = self.command('statement', 'compile',
|
||||||
|
{'target': self.evaluation, 'restart': True})
|
||||||
|
self.assertIn('--compile-only', command)
|
||||||
|
self.assertNotIn('--restart', command)
|
||||||
|
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.steps = {step.id: step for step in build_workflow(True)}
|
self.steps = {step.id: step for step in build_workflow(True)}
|
||||||
self.evaluation = evaluation_argument(REPOSITORY, REPOSITORY / "Example")
|
self.evaluation = evaluation_argument(REPOSITORY, REPOSITORY / "Example")
|
||||||
|
|||||||
@@ -151,6 +151,33 @@ class FullStatementResumeTests(unittest.TestCase):
|
|||||||
response(contexts=[]),
|
response(contexts=[]),
|
||||||
response(rubrics=[{'label': 'Ex 1', 'rubric_content': '4 points : correct'}])]
|
response(rubrics=[{'label': 'Ex 1', 'rubric_content': '4 points : correct'}])]
|
||||||
|
|
||||||
|
def test_compile_only_never_enters_analysis_and_preserves_sources(self):
|
||||||
|
directory = self.root / 'Sol2'
|
||||||
|
directory.mkdir()
|
||||||
|
tex = directory / '1.tex'
|
||||||
|
tex.write_text('manually edited solution')
|
||||||
|
with patch.object(gemini, 'process_exam') as analysis, patch.object(
|
||||||
|
gemini, 'compile_to_pdf', self.compiler):
|
||||||
|
self.assertEqual(gemini.main([str(self.root), '--compile-only']), 0)
|
||||||
|
analysis.assert_not_called()
|
||||||
|
self.assertEqual(tex.read_text(), 'manually edited solution')
|
||||||
|
self.assertEqual((directory / '1.pdf').read_bytes(), b'PDF')
|
||||||
|
self.assertEqual(self.compiler.call_args.kwargs['source_path'], self.root / 'correction.tex')
|
||||||
|
|
||||||
|
def test_preamble_change_recompiles_previously_successful_pdfs(self):
|
||||||
|
self.client.models.generate_content.side_effect = self.responses()
|
||||||
|
self.assertEqual(self.run_exam(), ExitCode.SUCCESS)
|
||||||
|
self.compiler.reset_mock()
|
||||||
|
self.assertEqual(self.run_exam(), ExitCode.SUCCESS)
|
||||||
|
self.compiler.assert_not_called()
|
||||||
|
(self.root / 'correction.tex').write_text(
|
||||||
|
r'\documentclass{article}\newcommand{\custom}{new}\begin{document}')
|
||||||
|
self.client.models.generate_content.reset_mock()
|
||||||
|
self.assertEqual(self.run_exam(), ExitCode.SUCCESS)
|
||||||
|
self.client.models.generate_content.assert_not_called()
|
||||||
|
self.assertEqual(self.compiler.call_count, 1)
|
||||||
|
self.assertEqual(self.compiler.call_args.args[1], self.root / 'Sol2/Ex 1.pdf')
|
||||||
|
|
||||||
def test_rubric_failure_retry_keeps_grouping_edits_and_sources(self):
|
def test_rubric_failure_retry_keeps_grouping_edits_and_sources(self):
|
||||||
self.client.models.generate_content.side_effect = [*self.responses()[:3], RuntimeError('offline')]
|
self.client.models.generate_content.side_effect = [*self.responses()[:3], RuntimeError('offline')]
|
||||||
self.assertEqual(self.run_exam(), ExitCode.PARTIAL)
|
self.assertEqual(self.run_exam(), ExitCode.PARTIAL)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import contextlib
|
import contextlib
|
||||||
import io
|
import io
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -10,6 +11,61 @@ from copienator import utils
|
|||||||
|
|
||||||
|
|
||||||
class CompileToPdfTests(unittest.TestCase):
|
class CompileToPdfTests(unittest.TestCase):
|
||||||
|
def test_source_preamble_keeps_macros_and_uses_fragment_layout(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
source = Path(directory) / 'source.tex'
|
||||||
|
source.write_text('% \\begin{document}\n\\documentclass{article}\n'
|
||||||
|
'\\usepackage{amsthm}\n\\newcommand{\\custom}{works}\n'
|
||||||
|
'\\begin{document}original body\\end{document}')
|
||||||
|
header = utils.fragment_latex_header(source)
|
||||||
|
self.assertIn('standalone', header)
|
||||||
|
self.assertIn(r'\usepackage{amsthm}', header)
|
||||||
|
self.assertIn(r'\newcommand{\custom}{works}', header)
|
||||||
|
self.assertNotIn('original body', header)
|
||||||
|
|
||||||
|
@unittest.skipUnless(shutil.which('pdflatex'), 'pdflatex is not installed')
|
||||||
|
def test_source_geometry_does_not_clip_fragment(self):
|
||||||
|
import pymupdf
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
source = Path(directory) / 'source.tex'
|
||||||
|
source.write_text(r'\documentclass{article}'
|
||||||
|
r'\usepackage[margin=1cm]{geometry}'
|
||||||
|
r'\geometry{top=0.5cm}'
|
||||||
|
r'\newcommand{\custom}{FIRSTLINE}'
|
||||||
|
r'\begin{document}unused body\end{document}')
|
||||||
|
output = Path(directory) / 'out.pdf'
|
||||||
|
utils.compile_to_pdf(r'\custom\par LASTLINE', output,
|
||||||
|
source_path=source, strict=True)
|
||||||
|
with pymupdf.open(output) as doc:
|
||||||
|
self.assertIn('FIRSTLINE', doc[0].get_text())
|
||||||
|
self.assertIn('LASTLINE', doc[0].get_text())
|
||||||
|
for word in doc[0].get_text('words'):
|
||||||
|
self.assertGreaterEqual(word[1], 0)
|
||||||
|
self.assertLessEqual(word[3], doc[0].rect.height)
|
||||||
|
|
||||||
|
def test_source_directory_is_available_to_tex(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
source = Path(directory) / 'source.tex'
|
||||||
|
source.write_text(r'\documentclass{article}\begin{document}')
|
||||||
|
result = subprocess.CompletedProcess(args=[], returncode=1, stdout='! error')
|
||||||
|
with patch.object(utils.subprocess, 'run', return_value=result) as run:
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
utils.compile_to_pdf('broken', Path(directory) / 'out.pdf',
|
||||||
|
source_path=source, strict=True)
|
||||||
|
self.assertTrue(run.call_args.kwargs['env']['TEXINPUTS'].startswith(directory))
|
||||||
|
|
||||||
|
def test_strict_failure_preserves_previous_pdf(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
output = Path(directory) / 'out.pdf'
|
||||||
|
output.write_bytes(b'previous PDF')
|
||||||
|
def failed_run(*args, **kwargs):
|
||||||
|
(Path(kwargs['cwd']) / 'text.pdf').write_bytes(b'partial')
|
||||||
|
return subprocess.CompletedProcess(args=[], returncode=1, stdout='! error')
|
||||||
|
with patch.object(utils.subprocess, 'run', side_effect=failed_run):
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
utils.compile_to_pdf('broken', output, strict=True)
|
||||||
|
self.assertEqual(output.read_bytes(), b'previous PDF')
|
||||||
|
|
||||||
def test_strict_compilation_propagates_failure_with_context(self):
|
def test_strict_compilation_propagates_failure_with_context(self):
|
||||||
result = subprocess.CompletedProcess(args=[], returncode=1,
|
result = subprocess.CompletedProcess(args=[], returncode=1,
|
||||||
stdout="! Undefined control sequence.\nl.13 broken command\n")
|
stdout="! Undefined control sequence.\nl.13 broken command\n")
|
||||||
|
|||||||
Reference in new issue
Block a user