randex 0.3.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: randex
3
- Version: 0.3.1
3
+ Version: 0.3.3
4
4
  Summary: Create randomized multiple choice exams using latex.
5
5
  License: CC BY-NC 4.0
6
6
  Author: G. Arampatzis
@@ -70,57 +70,56 @@ probably have already `latexmk` installed as well.
70
70
 
71
71
  ## Randex Commands
72
72
 
73
- ### randex-download-examples
73
+ ### randex download-examples
74
74
 
75
- To download the latest examples from GitHub, run the following command from the root
76
- folder of the project:
75
+ To download the latest examples from GitHub, run the following command:
77
76
 
78
77
  ```sh
79
- randex-download-examples
78
+ randex download-examples
80
79
  ```
81
80
 
82
- ### validate
81
+ ### randex validate
83
82
 
84
83
  This command validates a single question or all questions under a folder. Execute:
85
84
 
86
85
  ```sh
87
- validate -t examples/en/template-exam.yaml -o tmp --overwrite "examples/en/folder_*"
86
+ randex validate "examples/en/folder_*" -t examples/en/template-exam.yaml -o tmp --overwrite
88
87
  ```
89
88
 
90
89
  to validate all the questions under the folder `examples/en` that contains subfolders
91
90
  with questions.
92
91
  It will use the configuration from the file `examples/en/template-exam.yaml`.
93
- The LaTeX compilation will run inside the `temp` folder.
92
+ The LaTeX compilation will run inside the `tmp` folder.
94
93
  The `--clean` option will remove all intermediate files created by LaTeX,
95
94
  and the `-a` flag will show the correct answers in the produced PDF.
96
- Open the PDF file inside `temp` to validate that all questions appear correctly.
95
+ Open the PDF file inside `tmp` to validate that all questions appear correctly.
97
96
 
98
97
  Run:
99
98
 
100
99
  ```sh
101
- validate --help
100
+ randex validate --help
102
101
  ```
103
102
 
104
103
  to see the help message for the command.
105
104
 
106
- ### exams
105
+ ### randex batch
107
106
 
108
107
  To create a batch of exams with random questions, execute:
109
108
 
110
109
  ```sh
111
- exams -b 5 -n 2 -t examples/en/template-exam.yaml -o tmp --overwrite --clean "examples/en/folder_*"
110
+ randex batch "examples/en/folder_*" 5 -n 2 -t examples/en/template-exam.yaml -o tmp --overwrite --clean
112
111
  ```
113
112
 
114
- This command will create 5 exams from using the questions inside the 3 folders with
113
+ This command will create 5 exams using the questions inside the 3 folders with
115
114
  names `folder_0`, `folder_1`, and `folder_2` using the configuration from the file
116
115
  `examples/en/template-exam.yaml`.
117
116
  The `--clean` option will remove all intermediate files created by LaTeX.
118
117
  The `-n` option specifies the number of questions randomly chosen from each folder.
119
118
  It can appear once, meaning all folders will contribute the same number of questions,
120
- or multiple times, e.g., `-n 2 -n 1 -n 3 `, indicating the first folder will contribute
119
+ or multiple times, e.g., `-n 2 -n 1 -n 3`, indicating the first folder will contribute
121
120
  2 questions, the second folder will contribute 1 question, and the third folder will
122
121
  contribute 3 questions.
123
- The `-b` option specifies the number of exams to create.
122
+ The batch size (5 in this example) specifies the number of exams to create.
124
123
 
125
124
  ### Grade
126
125
 
@@ -44,57 +44,56 @@ probably have already `latexmk` installed as well.
44
44
 
45
45
  ## Randex Commands
46
46
 
47
- ### randex-download-examples
47
+ ### randex download-examples
48
48
 
49
- To download the latest examples from GitHub, run the following command from the root
50
- folder of the project:
49
+ To download the latest examples from GitHub, run the following command:
51
50
 
52
51
  ```sh
53
- randex-download-examples
52
+ randex download-examples
54
53
  ```
55
54
 
56
- ### validate
55
+ ### randex validate
57
56
 
58
57
  This command validates a single question or all questions under a folder. Execute:
59
58
 
60
59
  ```sh
61
- validate -t examples/en/template-exam.yaml -o tmp --overwrite "examples/en/folder_*"
60
+ randex validate "examples/en/folder_*" -t examples/en/template-exam.yaml -o tmp --overwrite
62
61
  ```
63
62
 
64
63
  to validate all the questions under the folder `examples/en` that contains subfolders
65
64
  with questions.
66
65
  It will use the configuration from the file `examples/en/template-exam.yaml`.
67
- The LaTeX compilation will run inside the `temp` folder.
66
+ The LaTeX compilation will run inside the `tmp` folder.
68
67
  The `--clean` option will remove all intermediate files created by LaTeX,
69
68
  and the `-a` flag will show the correct answers in the produced PDF.
70
- Open the PDF file inside `temp` to validate that all questions appear correctly.
69
+ Open the PDF file inside `tmp` to validate that all questions appear correctly.
71
70
 
72
71
  Run:
73
72
 
74
73
  ```sh
75
- validate --help
74
+ randex validate --help
76
75
  ```
77
76
 
78
77
  to see the help message for the command.
79
78
 
80
- ### exams
79
+ ### randex batch
81
80
 
82
81
  To create a batch of exams with random questions, execute:
83
82
 
84
83
  ```sh
85
- exams -b 5 -n 2 -t examples/en/template-exam.yaml -o tmp --overwrite --clean "examples/en/folder_*"
84
+ randex batch "examples/en/folder_*" 5 -n 2 -t examples/en/template-exam.yaml -o tmp --overwrite --clean
86
85
  ```
87
86
 
88
- This command will create 5 exams from using the questions inside the 3 folders with
87
+ This command will create 5 exams using the questions inside the 3 folders with
89
88
  names `folder_0`, `folder_1`, and `folder_2` using the configuration from the file
90
89
  `examples/en/template-exam.yaml`.
91
90
  The `--clean` option will remove all intermediate files created by LaTeX.
92
91
  The `-n` option specifies the number of questions randomly chosen from each folder.
93
92
  It can appear once, meaning all folders will contribute the same number of questions,
94
- or multiple times, e.g., `-n 2 -n 1 -n 3 `, indicating the first folder will contribute
93
+ or multiple times, e.g., `-n 2 -n 1 -n 3`, indicating the first folder will contribute
95
94
  2 questions, the second folder will contribute 1 question, and the third folder will
96
95
  contribute 3 questions.
97
- The `-b` option specifies the number of exams to create.
96
+ The batch size (5 in this example) specifies the number of exams to create.
98
97
 
99
98
  ### Grade
100
99
 
@@ -53,7 +53,7 @@ packages = [
53
53
  {include = "scripts"}
54
54
  ]
55
55
  readme = "README.md"
56
- version = "0.3.1"
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 compile(self, path: Path | str, clean: bool = False) -> None:
862
+ def _run_parallel_compilation(
863
+ self, path: Path, clean: bool
864
+ ) -> tuple[list[Path], list[str]]:
820
865
  """
821
- Compile all exams and merge into a single PDF.
866
+ Run parallel compilation of all exams.
822
867
 
823
- Parameters
824
- ----------
825
- path : Path | str
826
- The path to the directory where the exams will be compiled.
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
- if not self.exams:
831
- raise RuntimeError("No exams to compile. Call make_batch() first.")
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
- path = Path(path).resolve()
834
- path.mkdir(exist_ok=True, parents=True)
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 merged %s PDFs into %s",
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