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/__init__.py +8 -0
- randex/exam.py +908 -0
- randex-0.1.0.dist-info/LICENSE +407 -0
- randex-0.1.0.dist-info/METADATA +190 -0
- randex-0.1.0.dist-info/RECORD +10 -0
- randex-0.1.0.dist-info/WHEEL +4 -0
- randex-0.1.0.dist-info/entry_points.txt +4 -0
- scripts/__init__.py +1 -0
- scripts/exams.py +136 -0
- scripts/validate.py +105 -0
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)
|