randex 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
randex/exam.py ADDED
@@ -0,0 +1,908 @@
1
+ """
2
+ Implementation of the Question and the Exam classes.
3
+
4
+ These classes are the building block of the library.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import subprocess
11
+ import time
12
+ from collections import OrderedDict
13
+ from copy import deepcopy
14
+ from dataclasses import dataclass, field
15
+ from functools import cached_property
16
+ from pathlib import Path
17
+ from random import sample, shuffle
18
+
19
+ import yaml
20
+ from pydantic import BaseModel, field_validator, model_validator
21
+ from pypdf import PdfWriter
22
+ from typing_extensions import Self
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ def is_glob_expression(s: str) -> bool:
28
+ """
29
+ Check if a string is a glob expression.
30
+
31
+ Parameters
32
+ ----------
33
+ s : str
34
+ The string to check.
35
+
36
+ Returns
37
+ -------
38
+ bool:
39
+ True if the string is a glob expression, False otherwise.
40
+ """
41
+ return any(char in s for char in "*?[]")
42
+
43
+
44
+ class Question(BaseModel):
45
+ """
46
+ Represents a multiple-choice question with a correct answer.
47
+
48
+ Provides methods for LaTeX export and answer shuffling.
49
+
50
+ Attributes
51
+ ----------
52
+ question : str
53
+ The question text.
54
+ answers : list[str]
55
+ The list of answers.
56
+ right_answer : int
57
+ The index of the correct answer.
58
+ """
59
+
60
+ question: str
61
+ answers: list[str]
62
+ right_answer: int
63
+
64
+ @field_validator("answers", mode="before")
65
+ @classmethod
66
+ def validate_answers(cls, v: list[str]) -> list[str]:
67
+ """
68
+ Validate the answers field.
69
+
70
+ Parameters
71
+ ----------
72
+ v : list
73
+ The answers to validate.
74
+
75
+ Returns
76
+ -------
77
+ list[str]:
78
+ A list of strings.
79
+ """
80
+ if not isinstance(v, list):
81
+ raise TypeError("'answers' must be a list")
82
+ return [str(a) for a in v]
83
+
84
+ @field_validator("right_answer", mode="before")
85
+ @classmethod
86
+ def validate_right_answer_type(cls, v: int) -> int:
87
+ """
88
+ Validate the type of the right_answer field.
89
+
90
+ Parameters
91
+ ----------
92
+ v : int
93
+ The right answer to validate.
94
+
95
+ Returns
96
+ -------
97
+ int:
98
+ The right answer.
99
+ """
100
+ try:
101
+ return int(v)
102
+ except ValueError as e:
103
+ raise TypeError("'right_answer' must be coercible to an integer") from e
104
+
105
+ @model_validator(mode="after")
106
+ def validate_right_answer_value(self) -> Question:
107
+ """
108
+ Validate the value of the right answer.
109
+
110
+ Returns
111
+ -------
112
+ Question:
113
+ The Question object.
114
+ """
115
+ if not (0 <= self.right_answer < len(self.answers)):
116
+ raise IndexError(
117
+ f"'right_answer' index {self.right_answer} is out of bounds"
118
+ )
119
+ return self
120
+
121
+ def shuffle(self) -> Question:
122
+ """
123
+ Shuffle the answers of the question.
124
+
125
+ Returns
126
+ -------
127
+ Question:
128
+ A new Question object with shuffled answers.
129
+ """
130
+ new_q = deepcopy(self)
131
+ correct = new_q.answers[new_q.right_answer]
132
+ shuffled = new_q.answers[:]
133
+ shuffle(shuffled)
134
+ new_q.answers = shuffled
135
+ new_q.right_answer = shuffled.index(correct)
136
+ return new_q
137
+
138
+ def to_latex(self) -> str:
139
+ """
140
+ Return LaTeX code for the question set with shuffled answers.
141
+
142
+ Returns
143
+ -------
144
+ str:
145
+ A string containing the LaTeX code for the question set
146
+ with shuffled answers.
147
+ """
148
+ lines = [f"\\question {self.question}", "\\begin{oneparchoices}"]
149
+ for i, ans in enumerate(self.answers):
150
+ prefix = "\\correctchoice" if i == self.right_answer else "\\choice"
151
+ lines.append(f" {prefix} {ans}")
152
+ lines.append("\\end{oneparchoices}")
153
+ return "\n".join(lines)
154
+
155
+ def __str__(self) -> str:
156
+ """
157
+ Return a string representation of the question.
158
+
159
+ Returns
160
+ -------
161
+ str:
162
+ A string representation of the question.
163
+ """
164
+ lines = [f"Q: {self.question}"]
165
+ for i, ans in enumerate(self.answers):
166
+ mark = "✓" if i == self.right_answer else " "
167
+ lines.append(f" [{mark}] {i}. {ans}")
168
+ return "\n".join(lines)
169
+
170
+
171
+ @dataclass(frozen=True, kw_only=True)
172
+ class Pool:
173
+ """
174
+ Represents a pool of validated YAML questions.
175
+
176
+ Input:
177
+ - If `folder` is a glob string: matched folders are used directly.
178
+ - If `folder` is a path: that folder and its one-level subfolders are used.
179
+
180
+ Questions are loaded non-recursively from each folder, and must follow the format:
181
+
182
+ question: What is $1+1$?
183
+ answers: ["0", "1", "2", "3"]
184
+ right_answer: 2
185
+ points: 1
186
+ """
187
+
188
+ folder: str | Path
189
+
190
+ def resolve_folder_input(self) -> list[Path]:
191
+ """
192
+ Resolve the folder input to a list of directories (glob or path + subdirs).
193
+
194
+ Returns
195
+ -------
196
+ list[Path]:
197
+ A list of directories.
198
+ """
199
+ folder_input = self.folder
200
+
201
+ if isinstance(folder_input, str) and is_glob_expression(folder_input):
202
+ matched = [p.resolve() for p in Path().glob(folder_input) if p.is_dir()]
203
+ if not matched:
204
+ raise ValueError(
205
+ f"'{folder_input}' was detected as a glob pattern but "
206
+ "matched no folders. If this is a folder name, use a Path instead.",
207
+ )
208
+ return matched
209
+
210
+ folder = Path(folder_input).resolve()
211
+ if not folder.is_dir():
212
+ raise NotADirectoryError(f"{folder} is not a directory")
213
+
214
+ return [folder] + [f for f in folder.iterdir() if f.is_dir()]
215
+
216
+ @cached_property
217
+ def questions(self) -> OrderedDict[Path, list[Question]]:
218
+ """
219
+ Maps: folder → list of validated questions.
220
+
221
+ Only includes folders with valid questions, sorted by name.
222
+
223
+ Returns
224
+ -------
225
+ OrderedDict[Path, list[Question]]:
226
+ A dictionary mapping folders to lists of questions.
227
+ """
228
+ result = {}
229
+
230
+ for folder in self.resolve_folder_input():
231
+ folder_questions = []
232
+
233
+ for f in folder.iterdir():
234
+ if not f.is_file() or f.suffix.lower() not in {".yaml", ".yml"}:
235
+ continue
236
+
237
+ try:
238
+ with f.open("r", encoding="utf-8") as stream:
239
+ data = yaml.safe_load(stream)
240
+ question = Question(**data)
241
+ folder_questions.append(question)
242
+ except (yaml.YAMLError, TypeError, ValueError) as e:
243
+ logger.warning("Skipping %s (invalid question): %s", f, e)
244
+ if folder_questions:
245
+ result[folder] = folder_questions
246
+ logger.debug(
247
+ "Found %s valid questions in %s",
248
+ len(folder_questions),
249
+ folder,
250
+ )
251
+ else:
252
+ logger.info("No valid questions found in %s, excluding it.", folder)
253
+
254
+ return OrderedDict(sorted(result.items(), key=lambda x: x[0].name))
255
+
256
+ @cached_property
257
+ def folders(self) -> tuple[Path, ...]:
258
+ """
259
+ Return folders containing at least one valid question, sorted by name.
260
+
261
+ Returns
262
+ -------
263
+ tuple[Path, ...]:
264
+ A tuple of folders.
265
+ """
266
+ return tuple(self.questions.keys())
267
+
268
+ @cached_property
269
+ def number_of_folders(self) -> int:
270
+ """
271
+ Return the total number of folders.
272
+
273
+ Returns
274
+ -------
275
+ int:
276
+ The number of folders.
277
+ """
278
+ return len(self.questions)
279
+
280
+ def __str__(self) -> str:
281
+ """
282
+ Return a string representation of the pool.
283
+
284
+ Returns
285
+ -------
286
+ str:
287
+ A string representation of the pool.
288
+ """
289
+ lines = [f"Pool with {self.number_of_folders} folder(s):"]
290
+ for folder, questions in self.questions.items():
291
+ lines.append(f" - {folder}: {len(questions)} question(s)")
292
+ return "\n".join(lines)
293
+
294
+ def print_questions(self) -> None:
295
+ """Print all questions in the pool grouped by folder."""
296
+ first = True
297
+ for folder, question_list in sorted(
298
+ self.questions.items(),
299
+ key=lambda x: x[0].name.lower(),
300
+ ):
301
+ if not question_list:
302
+ continue
303
+
304
+ if not first:
305
+ print("\n" + "-" * 60)
306
+ first = False
307
+
308
+ print(f"\n\U0001f4c1 {folder}\n")
309
+ for i, question in enumerate(question_list):
310
+ print(f" \U0001f4c4 Question {i + 1}")
311
+ print(f" Q: {question.question}")
312
+ print(" Answers:")
313
+ for j, ans in enumerate(question.answers):
314
+ mark = "✅" if j == question.right_answer else " "
315
+ print(f" {mark} {j}. {ans}")
316
+ print("\n")
317
+
318
+
319
+ class QuestionSet(BaseModel):
320
+ """
321
+ A set of questions.
322
+
323
+ The questions are grouped by a string key which usually represents a folder.
324
+
325
+ Attributes
326
+ ----------
327
+ questions: dict[str, list[Question]]
328
+ A dictionary mapping strings to lists of Question.
329
+ """
330
+
331
+ questions: OrderedDict[str, list[Question]]
332
+
333
+ @model_validator(mode="before")
334
+ @classmethod
335
+ def fix_keys(cls, data: dict) -> dict:
336
+ """
337
+ Fix the keys in the questions.
338
+
339
+ This allows to initialize the model with questions: dict[Path, list[Question]]
340
+ instead of dict[str, list[Question]].
341
+
342
+ Parameters
343
+ ----------
344
+ data : dict
345
+ The data to fix.
346
+
347
+ Returns
348
+ -------
349
+ dict:
350
+ The fixed data.
351
+ """
352
+ if isinstance(data, dict) and "questions" in data:
353
+ questions = data["questions"]
354
+ if isinstance(questions, dict):
355
+ data["questions"] = {str(k): v for k, v in questions.items()}
356
+ return data
357
+
358
+ def size(self) -> int:
359
+ """
360
+ Return the number of questions in the set.
361
+
362
+ Returns
363
+ -------
364
+ int:
365
+ The number of questions in the set.
366
+ """
367
+ return sum(len(qs) for qs in self.questions.values())
368
+
369
+ def keys(self) -> list[str]:
370
+ """
371
+ Return the keys of the questions.
372
+
373
+ Returns
374
+ -------
375
+ list[str]:
376
+ The keys of the questions.
377
+ """
378
+ return list(self.questions.keys())
379
+
380
+ def sample(
381
+ self,
382
+ n: int | list[int] | tuple[int, ...],
383
+ ) -> list[Question]:
384
+ """
385
+ Sample a number of questions from the pool.
386
+
387
+ Parameters
388
+ ----------
389
+ n : int | list[int] | tuple[int, ...]
390
+ The number of questions to sample.
391
+ If a list or tuple is provided, the number of questions to sample from
392
+ each folder. If an integer is provided, the number of questions to sample
393
+ from the pool.
394
+
395
+ Returns
396
+ -------
397
+ list[Question]:
398
+ A list of questions.
399
+ """
400
+ if isinstance(n, list | tuple):
401
+ if len(n) != len(self.questions):
402
+ question_keys = "\n" + "\n".join(str(k) for k in self.keys()) + "\n"
403
+ raise ValueError(
404
+ f"Expected {len(self.questions)} integers "
405
+ "— one for each folder: "
406
+ f"{question_keys}but got {len(n)}.",
407
+ )
408
+
409
+ selected = []
410
+ for count, folder in zip(n, self.questions, strict=False):
411
+ items = self.questions[folder]
412
+ if count > len(items):
413
+ raise ValueError(
414
+ f"requested {count} questions, but only {len(items)} "
415
+ f"are available in folder: \n{folder}",
416
+ )
417
+ selected.extend(sample(items, count))
418
+
419
+ elif isinstance(n, int):
420
+ all_items = [q for qs in self.questions.values() for q in qs]
421
+ if n > len(all_items):
422
+ raise ValueError(
423
+ f"Requested {n} questions, but only {len(all_items)} are available"
424
+ )
425
+ selected = sample(all_items, n)
426
+
427
+ else:
428
+ raise TypeError("n must be an int, list[int], or tuple[int]") # type: ignore[unreachable]
429
+
430
+ return [deepcopy(d) for d in selected]
431
+
432
+
433
+ class ExamTemplate(BaseModel):
434
+ """Pydantic-based class holding LaTeX exam configuration parts."""
435
+
436
+ documentclass: str = "\\documentclass[11pt]{exam}\n\n"
437
+
438
+ prebegin: str = (
439
+ "\\usepackage{amsmath}\n"
440
+ "\\usepackage{amssymb}\n"
441
+ "\\usepackage{bm}\n"
442
+ "\\usepackage{geometry}\n\n"
443
+ "\\geometry{\n"
444
+ " a4paper,\n"
445
+ " total={160mm,250mm},\n"
446
+ " left=15mm,\n"
447
+ " right=15mm,\n"
448
+ " top=20mm,\n"
449
+ "}\n\n"
450
+ r"\linespread{1.2}"
451
+ )
452
+
453
+ postbegin: str = (
454
+ "\n\\makebox[0.9\\textwidth]{Name\\enspace\\hrulefill}\n"
455
+ "\\vspace{10mm}\n\n"
456
+ "\\makebox[0.3\\textwidth]{Register number:\\enspace\\hrulefill}\n"
457
+ "\\makebox[0.6\\textwidth]{School:\\enspace\\hrulefill}\n"
458
+ "\\vspace{10mm}\n\n"
459
+ )
460
+
461
+ preend: str = ""
462
+ head: str = "\n\\pagestyle{head}\n\\runningheadrule\n"
463
+ lhead: str = ""
464
+ chead: str = ""
465
+
466
+ @classmethod
467
+ def load(cls, exam_template: Path | dict | None) -> Self:
468
+ """
469
+ Load the exam configuration from a dict or YAML file.
470
+
471
+ Parameters
472
+ ----------
473
+ exam_template : Path | dict | None
474
+ The path to the YAML file or a dictionary.
475
+
476
+ Returns
477
+ -------
478
+ ExamTemplate:
479
+ An ExamTemplate object.
480
+ """
481
+ if exam_template is None:
482
+ return cls()
483
+
484
+ if isinstance(exam_template, Path):
485
+ if not exam_template.is_file():
486
+ logger.warning(
487
+ "The file %s does not exist. Using default configuration.",
488
+ exam_template,
489
+ )
490
+ return cls()
491
+ with exam_template.open("r", encoding="utf-8") as f:
492
+ exam_template = yaml.safe_load(f) or {}
493
+
494
+ return cls(**exam_template)
495
+
496
+
497
+ class Exam(BaseModel):
498
+ """A Pydantic model for a single exam with multiple questions."""
499
+
500
+ exam_template: ExamTemplate
501
+ show_answers: bool = False
502
+ sn: str = "0"
503
+ questions: list[Question]
504
+
505
+ @field_validator("sn")
506
+ @classmethod
507
+ def validate_sn_format(cls, v: str) -> str:
508
+ """
509
+ Validate the format of the serial number.
510
+
511
+ Parameters
512
+ ----------
513
+ v : str
514
+ The serial number to validate.
515
+
516
+ Returns
517
+ -------
518
+ str:
519
+ The serial number.
520
+ """
521
+ if not v.isdigit():
522
+ raise ValueError("Serial number (sn) must be numeric.")
523
+ return v
524
+
525
+ @field_validator("questions")
526
+ @classmethod
527
+ def validate_questions_not_empty(cls, v: list[Question]) -> list[Question]:
528
+ """
529
+ Validate that the exam contains at least one question.
530
+
531
+ Parameters
532
+ ----------
533
+ v : list[Question]
534
+ The questions to validate.
535
+
536
+ Returns
537
+ -------
538
+ list[Question]:
539
+ The questions.
540
+ """
541
+ if not v:
542
+ raise ValueError("Exam must contain at least one question.")
543
+ return v
544
+
545
+ @model_validator(mode="after")
546
+ def validate_unique_questions(self) -> Exam:
547
+ """
548
+ Validate that the questions are unique.
549
+
550
+ Returns
551
+ -------
552
+ Exam:
553
+ The Exam object.
554
+ """
555
+ seen = set()
556
+ for q in self.questions:
557
+ key = (q.question, tuple(q.answers))
558
+ if key in seen:
559
+ raise ValueError(f"Duplicate question found: '{q.question}'")
560
+ seen.add(key)
561
+ return self
562
+
563
+ def apply_shuffling(
564
+ self,
565
+ shuffle_questions: bool = False,
566
+ shuffle_answers: bool = False,
567
+ ) -> None:
568
+ """
569
+ Apply question and/or answer shuffling in-place.
570
+
571
+ Parameters
572
+ ----------
573
+ shuffle_questions : bool
574
+ Whether to shuffle the questions.
575
+ shuffle_answers : bool
576
+ Whether to shuffle the answers.
577
+ """
578
+ if shuffle_questions:
579
+ from random import shuffle
580
+
581
+ shuffle(self.questions)
582
+ if shuffle_answers:
583
+ self.questions = [q.shuffle() for q in self.questions]
584
+
585
+ def compile(
586
+ self,
587
+ path: Path | str | None,
588
+ clean: bool = False,
589
+ ) -> subprocess.CompletedProcess:
590
+ """
591
+ Compile the LaTeX exam document to PDF.
592
+
593
+ Parameters
594
+ ----------
595
+ path : Path | str | None
596
+ The path to the directory where the exams will be compiled.
597
+ clean : bool
598
+ Whether to clean the LaTeX auxiliary files.
599
+
600
+ Returns
601
+ -------
602
+ subprocess.CompletedProcess:
603
+ The result of the compilation.
604
+ """
605
+ if not path:
606
+ path = Path(".")
607
+ elif isinstance(path, str):
608
+ path = Path(path)
609
+
610
+ path.mkdir(exist_ok=True, parents=True)
611
+ tex_file = path / "exam.tex"
612
+
613
+ with open(tex_file, "w", encoding="utf-8") as f:
614
+ f.write(str(self))
615
+
616
+ cmd = f"latexmk -pdf -cd {tex_file} -interaction=nonstopmode -f"
617
+ logger.info("Compiling: %s", cmd)
618
+
619
+ try:
620
+ result = subprocess.run(
621
+ cmd.split(),
622
+ capture_output=True,
623
+ text=True,
624
+ check=False,
625
+ timeout=3600,
626
+ )
627
+ if result.returncode != 0:
628
+ logger.error("LaTeX compilation failed: %s", result.stderr)
629
+ else:
630
+ logger.info("Compilation succeeded")
631
+
632
+ except subprocess.TimeoutExpired as e:
633
+ logger.exception("LaTeX compilation timed out")
634
+ raise RuntimeError("Compilation timed out after 5 minutes") from e
635
+
636
+ if clean:
637
+ time.sleep(1)
638
+ clean_cmd = f"latexmk -c -cd {tex_file}"
639
+ try:
640
+ subprocess.run(
641
+ clean_cmd.split(),
642
+ capture_output=True,
643
+ text=True,
644
+ check=False,
645
+ timeout=600,
646
+ )
647
+ except subprocess.TimeoutExpired as e:
648
+ logger.warning("Cleanup timed out")
649
+ raise RuntimeError("Cleanup timed out after 10 minutes") from e
650
+
651
+ return result
652
+
653
+ def __str__(self) -> str:
654
+ """
655
+ LaTeX-ready representation of the exam.
656
+
657
+ Returns
658
+ -------
659
+ str:
660
+ A string containing the LaTeX code for the exam.
661
+ """
662
+ doc_parts = [
663
+ self.exam_template.documentclass,
664
+ self.exam_template.prebegin,
665
+ ]
666
+
667
+ if self.show_answers:
668
+ doc_parts.append("\n\\printanswers\n")
669
+
670
+ doc_parts.extend(
671
+ [
672
+ self.exam_template.head,
673
+ f"\n\\rhead{{{self.sn}}}\n\n",
674
+ self.exam_template.lhead,
675
+ self.exam_template.chead,
676
+ "\n\n\\begin{document}\n",
677
+ self.exam_template.postbegin,
678
+ "\\begin{questions}\n\n",
679
+ ]
680
+ )
681
+
682
+ for q in self.questions:
683
+ doc_parts.extend([q.to_latex()])
684
+
685
+ doc_parts.extend(
686
+ [
687
+ "\n\n\\end{questions}\n\n",
688
+ self.exam_template.preend,
689
+ "\n\\end{document}",
690
+ ]
691
+ )
692
+
693
+ return "\n".join(doc_parts)
694
+
695
+
696
+ class ExamBatch(BaseModel):
697
+ """A batch of exams with random questions."""
698
+
699
+ N: int
700
+ questions_set: QuestionSet
701
+ exam_template: ExamTemplate
702
+ n: list[int] | tuple[int, ...] | int = field(default=1)
703
+ exams: list[Exam] = field(default_factory=list)
704
+ show_answers: bool = False
705
+
706
+ @field_validator("N")
707
+ @classmethod
708
+ def validate_N(cls, v: int) -> int: # noqa: N802
709
+ """
710
+ Validate the number of exams.
711
+
712
+ Parameters
713
+ ----------
714
+ v : int
715
+ The number of exams.
716
+
717
+ Returns
718
+ -------
719
+ int:
720
+ The number of exams.
721
+ """
722
+ if v <= 0:
723
+ raise ValueError("N must be a positive integer")
724
+ return v
725
+
726
+ @field_validator("n", mode="before")
727
+ @classmethod
728
+ def validate_n_type(
729
+ cls,
730
+ v: int | list[int] | tuple[int, ...],
731
+ ) -> int | list[int] | tuple[int, ...]:
732
+ """
733
+ Validate the type of the number of questions.
734
+
735
+ Parameters
736
+ ----------
737
+ v : int | list[int] | tuple[int, ...]
738
+ The number of questions.
739
+
740
+ Returns
741
+ -------
742
+ int | list[int] | tuple[int, ...]:
743
+ The number of questions.
744
+ """
745
+ if isinstance(v, int):
746
+ if v <= 0:
747
+ raise ValueError("Number of questions must be positive")
748
+ elif isinstance(v, (list, tuple)):
749
+ if not all(isinstance(x, int) and x > 0 for x in v):
750
+ raise ValueError("All elements in 'n' must be positive integers")
751
+ else:
752
+ raise TypeError(
753
+ f"'n' is {type(v)} but must be an int or list/tuple of ints"
754
+ )
755
+ return v
756
+
757
+ @model_validator(mode="after")
758
+ def validate_question_availability(self) -> ExamBatch:
759
+ """
760
+ Validate that the number of questions is available.
761
+
762
+ Returns
763
+ -------
764
+ ExamBatch:
765
+ The ExamBatch object.
766
+ """
767
+ if isinstance(self.n, int):
768
+ if self.n > self.questions_set.size():
769
+ raise ValueError(
770
+ f"Requested {self.n} questions, but only "
771
+ f"{self.questions_set.size()} available"
772
+ )
773
+ else:
774
+ for i, (key, qlist) in enumerate(self.questions_set.questions.items()):
775
+ if i >= len(self.n):
776
+ break
777
+ if self.n[i] > len(qlist):
778
+ raise ValueError(
779
+ f"Requested {self.n[i]} questions from {key}, "
780
+ f"but only {len(qlist)} available"
781
+ )
782
+ return self
783
+
784
+ def make_batch(self) -> None:
785
+ """Generate a batch of randomized exams."""
786
+ serial_width = len(str(self.N))
787
+
788
+ for i in range(self.N):
789
+ try:
790
+ questions = self.questions_set.sample(self.n)
791
+ serial_number = str(i).zfill(serial_width)
792
+ exam = Exam(
793
+ sn=serial_number,
794
+ exam_template=self.exam_template,
795
+ questions=questions,
796
+ show_answers=self.show_answers,
797
+ )
798
+ self.exams.append(exam)
799
+ logger.debug(
800
+ "Created exam %s with %s questions", serial_number, len(questions)
801
+ )
802
+ except Exception as e:
803
+ logger.exception("Failed to create exam %s", i)
804
+ raise RuntimeError(f"Failed to create exam {i}") from e
805
+
806
+ logger.info("Successfully created %s exams", len(self.exams))
807
+
808
+ def compile(self, path: Path | str, clean: bool = False) -> None:
809
+ """
810
+ Compile all exams and merge into a single PDF.
811
+
812
+ Parameters
813
+ ----------
814
+ path : Path | str
815
+ The path to the directory where the exams will be compiled.
816
+ clean : bool
817
+ Whether to clean the LaTeX auxiliary files.
818
+ """
819
+ if not self.exams:
820
+ raise RuntimeError("No exams to compile. Call make_batch() first.")
821
+
822
+ path = Path(path).resolve()
823
+ path.mkdir(exist_ok=True, parents=True)
824
+
825
+ pdf_files = []
826
+ failed = []
827
+
828
+ for exam in self.exams:
829
+ exam_dir = path / exam.sn
830
+ exam_dir.mkdir(exist_ok=True, parents=True)
831
+
832
+ try:
833
+ logger.info("Compiling exam %s", exam.sn)
834
+ result = exam.compile(exam_dir, clean)
835
+
836
+ pdf_path = exam_dir / "exam.pdf"
837
+ if result.returncode == 0 and pdf_path.exists():
838
+ pdf_files.append(pdf_path)
839
+ else:
840
+ logger.error("PDF not created or failed for exam %s", exam.sn)
841
+ failed.append(exam.sn)
842
+
843
+ except Exception:
844
+ logger.exception("Error compiling exam %s", exam.sn)
845
+ failed.append(exam.sn)
846
+
847
+ if failed:
848
+ logger.warning("Failed to compile exams: %s", ", ".join(failed))
849
+ if not pdf_files:
850
+ raise RuntimeError("No exams compiled successfully")
851
+
852
+ merged_path = path / "exams.pdf"
853
+ merger = PdfWriter()
854
+
855
+ try:
856
+ for pdf_path in pdf_files:
857
+ try:
858
+ merger.append(pdf_path)
859
+ except Exception:
860
+ logger.exception("Failed to add %s to merged PDF", pdf_path)
861
+
862
+ with open(merged_path, "wb") as f:
863
+ merger.write(f)
864
+
865
+ logger.info(
866
+ "Successfully merged %s PDFs into %s",
867
+ len(pdf_files),
868
+ merged_path,
869
+ )
870
+
871
+ except Exception as e:
872
+ logger.exception("Failed to create merged PDF")
873
+ raise RuntimeError("Failed to create merged PDF") from e
874
+ finally:
875
+ merger.close()
876
+
877
+ def save(self, path: Path | str) -> None:
878
+ """
879
+ Save the batch to a YAML file.
880
+
881
+ Parameters
882
+ ----------
883
+ path : Path | str
884
+ The path to the YAML file.
885
+ """
886
+ path = Path(path)
887
+ with path.open("w", encoding="utf-8") as f:
888
+ yaml.dump(self.model_dump(mode="json"), f, allow_unicode=True)
889
+
890
+ @classmethod
891
+ def load(cls, path: Path | str) -> Self:
892
+ """
893
+ Load a batch from a YAML file.
894
+
895
+ Parameters
896
+ ----------
897
+ path : Path | str
898
+ The path to the YAML file.
899
+
900
+ Returns
901
+ -------
902
+ ExamBatch:
903
+ An ExamBatch object.
904
+ """
905
+ path = Path(path)
906
+ with path.open("r", encoding="utf-8") as f:
907
+ data = yaml.safe_load(f)
908
+ return cls.model_validate(data)