randex 0.3.2__tar.gz → 0.3.3__tar.gz
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-0.3.2 → randex-0.3.3}/PKG-INFO +1 -1
- {randex-0.3.2 → randex-0.3.3}/pyproject.toml +2 -1
- {randex-0.3.2 → randex-0.3.3}/randex/exam.py +142 -16
- {randex-0.3.2 → randex-0.3.3}/scripts/batch.py +4 -5
- {randex-0.3.2 → randex-0.3.3}/scripts/randex.py +8 -0
- {randex-0.3.2 → randex-0.3.3}/LICENSE +0 -0
- {randex-0.3.2 → randex-0.3.3}/README.md +0 -0
- {randex-0.3.2 → randex-0.3.3}/randex/__init__.py +0 -0
- {randex-0.3.2 → randex-0.3.3}/randex/cli.py +0 -0
- {randex-0.3.2 → randex-0.3.3}/scripts/__init__.py +0 -0
- {randex-0.3.2 → randex-0.3.3}/scripts/download_examples.py +0 -0
- {randex-0.3.2 → randex-0.3.3}/scripts/validate.py +0 -0
@@ -53,7 +53,7 @@ packages = [
|
|
53
53
|
{include = "scripts"}
|
54
54
|
]
|
55
55
|
readme = "README.md"
|
56
|
-
version = "0.3.
|
56
|
+
version = "0.3.3"
|
57
57
|
|
58
58
|
[tool.poetry.dependencies]
|
59
59
|
cerberus = "^1.3.5"
|
@@ -141,6 +141,7 @@ ignore = [
|
|
141
141
|
"FA100",
|
142
142
|
"T201",
|
143
143
|
"TRY003",
|
144
|
+
"TRY300",
|
144
145
|
"ISC001",
|
145
146
|
"N806",
|
146
147
|
"N803",
|
@@ -10,6 +10,7 @@ import logging
|
|
10
10
|
import subprocess
|
11
11
|
import time
|
12
12
|
from collections import OrderedDict
|
13
|
+
from concurrent.futures import ProcessPoolExecutor, as_completed
|
13
14
|
from copy import deepcopy
|
14
15
|
from dataclasses import dataclass, field
|
15
16
|
from functools import cached_property
|
@@ -17,7 +18,7 @@ from pathlib import Path
|
|
17
18
|
from random import sample, shuffle
|
18
19
|
|
19
20
|
import yaml
|
20
|
-
from pydantic import BaseModel, field_validator, model_validator
|
21
|
+
from pydantic import BaseModel, ValidationError, field_validator, model_validator
|
21
22
|
from pypdf import PdfWriter
|
22
23
|
from typing_extensions import Self
|
23
24
|
|
@@ -792,6 +793,48 @@ class ExamBatch(BaseModel):
|
|
792
793
|
)
|
793
794
|
return self
|
794
795
|
|
796
|
+
@staticmethod
|
797
|
+
def _compile_single_exam(
|
798
|
+
exam_data: dict,
|
799
|
+
exam_dir: Path,
|
800
|
+
clean: bool,
|
801
|
+
) -> tuple[str, bool, str]:
|
802
|
+
"""
|
803
|
+
Compile a single exam in a separate process.
|
804
|
+
|
805
|
+
Parameters
|
806
|
+
----------
|
807
|
+
exam_data : dict
|
808
|
+
Serialized exam data
|
809
|
+
exam_dir : Path
|
810
|
+
Directory to compile the exam in
|
811
|
+
clean : bool
|
812
|
+
Whether to clean auxiliary files
|
813
|
+
|
814
|
+
Returns
|
815
|
+
-------
|
816
|
+
tuple[str, bool, str]
|
817
|
+
Tuple of (serial_number, success, error_message)
|
818
|
+
"""
|
819
|
+
try:
|
820
|
+
exam_dir.mkdir(exist_ok=True, parents=True)
|
821
|
+
|
822
|
+
# Reconstruct the Exam object
|
823
|
+
from randex.exam import Exam
|
824
|
+
|
825
|
+
exam = Exam.model_validate(exam_data)
|
826
|
+
logger.info("Compiling exam %s", exam.sn)
|
827
|
+
|
828
|
+
result = exam.compile(exam_dir, clean)
|
829
|
+
pdf_path = exam_dir / "exam.pdf"
|
830
|
+
|
831
|
+
if result.returncode == 0 and pdf_path.exists():
|
832
|
+
return exam.sn, True, ""
|
833
|
+
return exam.sn, False, "LaTeX compilation failed or PDF not created"
|
834
|
+
|
835
|
+
except (ValidationError, subprocess.SubprocessError, OSError) as e:
|
836
|
+
return exam_data.get("sn", "unknown"), False, str(e)
|
837
|
+
|
795
838
|
def make_batch(self) -> None:
|
796
839
|
"""Generate a batch of randomized exams."""
|
797
840
|
serial_width = len(str(self.N))
|
@@ -816,23 +859,72 @@ class ExamBatch(BaseModel):
|
|
816
859
|
|
817
860
|
logger.info("Successfully created %s exams", len(self.exams))
|
818
861
|
|
819
|
-
def
|
862
|
+
def _run_parallel_compilation(
|
863
|
+
self, path: Path, clean: bool
|
864
|
+
) -> tuple[list[Path], list[str]]:
|
820
865
|
"""
|
821
|
-
|
866
|
+
Run parallel compilation of all exams.
|
822
867
|
|
823
|
-
|
824
|
-
|
825
|
-
|
826
|
-
|
827
|
-
clean : bool
|
828
|
-
Whether to clean the LaTeX auxiliary files.
|
868
|
+
Returns
|
869
|
+
-------
|
870
|
+
tuple[list[Path], list[str]]
|
871
|
+
Tuple of (pdf_files, failed_exams)
|
829
872
|
"""
|
830
|
-
|
831
|
-
|
873
|
+
# Prepare compilation tasks
|
874
|
+
compilation_tasks = []
|
875
|
+
for exam in self.exams:
|
876
|
+
exam_dir = path / exam.sn
|
877
|
+
exam_data = exam.model_dump(mode="json")
|
878
|
+
compilation_tasks.append((exam_data, exam_dir, clean))
|
832
879
|
|
833
|
-
|
834
|
-
|
880
|
+
logger.info("🚀 Starting parallel compilation of %d exams...", len(self.exams))
|
881
|
+
|
882
|
+
pdf_files = []
|
883
|
+
failed = []
|
884
|
+
|
885
|
+
with ProcessPoolExecutor() as executor:
|
886
|
+
future_to_sn = {
|
887
|
+
executor.submit(
|
888
|
+
ExamBatch._compile_single_exam,
|
889
|
+
exam_data,
|
890
|
+
exam_dir,
|
891
|
+
clean,
|
892
|
+
): exam_data["sn"]
|
893
|
+
for exam_data, exam_dir, clean in compilation_tasks
|
894
|
+
}
|
895
|
+
|
896
|
+
for future in as_completed(future_to_sn):
|
897
|
+
sn = future_to_sn[future]
|
898
|
+
try:
|
899
|
+
serial_number, success, error_msg = future.result()
|
900
|
+
if success:
|
901
|
+
pdf_path = path / serial_number / "exam.pdf"
|
902
|
+
pdf_files.append(pdf_path)
|
903
|
+
logger.debug("✅ Successfully compiled exam %s", serial_number)
|
904
|
+
else:
|
905
|
+
logger.error(
|
906
|
+
"❌ Failed to compile exam %s: %s",
|
907
|
+
serial_number,
|
908
|
+
error_msg,
|
909
|
+
)
|
910
|
+
failed.append(serial_number)
|
911
|
+
except Exception:
|
912
|
+
logger.exception("💥 Error compiling exam %s", sn)
|
913
|
+
failed.append(sn)
|
835
914
|
|
915
|
+
return pdf_files, failed
|
916
|
+
|
917
|
+
def _run_sequential_compilation(
|
918
|
+
self, path: Path, clean: bool
|
919
|
+
) -> tuple[list[Path], list[str]]:
|
920
|
+
"""
|
921
|
+
Run sequential compilation of all exams (for backward compatibility).
|
922
|
+
|
923
|
+
Returns
|
924
|
+
-------
|
925
|
+
tuple[list[Path], list[str]]
|
926
|
+
Tuple of (pdf_files, failed_exams)
|
927
|
+
"""
|
836
928
|
pdf_files = []
|
837
929
|
failed = []
|
838
930
|
|
@@ -847,19 +939,51 @@ class ExamBatch(BaseModel):
|
|
847
939
|
pdf_path = exam_dir / "exam.pdf"
|
848
940
|
if result.returncode == 0 and pdf_path.exists():
|
849
941
|
pdf_files.append(pdf_path)
|
942
|
+
logger.debug("✅ Successfully compiled exam %s", exam.sn)
|
850
943
|
else:
|
851
|
-
logger.error("PDF not created or failed for exam %s", exam.sn)
|
944
|
+
logger.error("❌ PDF not created or failed for exam %s", exam.sn)
|
852
945
|
failed.append(exam.sn)
|
853
946
|
|
854
947
|
except Exception:
|
855
|
-
logger.exception("Error compiling exam %s", exam.sn)
|
948
|
+
logger.exception("💥 Error compiling exam %s", exam.sn)
|
856
949
|
failed.append(exam.sn)
|
857
950
|
|
951
|
+
return pdf_files, failed
|
952
|
+
|
953
|
+
def compile(
|
954
|
+
self, path: Path | str, clean: bool = False, parallel: bool = True
|
955
|
+
) -> None:
|
956
|
+
"""
|
957
|
+
Compile all exams and merge into a single PDF.
|
958
|
+
|
959
|
+
Parameters
|
960
|
+
----------
|
961
|
+
path : Path | str
|
962
|
+
The path to the directory where the exams will be compiled.
|
963
|
+
clean : bool
|
964
|
+
Whether to clean the LaTeX auxiliary files.
|
965
|
+
parallel : bool
|
966
|
+
Whether to use parallel compilation. Defaults to True.
|
967
|
+
"""
|
968
|
+
if not self.exams:
|
969
|
+
raise RuntimeError("No exams to compile. Call make_batch() first.")
|
970
|
+
|
971
|
+
path = Path(path).resolve()
|
972
|
+
path.mkdir(exist_ok=True, parents=True)
|
973
|
+
|
974
|
+
if parallel:
|
975
|
+
pdf_files, failed = self._run_parallel_compilation(path, clean)
|
976
|
+
else:
|
977
|
+
pdf_files, failed = self._run_sequential_compilation(path, clean)
|
978
|
+
|
858
979
|
if failed:
|
859
980
|
logger.warning("Failed to compile exams: %s", ", ".join(failed))
|
860
981
|
if not pdf_files:
|
861
982
|
raise RuntimeError("No exams compiled successfully")
|
862
983
|
|
984
|
+
# Sort PDF files by serial number to ensure consistent ordering
|
985
|
+
pdf_files.sort(key=lambda pdf_path: pdf_path.parent.name)
|
986
|
+
|
863
987
|
merged_path = path / "exams.pdf"
|
864
988
|
merger = PdfWriter()
|
865
989
|
|
@@ -873,9 +997,11 @@ class ExamBatch(BaseModel):
|
|
873
997
|
with open(merged_path, "wb") as f:
|
874
998
|
merger.write(f)
|
875
999
|
|
1000
|
+
compilation_type = "parallel" if parallel else "sequential"
|
876
1001
|
logger.info(
|
877
|
-
"Successfully
|
1002
|
+
"🎉 Successfully compiled %s exams %s and merged into %s",
|
878
1003
|
len(pdf_files),
|
1004
|
+
compilation_type,
|
879
1005
|
merged_path,
|
880
1006
|
)
|
881
1007
|
|
@@ -23,6 +23,7 @@ def main(
|
|
23
23
|
out_folder: Path | None,
|
24
24
|
clean: bool,
|
25
25
|
overwrite: bool,
|
26
|
+
sequential: bool,
|
26
27
|
) -> None:
|
27
28
|
"""
|
28
29
|
Create a batch of exams with randomly chosen multiple choice questions.
|
@@ -43,6 +44,8 @@ def main(
|
|
43
44
|
Clean the output folder before creating the exams.
|
44
45
|
overwrite : bool
|
45
46
|
Overwrite the output folder if it already exists.
|
47
|
+
sequential : bool
|
48
|
+
Use sequential compilation instead of parallel.
|
46
49
|
"""
|
47
50
|
if out_folder is None:
|
48
51
|
out_folder = Path(f"tmp_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}")
|
@@ -87,13 +90,9 @@ def main(
|
|
87
90
|
b.make_batch()
|
88
91
|
|
89
92
|
logger.info("🔨 Compiling exams...")
|
90
|
-
b.compile(clean=clean, path=out_folder)
|
93
|
+
b.compile(clean=clean, path=out_folder, parallel=not sequential)
|
91
94
|
|
92
95
|
logger.info("💾 Saving batch configuration to: %s", out_folder / "exams.yaml")
|
93
96
|
b.save(out_folder / "exams.yaml")
|
94
97
|
|
95
|
-
logger.debug("🔄 Reloading and recompiling batch...")
|
96
|
-
b = ExamBatch.load(out_folder / "exams.yaml")
|
97
|
-
b.compile(clean=clean, path=out_folder)
|
98
|
-
|
99
98
|
logger.info("✅ Batch creation completed successfully in: %s", out_folder)
|
@@ -115,6 +115,12 @@ def download_examples() -> None:
|
|
115
115
|
default=False,
|
116
116
|
help="Overwrite the out-folder if it already exists (use with caution).",
|
117
117
|
)
|
118
|
+
@click.option(
|
119
|
+
"--sequential",
|
120
|
+
is_flag=True,
|
121
|
+
default=False,
|
122
|
+
help="Use sequential compilation instead of parallel.",
|
123
|
+
)
|
118
124
|
def batch(
|
119
125
|
folder: str,
|
120
126
|
batch_size: int,
|
@@ -123,6 +129,7 @@ def batch(
|
|
123
129
|
out_folder: Path | None,
|
124
130
|
clean: bool,
|
125
131
|
overwrite: bool,
|
132
|
+
sequential: bool,
|
126
133
|
) -> None:
|
127
134
|
"""
|
128
135
|
Create a batch of exams with randomly chosen multiple choice questions.
|
@@ -148,6 +155,7 @@ def batch(
|
|
148
155
|
out_folder=out_folder,
|
149
156
|
clean=clean,
|
150
157
|
overwrite=overwrite,
|
158
|
+
sequential=sequential,
|
151
159
|
)
|
152
160
|
|
153
161
|
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|