randex 0.2.1__tar.gz → 0.3.2__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.2.1 → randex-0.3.2}/PKG-INFO +14 -15
- {randex-0.2.1 → randex-0.3.2}/README.md +13 -14
- {randex-0.2.1 → randex-0.3.2}/pyproject.toml +2 -4
- {randex-0.2.1 → randex-0.3.2}/randex/cli.py +74 -0
- {randex-0.2.1 → randex-0.3.2}/randex/exam.py +12 -7
- randex-0.3.2/scripts/batch.py +99 -0
- randex-0.2.1/scripts/randex_download_examples.py → randex-0.3.2/scripts/download_examples.py +18 -13
- randex-0.3.2/scripts/randex.py +228 -0
- randex-0.3.2/scripts/validate.py +129 -0
- randex-0.2.1/scripts/exams.py +0 -138
- randex-0.2.1/scripts/validate.py +0 -107
- {randex-0.2.1 → randex-0.3.2}/LICENSE +0 -0
- {randex-0.2.1 → randex-0.3.2}/randex/__init__.py +0 -0
- {randex-0.2.1 → randex-0.3.2}/scripts/__init__.py +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: randex
|
3
|
-
Version: 0.2
|
3
|
+
Version: 0.3.2
|
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
|
73
|
+
### randex download-examples
|
74
74
|
|
75
|
-
To download the latest examples from GitHub, run the following command
|
76
|
-
folder of the project:
|
75
|
+
To download the latest examples from GitHub, run the following command:
|
77
76
|
|
78
77
|
```sh
|
79
|
-
randex
|
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
|
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 `
|
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 `
|
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
|
-
###
|
105
|
+
### randex batch
|
107
106
|
|
108
107
|
To create a batch of exams with random questions, execute:
|
109
108
|
|
110
109
|
```sh
|
111
|
-
|
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
|
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
|
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
|
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
|
47
|
+
### randex download-examples
|
48
48
|
|
49
|
-
To download the latest examples from GitHub, run the following command
|
50
|
-
folder of the project:
|
49
|
+
To download the latest examples from GitHub, run the following command:
|
51
50
|
|
52
51
|
```sh
|
53
|
-
randex
|
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
|
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 `
|
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 `
|
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
|
-
###
|
79
|
+
### randex batch
|
81
80
|
|
82
81
|
To create a batch of exams with random questions, execute:
|
83
82
|
|
84
83
|
```sh
|
85
|
-
|
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
|
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
|
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
|
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.2
|
56
|
+
version = "0.3.2"
|
57
57
|
|
58
58
|
[tool.poetry.dependencies]
|
59
59
|
cerberus = "^1.3.5"
|
@@ -76,9 +76,7 @@ pytest-cov = "^6.2.1"
|
|
76
76
|
types-pyyaml = "^6.0.12.20250516"
|
77
77
|
|
78
78
|
[tool.poetry.scripts]
|
79
|
-
|
80
|
-
randex-download-examples = "scripts.randex_download_examples:main"
|
81
|
-
validate = "scripts.validate:main"
|
79
|
+
randex = "scripts.randex:cli"
|
82
80
|
|
83
81
|
[tool.poetry.urls]
|
84
82
|
"Homepage" = "https://github.com/arampatzis/randex"
|
@@ -1,8 +1,82 @@
|
|
1
1
|
"""Shared CLI utilities for randex scripts."""
|
2
2
|
|
3
|
+
import logging
|
4
|
+
import sys
|
5
|
+
|
3
6
|
import click
|
4
7
|
|
5
8
|
|
9
|
+
def setup_logging(verbose: int = 0, quiet: bool = False) -> None:
|
10
|
+
"""
|
11
|
+
Set up logging configuration for the CLI.
|
12
|
+
|
13
|
+
Parameters
|
14
|
+
----------
|
15
|
+
verbose : int
|
16
|
+
Verbosity level (0=INFO, 1=DEBUG, 2+ more verbose DEBUG)
|
17
|
+
quiet : bool
|
18
|
+
If True, only show warnings and errors
|
19
|
+
"""
|
20
|
+
if quiet:
|
21
|
+
level = logging.WARNING
|
22
|
+
elif verbose >= 1:
|
23
|
+
level = logging.DEBUG
|
24
|
+
else:
|
25
|
+
level = logging.INFO
|
26
|
+
|
27
|
+
# Create formatter
|
28
|
+
formatter = logging.Formatter(fmt="%(message)s", datefmt="%Y-%m-%d %H:%M:%S")
|
29
|
+
|
30
|
+
# Set up the root logger
|
31
|
+
logger = logging.getLogger()
|
32
|
+
logger.setLevel(level)
|
33
|
+
|
34
|
+
# Remove any existing handlers
|
35
|
+
for handler in logger.handlers[:]:
|
36
|
+
logger.removeHandler(handler)
|
37
|
+
|
38
|
+
# Create console handler
|
39
|
+
handler = logging.StreamHandler(sys.stdout)
|
40
|
+
handler.setLevel(level)
|
41
|
+
handler.setFormatter(formatter)
|
42
|
+
|
43
|
+
logger.addHandler(handler)
|
44
|
+
|
45
|
+
# For verbose mode, also show logger names and levels
|
46
|
+
if verbose >= 2:
|
47
|
+
formatter = logging.Formatter(
|
48
|
+
fmt="[%(levelname)s] %(name)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
|
49
|
+
)
|
50
|
+
handler.setFormatter(formatter)
|
51
|
+
|
52
|
+
|
53
|
+
def get_logger(name: str | None = None) -> logging.Logger:
|
54
|
+
"""
|
55
|
+
Get a logger instance.
|
56
|
+
|
57
|
+
Parameters
|
58
|
+
----------
|
59
|
+
name : str, optional
|
60
|
+
Logger name. If None, uses the calling module name.
|
61
|
+
|
62
|
+
Returns
|
63
|
+
-------
|
64
|
+
logging.Logger
|
65
|
+
Configured logger instance
|
66
|
+
"""
|
67
|
+
if name is None:
|
68
|
+
# Get the calling module name
|
69
|
+
import inspect
|
70
|
+
|
71
|
+
frame = inspect.currentframe()
|
72
|
+
if frame and frame.f_back:
|
73
|
+
name = frame.f_back.f_globals.get("__name__", "randex")
|
74
|
+
else:
|
75
|
+
name = "randex"
|
76
|
+
|
77
|
+
return logging.getLogger(name)
|
78
|
+
|
79
|
+
|
6
80
|
class CustomCommand(click.Command):
|
7
81
|
"""Custom Click command that provides better error messages."""
|
8
82
|
|
@@ -299,6 +299,10 @@ class Pool:
|
|
299
299
|
|
300
300
|
def print_questions(self) -> None:
|
301
301
|
"""Print all questions in the pool grouped by folder."""
|
302
|
+
from randex.cli import get_logger
|
303
|
+
|
304
|
+
logger = get_logger(__name__)
|
305
|
+
|
302
306
|
first = True
|
303
307
|
for folder, question_list in sorted(
|
304
308
|
self.questions.items(),
|
@@ -308,18 +312,19 @@ class Pool:
|
|
308
312
|
continue
|
309
313
|
|
310
314
|
if not first:
|
311
|
-
|
315
|
+
s = "\n" + "-" * 60
|
316
|
+
logger.info(s)
|
312
317
|
first = False
|
313
318
|
|
314
|
-
|
319
|
+
logger.info("\n\U0001f4c1 %s\n", folder)
|
315
320
|
for i, question in enumerate(question_list):
|
316
|
-
|
317
|
-
|
318
|
-
|
321
|
+
logger.info(" \U0001f4c4 Question %d", i + 1)
|
322
|
+
logger.info(" Q: %s", question.question)
|
323
|
+
logger.info(" Answers:")
|
319
324
|
for j, ans in enumerate(question.answers):
|
320
325
|
mark = "✅" if j == question.right_answer else " "
|
321
|
-
|
322
|
-
|
326
|
+
logger.info(" %s %d. %s", mark, j, ans)
|
327
|
+
logger.info("\n")
|
323
328
|
|
324
329
|
|
325
330
|
class QuestionSet(BaseModel):
|
@@ -0,0 +1,99 @@
|
|
1
|
+
"""Script that creates a batch of randomized exams."""
|
2
|
+
|
3
|
+
import shutil
|
4
|
+
import sys
|
5
|
+
from datetime import datetime
|
6
|
+
from pathlib import Path
|
7
|
+
|
8
|
+
import click
|
9
|
+
from pydantic import ValidationError
|
10
|
+
|
11
|
+
from randex.cli import get_logger
|
12
|
+
from randex.exam import ExamBatch, ExamTemplate, Pool, QuestionSet
|
13
|
+
|
14
|
+
logger = get_logger(__name__)
|
15
|
+
|
16
|
+
|
17
|
+
def main(
|
18
|
+
*,
|
19
|
+
folder: str,
|
20
|
+
number_of_questions: list | int,
|
21
|
+
batch_size: int,
|
22
|
+
template_tex_path: Path,
|
23
|
+
out_folder: Path | None,
|
24
|
+
clean: bool,
|
25
|
+
overwrite: bool,
|
26
|
+
) -> None:
|
27
|
+
"""
|
28
|
+
Create a batch of exams with randomly chosen multiple choice questions.
|
29
|
+
|
30
|
+
Parameters
|
31
|
+
----------
|
32
|
+
folder : str
|
33
|
+
Path or quoted glob (e.g. "examples/en/folder_*").
|
34
|
+
number_of_questions : list | int
|
35
|
+
Number of questions to sample.
|
36
|
+
batch_size : int
|
37
|
+
Number of exams to be created.
|
38
|
+
template_tex_path : Path
|
39
|
+
Path to the YAML file that contains the template for the exam configuration.
|
40
|
+
out_folder : Path | None
|
41
|
+
Create the batch exams in this folder (default: tmp_HH-MM-SS).
|
42
|
+
clean : bool
|
43
|
+
Clean the output folder before creating the exams.
|
44
|
+
overwrite : bool
|
45
|
+
Overwrite the output folder if it already exists.
|
46
|
+
"""
|
47
|
+
if out_folder is None:
|
48
|
+
out_folder = Path(f"tmp_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}")
|
49
|
+
|
50
|
+
logger.info("📁 Output folder: %s", out_folder)
|
51
|
+
|
52
|
+
if out_folder.exists():
|
53
|
+
if not overwrite:
|
54
|
+
raise click.UsageError(
|
55
|
+
f"Output folder '{out_folder}' already exists.\n"
|
56
|
+
"Use --overwrite to remove it and continue.",
|
57
|
+
)
|
58
|
+
logger.warning("🗑️ Removing existing output folder: %s", out_folder)
|
59
|
+
shutil.rmtree(out_folder)
|
60
|
+
|
61
|
+
if isinstance(number_of_questions, list | tuple) and len(number_of_questions) == 1:
|
62
|
+
number_of_questions = number_of_questions[0]
|
63
|
+
|
64
|
+
logger.info("📂 Loading questions from: %s", folder)
|
65
|
+
pool = Pool(folder=folder)
|
66
|
+
|
67
|
+
pool.print_questions()
|
68
|
+
questions_set = QuestionSet(questions=pool.questions) # type: ignore[arg-type]
|
69
|
+
questions_set.sample(n=number_of_questions)
|
70
|
+
|
71
|
+
logger.info("📄 Loading exam template from: %s", template_tex_path)
|
72
|
+
exam_template = ExamTemplate.load(template_tex_path)
|
73
|
+
|
74
|
+
try:
|
75
|
+
logger.info("🔄 Creating batch of %d exams...", batch_size)
|
76
|
+
b = ExamBatch(
|
77
|
+
N=batch_size,
|
78
|
+
questions_set=questions_set,
|
79
|
+
exam_template=exam_template,
|
80
|
+
n=number_of_questions,
|
81
|
+
)
|
82
|
+
except ValidationError as e:
|
83
|
+
logger.exception("❌ Validation error while creating exam batch:")
|
84
|
+
logger.exception(e.json(indent=2))
|
85
|
+
sys.exit(1)
|
86
|
+
|
87
|
+
b.make_batch()
|
88
|
+
|
89
|
+
logger.info("🔨 Compiling exams...")
|
90
|
+
b.compile(clean=clean, path=out_folder)
|
91
|
+
|
92
|
+
logger.info("💾 Saving batch configuration to: %s", out_folder / "exams.yaml")
|
93
|
+
b.save(out_folder / "exams.yaml")
|
94
|
+
|
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
|
+
logger.info("✅ Batch creation completed successfully in: %s", out_folder)
|
randex-0.2.1/scripts/randex_download_examples.py → randex-0.3.2/scripts/download_examples.py
RENAMED
@@ -9,49 +9,54 @@ import zipfile
|
|
9
9
|
from pathlib import Path
|
10
10
|
from urllib.error import URLError
|
11
11
|
|
12
|
+
from randex.cli import get_logger
|
13
|
+
|
14
|
+
logger = get_logger(__name__)
|
15
|
+
|
12
16
|
GITHUB_ZIP_URL = "https://github.com/arampatzis/randex/archive/refs/heads/main.zip"
|
13
17
|
DEST_DIR = Path("examples")
|
14
18
|
|
15
19
|
|
16
20
|
def main() -> None:
|
17
21
|
"""Download the latest examples from GitHub."""
|
18
|
-
|
22
|
+
logger.info("📦 Downloading examples from GitHub...")
|
19
23
|
|
20
24
|
if DEST_DIR.exists():
|
21
|
-
|
22
|
-
|
23
|
-
"Please remove it first."
|
25
|
+
logger.error(
|
26
|
+
"❌ Destination folder '%s' already exists. Please remove it first."
|
24
27
|
)
|
25
28
|
sys.exit(1)
|
26
29
|
|
27
30
|
try:
|
31
|
+
logger.debug("🌐 Fetching from: %s", GITHUB_ZIP_URL)
|
28
32
|
with (
|
29
33
|
urllib.request.urlopen(GITHUB_ZIP_URL) as response, # noqa: S310
|
30
34
|
zipfile.ZipFile(io.BytesIO(response.read())) as zip_ref,
|
31
35
|
):
|
32
36
|
temp_dir = Path(tempfile.mkdtemp())
|
37
|
+
logger.debug("📁 Extracting to temporary directory: %s", temp_dir)
|
33
38
|
zip_ref.extractall(temp_dir)
|
34
39
|
|
35
40
|
# Detect the root folder inside the zip (e.g., randex-main)
|
36
41
|
root_entry = next((p for p in temp_dir.iterdir() if p.is_dir()), None)
|
37
42
|
|
38
43
|
if not root_entry:
|
39
|
-
|
44
|
+
logger.error("❌ Could not locate the root folder in the archive.")
|
40
45
|
sys.exit(1)
|
41
46
|
|
42
47
|
examples_path = root_entry / "examples"
|
48
|
+
logger.debug("🔍 Looking for examples in: %s", examples_path)
|
43
49
|
|
44
50
|
if not examples_path.exists():
|
45
|
-
|
51
|
+
logger.error(
|
52
|
+
"❌ examples/ folder not found in the downloaded repository."
|
53
|
+
)
|
46
54
|
sys.exit(1)
|
47
55
|
|
56
|
+
logger.debug("📂 Copying examples from %s to %s", examples_path, DEST_DIR)
|
48
57
|
shutil.copytree(examples_path, DEST_DIR)
|
49
|
-
|
58
|
+
logger.info("✅ Examples downloaded to: %s", DEST_DIR.resolve())
|
50
59
|
|
51
|
-
except (URLError, zipfile.BadZipFile, OSError, shutil.Error)
|
52
|
-
|
60
|
+
except (URLError, zipfile.BadZipFile, OSError, shutil.Error):
|
61
|
+
logger.exception("❌ Error occurred during the download process.")
|
53
62
|
sys.exit(1)
|
54
|
-
|
55
|
-
|
56
|
-
if __name__ == "__main__":
|
57
|
-
main()
|
@@ -0,0 +1,228 @@
|
|
1
|
+
"""Main CLI for randex - Create randomized multiple choice exams using latex."""
|
2
|
+
|
3
|
+
import importlib.metadata
|
4
|
+
from pathlib import Path
|
5
|
+
|
6
|
+
import click
|
7
|
+
|
8
|
+
from randex.cli import CustomCommand, setup_logging
|
9
|
+
from scripts.batch import main as batch_main
|
10
|
+
from scripts.download_examples import main as download_examples_main
|
11
|
+
from scripts.validate import main as validate_main
|
12
|
+
|
13
|
+
try:
|
14
|
+
__version__ = importlib.metadata.version("randex")
|
15
|
+
except importlib.metadata.PackageNotFoundError:
|
16
|
+
__version__ = "0.0.0"
|
17
|
+
|
18
|
+
|
19
|
+
# Main CLI group
|
20
|
+
@click.group()
|
21
|
+
@click.version_option(version=__version__, prog_name="randex")
|
22
|
+
@click.option(
|
23
|
+
"--verbose",
|
24
|
+
"-v",
|
25
|
+
count=True,
|
26
|
+
help="Increase verbosity (use -v for DEBUG, -vv for more detailed DEBUG output)",
|
27
|
+
)
|
28
|
+
@click.option(
|
29
|
+
"--quiet", "-q", is_flag=True, help="Quiet mode - only show warnings and errors"
|
30
|
+
)
|
31
|
+
@click.pass_context
|
32
|
+
def cli(ctx: click.Context, verbose: int, quiet: bool) -> None:
|
33
|
+
"""randex: A CLI tool to create randomized multiple choice exams using latex."""
|
34
|
+
# Set up logging before any command runs
|
35
|
+
setup_logging(verbose=verbose, quiet=quiet)
|
36
|
+
|
37
|
+
# Store logging config in context for subcommands
|
38
|
+
ctx.ensure_object(dict)
|
39
|
+
ctx.obj["verbose"] = verbose
|
40
|
+
ctx.obj["quiet"] = quiet
|
41
|
+
|
42
|
+
|
43
|
+
# Subcommand: download-examples # noqa: ERA001
|
44
|
+
@cli.command(
|
45
|
+
cls=CustomCommand,
|
46
|
+
context_settings={"help_option_names": ["--help"]},
|
47
|
+
)
|
48
|
+
def download_examples() -> None:
|
49
|
+
"""Download the latest examples from GitHub."""
|
50
|
+
download_examples_main()
|
51
|
+
|
52
|
+
|
53
|
+
@cli.command(
|
54
|
+
cls=CustomCommand,
|
55
|
+
context_settings={"help_option_names": ["--help"]},
|
56
|
+
)
|
57
|
+
@click.argument(
|
58
|
+
"folder",
|
59
|
+
type=str,
|
60
|
+
nargs=1,
|
61
|
+
required=True,
|
62
|
+
)
|
63
|
+
@click.option(
|
64
|
+
"--batch-size",
|
65
|
+
"-b",
|
66
|
+
type=int,
|
67
|
+
default=1,
|
68
|
+
help="Number of exams to be created",
|
69
|
+
)
|
70
|
+
@click.option(
|
71
|
+
"--number_of_questions",
|
72
|
+
"-n",
|
73
|
+
type=int,
|
74
|
+
default=[1],
|
75
|
+
multiple=True,
|
76
|
+
help="""
|
77
|
+
Specify how many questions to sample.
|
78
|
+
|
79
|
+
- Use once: sample total number of questions from all folders.
|
80
|
+
Example: -n 10
|
81
|
+
|
82
|
+
- Use multiple times: sample per-folder counts, in order.
|
83
|
+
Example: -n 5 -n 3 # 5 from folder 1, 3 from folder 2
|
84
|
+
""",
|
85
|
+
)
|
86
|
+
@click.option(
|
87
|
+
"--template-tex-path",
|
88
|
+
"-t",
|
89
|
+
type=click.Path(
|
90
|
+
exists=True,
|
91
|
+
resolve_path=True,
|
92
|
+
file_okay=True,
|
93
|
+
dir_okay=False,
|
94
|
+
path_type=Path,
|
95
|
+
),
|
96
|
+
required=True,
|
97
|
+
help="Path to the YAML file that contains the template for the exam configuration",
|
98
|
+
)
|
99
|
+
@click.option(
|
100
|
+
"--out-folder",
|
101
|
+
"-o",
|
102
|
+
type=Path,
|
103
|
+
help="Create the batch exams in this folder (default: tmp_HH-MM-SS)",
|
104
|
+
)
|
105
|
+
@click.option(
|
106
|
+
"--clean",
|
107
|
+
"-c",
|
108
|
+
is_flag=True,
|
109
|
+
default=False,
|
110
|
+
help="Clean all latex compilation auxiliary files",
|
111
|
+
)
|
112
|
+
@click.option(
|
113
|
+
"--overwrite",
|
114
|
+
is_flag=True,
|
115
|
+
default=False,
|
116
|
+
help="Overwrite the out-folder if it already exists (use with caution).",
|
117
|
+
)
|
118
|
+
def batch(
|
119
|
+
folder: str,
|
120
|
+
batch_size: int,
|
121
|
+
number_of_questions: list | int,
|
122
|
+
template_tex_path: Path,
|
123
|
+
out_folder: Path | None,
|
124
|
+
clean: bool,
|
125
|
+
overwrite: bool,
|
126
|
+
) -> None:
|
127
|
+
"""
|
128
|
+
Create a batch of exams with randomly chosen multiple choice questions.
|
129
|
+
|
130
|
+
The questions are loaded from a list of FOLDERS.
|
131
|
+
|
132
|
+
FOLDER: Path or quoted glob (e.g. "data/unit_*").
|
133
|
+
|
134
|
+
The questions are loaded from the FOLDERs and must follow the format:
|
135
|
+
|
136
|
+
question: What is $1+1$?
|
137
|
+
answers: ["0", "1", "2", "3"]
|
138
|
+
right_answer: 2
|
139
|
+
|
140
|
+
💡 Remember to wrap glob patterns in quotes to prevent shell expansion!
|
141
|
+
|
142
|
+
"""
|
143
|
+
batch_main(
|
144
|
+
folder=folder,
|
145
|
+
batch_size=batch_size,
|
146
|
+
number_of_questions=number_of_questions,
|
147
|
+
template_tex_path=template_tex_path,
|
148
|
+
out_folder=out_folder,
|
149
|
+
clean=clean,
|
150
|
+
overwrite=overwrite,
|
151
|
+
)
|
152
|
+
|
153
|
+
|
154
|
+
@cli.command(
|
155
|
+
cls=CustomCommand,
|
156
|
+
context_settings={"help_option_names": ["--help"]},
|
157
|
+
)
|
158
|
+
@click.argument(
|
159
|
+
"folder",
|
160
|
+
type=str,
|
161
|
+
nargs=1,
|
162
|
+
required=True,
|
163
|
+
)
|
164
|
+
@click.option(
|
165
|
+
"--template-tex-path",
|
166
|
+
"-t",
|
167
|
+
type=click.Path(
|
168
|
+
exists=True,
|
169
|
+
resolve_path=True,
|
170
|
+
file_okay=True,
|
171
|
+
dir_okay=False,
|
172
|
+
path_type=Path,
|
173
|
+
),
|
174
|
+
required=True,
|
175
|
+
help="Path to the YAML file that contains the template for the exam configuration",
|
176
|
+
)
|
177
|
+
@click.option(
|
178
|
+
"--out-folder",
|
179
|
+
"-o",
|
180
|
+
type=Path,
|
181
|
+
default=".",
|
182
|
+
help="Run the latex compiler inside this folder",
|
183
|
+
)
|
184
|
+
@click.option(
|
185
|
+
"--clean",
|
186
|
+
"-c",
|
187
|
+
is_flag=True,
|
188
|
+
default=False,
|
189
|
+
help="Clean all latex compilation auxiliary files.",
|
190
|
+
)
|
191
|
+
@click.option(
|
192
|
+
"--show-answers",
|
193
|
+
"-a",
|
194
|
+
is_flag=True,
|
195
|
+
default=False,
|
196
|
+
help="Show the right answers on the pdf",
|
197
|
+
)
|
198
|
+
@click.option(
|
199
|
+
"--overwrite",
|
200
|
+
is_flag=True,
|
201
|
+
default=False,
|
202
|
+
help="Overwrite the out-folder if it already exists (use with caution).",
|
203
|
+
)
|
204
|
+
def validate(
|
205
|
+
folder: str,
|
206
|
+
template_tex_path: Path,
|
207
|
+
out_folder: Path,
|
208
|
+
clean: bool,
|
209
|
+
show_answers: bool,
|
210
|
+
overwrite: bool,
|
211
|
+
) -> None:
|
212
|
+
"""
|
213
|
+
Create a pdf file with all the questions defined in FOLDER.
|
214
|
+
|
215
|
+
The FOLDER is traversed recursively to load all questions.
|
216
|
+
"""
|
217
|
+
validate_main(
|
218
|
+
folder=folder,
|
219
|
+
template_tex_path=template_tex_path,
|
220
|
+
out_folder=out_folder,
|
221
|
+
clean=clean,
|
222
|
+
show_answers=show_answers,
|
223
|
+
overwrite=overwrite,
|
224
|
+
)
|
225
|
+
|
226
|
+
|
227
|
+
if __name__ == "__main__":
|
228
|
+
cli()
|
@@ -0,0 +1,129 @@
|
|
1
|
+
"""Script that validates a single question, or all questions inside a folder."""
|
2
|
+
|
3
|
+
import logging
|
4
|
+
import shutil
|
5
|
+
import subprocess
|
6
|
+
from datetime import datetime
|
7
|
+
from pathlib import Path
|
8
|
+
|
9
|
+
import click
|
10
|
+
|
11
|
+
from randex.cli import get_logger
|
12
|
+
from randex.exam import Exam, ExamTemplate, Pool, QuestionSet
|
13
|
+
|
14
|
+
logger = get_logger(__name__)
|
15
|
+
|
16
|
+
|
17
|
+
def _setup_output_folder(out_folder: Path | str | None, overwrite: bool) -> Path:
|
18
|
+
"""
|
19
|
+
Set up the output folder for validation.
|
20
|
+
|
21
|
+
Parameters
|
22
|
+
----------
|
23
|
+
out_folder : Path | None
|
24
|
+
The output folder path, or None to generate a default.
|
25
|
+
overwrite : bool
|
26
|
+
Whether to overwrite existing folders.
|
27
|
+
|
28
|
+
Returns
|
29
|
+
-------
|
30
|
+
Path
|
31
|
+
The prepared output folder path.
|
32
|
+
"""
|
33
|
+
if out_folder is None:
|
34
|
+
out_folder = Path(f"tmp_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}")
|
35
|
+
|
36
|
+
out_folder = Path(out_folder)
|
37
|
+
|
38
|
+
logger.info("📁 Output folder: %s", out_folder)
|
39
|
+
|
40
|
+
if out_folder.exists():
|
41
|
+
if not overwrite:
|
42
|
+
raise click.UsageError(
|
43
|
+
f"Output folder '{out_folder}' already exists.\n"
|
44
|
+
"Use --overwrite to remove it and continue.",
|
45
|
+
)
|
46
|
+
logger.warning("🗑️ Removing existing output folder: %s", out_folder)
|
47
|
+
shutil.rmtree(out_folder)
|
48
|
+
|
49
|
+
return out_folder
|
50
|
+
|
51
|
+
|
52
|
+
def _handle_compilation_result(
|
53
|
+
result: subprocess.CompletedProcess,
|
54
|
+
out_folder: Path | str,
|
55
|
+
) -> None:
|
56
|
+
"""
|
57
|
+
Handle and log the compilation results.
|
58
|
+
|
59
|
+
Parameters
|
60
|
+
----------
|
61
|
+
result : subprocess.CompletedProcess
|
62
|
+
The compilation result from LaTeX.
|
63
|
+
out_folder : Path
|
64
|
+
The output folder path for success messages.
|
65
|
+
"""
|
66
|
+
if result.stdout:
|
67
|
+
logger.debug("LaTeX compilation STDOUT:")
|
68
|
+
for line in result.stdout.splitlines():
|
69
|
+
logger.debug(" %s", line)
|
70
|
+
|
71
|
+
if result.stderr:
|
72
|
+
if result.returncode != 0:
|
73
|
+
logger.error("LaTeX compilation STDERR:")
|
74
|
+
for line in result.stderr.splitlines():
|
75
|
+
logger.error(" %s", line)
|
76
|
+
else:
|
77
|
+
logger.debug("LaTeX compilation STDERR:")
|
78
|
+
for line in result.stderr.splitlines():
|
79
|
+
logger.debug(" %s", line)
|
80
|
+
|
81
|
+
if result.returncode == 0:
|
82
|
+
logger.info("✅ Validation completed successfully in: %s", out_folder)
|
83
|
+
else:
|
84
|
+
logger.error(
|
85
|
+
"❌ LaTeX compilation failed with return code: %d", result.returncode
|
86
|
+
)
|
87
|
+
|
88
|
+
|
89
|
+
def main(
|
90
|
+
*,
|
91
|
+
folder: Path | str,
|
92
|
+
template_tex_path: Path | str,
|
93
|
+
out_folder: Path | str | None,
|
94
|
+
clean: bool,
|
95
|
+
show_answers: bool,
|
96
|
+
overwrite: bool,
|
97
|
+
) -> None:
|
98
|
+
"""
|
99
|
+
Create a pdf file with all the questions defined in FOLDER.
|
100
|
+
|
101
|
+
The FOLDER is traversed recursively.
|
102
|
+
"""
|
103
|
+
out_folder = _setup_output_folder(out_folder, overwrite)
|
104
|
+
|
105
|
+
logger.info("📂 Loading questions from: %s", folder)
|
106
|
+
pool = Pool(folder=folder)
|
107
|
+
|
108
|
+
if logger.isEnabledFor(logging.DEBUG):
|
109
|
+
pool.print_questions()
|
110
|
+
|
111
|
+
questions_set = QuestionSet(questions=pool.questions) # type: ignore[arg-type]
|
112
|
+
number_of_questions = questions_set.size()
|
113
|
+
questions = questions_set.sample(n=number_of_questions)
|
114
|
+
|
115
|
+
logger.info("📄 Loading exam template from: %s", template_tex_path)
|
116
|
+
exam_template = ExamTemplate.load(Path(template_tex_path))
|
117
|
+
|
118
|
+
logger.info("📝 Creating validation exam with %d questions", number_of_questions)
|
119
|
+
exam = Exam(
|
120
|
+
exam_template=exam_template,
|
121
|
+
questions=questions,
|
122
|
+
show_answers=show_answers,
|
123
|
+
)
|
124
|
+
exam.apply_shuffling(shuffle_questions=True, shuffle_answers=True)
|
125
|
+
|
126
|
+
logger.info("🔨 Compiling exam...")
|
127
|
+
result = exam.compile(path=out_folder, clean=clean)
|
128
|
+
|
129
|
+
_handle_compilation_result(result, out_folder)
|
randex-0.2.1/scripts/exams.py
DELETED
@@ -1,138 +0,0 @@
|
|
1
|
-
"""Script that creates a batch of randomized exams."""
|
2
|
-
|
3
|
-
import shutil
|
4
|
-
from datetime import datetime
|
5
|
-
from pathlib import Path
|
6
|
-
|
7
|
-
import click
|
8
|
-
|
9
|
-
from randex.cli import CustomCommand
|
10
|
-
from randex.exam import ExamBatch, ExamTemplate, Pool, QuestionSet
|
11
|
-
|
12
|
-
|
13
|
-
@click.command(
|
14
|
-
cls=CustomCommand,
|
15
|
-
context_settings={"help_option_names": ["--help"]},
|
16
|
-
)
|
17
|
-
@click.argument(
|
18
|
-
"folder",
|
19
|
-
type=str,
|
20
|
-
nargs=1,
|
21
|
-
required=True,
|
22
|
-
)
|
23
|
-
@click.option(
|
24
|
-
"--batch-size",
|
25
|
-
"-b",
|
26
|
-
type=int,
|
27
|
-
default=1,
|
28
|
-
help="Number of exams to be created",
|
29
|
-
)
|
30
|
-
@click.option(
|
31
|
-
"--number_of_questions",
|
32
|
-
"-n",
|
33
|
-
type=int,
|
34
|
-
default=[1],
|
35
|
-
multiple=True,
|
36
|
-
help="""
|
37
|
-
Specify how many questions to sample.
|
38
|
-
|
39
|
-
- Use once: sample total number of questions from all folders.
|
40
|
-
Example: -n 10
|
41
|
-
|
42
|
-
- Use multiple times: sample per-folder counts, in order.
|
43
|
-
Example: -n 5 -n 3 # 5 from folder 1, 3 from folder 2
|
44
|
-
""",
|
45
|
-
)
|
46
|
-
@click.option(
|
47
|
-
"--template-tex-path",
|
48
|
-
"-t",
|
49
|
-
type=click.Path(
|
50
|
-
exists=True,
|
51
|
-
resolve_path=True,
|
52
|
-
file_okay=True,
|
53
|
-
dir_okay=False,
|
54
|
-
path_type=Path,
|
55
|
-
),
|
56
|
-
required=True,
|
57
|
-
help="Path to the YAML file that contains the template for the exam configuration",
|
58
|
-
)
|
59
|
-
@click.option(
|
60
|
-
"--out-folder",
|
61
|
-
"-o",
|
62
|
-
type=Path,
|
63
|
-
help="Create the batch exams in this folder (default: tmp_HH-MM-SS)",
|
64
|
-
)
|
65
|
-
@click.option(
|
66
|
-
"--clean",
|
67
|
-
"-c",
|
68
|
-
is_flag=True,
|
69
|
-
default=False,
|
70
|
-
help="Clean all latex compilation auxiliary files",
|
71
|
-
)
|
72
|
-
@click.option(
|
73
|
-
"--overwrite",
|
74
|
-
is_flag=True,
|
75
|
-
default=False,
|
76
|
-
help="Overwrite the out-folder if it already exists (use with caution).",
|
77
|
-
)
|
78
|
-
def main(
|
79
|
-
folder: str,
|
80
|
-
number_of_questions: list | int,
|
81
|
-
batch_size: int,
|
82
|
-
template_tex_path: Path,
|
83
|
-
out_folder: Path | None,
|
84
|
-
clean: bool,
|
85
|
-
overwrite: bool,
|
86
|
-
) -> None:
|
87
|
-
"""
|
88
|
-
Create a batch of exams with randomly chosen multiple choice questions.
|
89
|
-
|
90
|
-
The questions are loaded from a list of FOLDERS.
|
91
|
-
|
92
|
-
FOLDER: Path or quoted glob (e.g. "data/unit_*").
|
93
|
-
|
94
|
-
The questions are loaded from the FOLDERs and must follow the format:
|
95
|
-
|
96
|
-
question: What is $1+1$?
|
97
|
-
answers: ["0", "1", "2", "3"]
|
98
|
-
right_answer: 2
|
99
|
-
|
100
|
-
💡 Remember to wrap glob patterns in quotes to prevent shell expansion!
|
101
|
-
"""
|
102
|
-
if out_folder is None:
|
103
|
-
out_folder = Path(f"tmp_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}")
|
104
|
-
|
105
|
-
if out_folder.exists():
|
106
|
-
if not overwrite:
|
107
|
-
raise click.UsageError(
|
108
|
-
f"Output folder '{out_folder}' already exists.\n"
|
109
|
-
"Use --overwrite to remove it and continue.",
|
110
|
-
)
|
111
|
-
shutil.rmtree(out_folder)
|
112
|
-
|
113
|
-
if isinstance(number_of_questions, list | tuple) and len(number_of_questions) == 1:
|
114
|
-
number_of_questions = number_of_questions[0]
|
115
|
-
|
116
|
-
pool = Pool(folder=folder)
|
117
|
-
|
118
|
-
pool.print_questions()
|
119
|
-
questions_set = QuestionSet(questions=pool.questions) # type: ignore[arg-type]
|
120
|
-
questions_set.sample(n=number_of_questions)
|
121
|
-
exam_template = ExamTemplate.load(template_tex_path)
|
122
|
-
|
123
|
-
b = ExamBatch(
|
124
|
-
N=batch_size,
|
125
|
-
questions_set=questions_set,
|
126
|
-
exam_template=exam_template,
|
127
|
-
n=number_of_questions,
|
128
|
-
)
|
129
|
-
|
130
|
-
b.make_batch()
|
131
|
-
|
132
|
-
b.compile(clean=clean, path=out_folder)
|
133
|
-
|
134
|
-
b.save(out_folder / "exams.yaml")
|
135
|
-
|
136
|
-
b = ExamBatch.load(out_folder / "exams.yaml")
|
137
|
-
|
138
|
-
b.compile(clean=clean, path=out_folder)
|
randex-0.2.1/scripts/validate.py
DELETED
@@ -1,107 +0,0 @@
|
|
1
|
-
"""Script that validates a single question, or all questions inside a folder."""
|
2
|
-
|
3
|
-
import shutil
|
4
|
-
from datetime import datetime
|
5
|
-
from pathlib import Path
|
6
|
-
|
7
|
-
import click
|
8
|
-
|
9
|
-
from randex.cli import CustomCommand
|
10
|
-
from randex.exam import Exam, ExamTemplate, Pool, QuestionSet
|
11
|
-
|
12
|
-
|
13
|
-
@click.command(
|
14
|
-
cls=CustomCommand,
|
15
|
-
context_settings={"help_option_names": ["--help"]},
|
16
|
-
)
|
17
|
-
@click.argument(
|
18
|
-
"folder",
|
19
|
-
type=str,
|
20
|
-
nargs=1,
|
21
|
-
required=True,
|
22
|
-
)
|
23
|
-
@click.option(
|
24
|
-
"--template-tex-path",
|
25
|
-
"-t",
|
26
|
-
type=click.Path(
|
27
|
-
exists=True,
|
28
|
-
resolve_path=True,
|
29
|
-
file_okay=True,
|
30
|
-
dir_okay=False,
|
31
|
-
path_type=Path,
|
32
|
-
),
|
33
|
-
required=True,
|
34
|
-
help="Path to the YAML file that contains the template for the exam configuration",
|
35
|
-
)
|
36
|
-
@click.option(
|
37
|
-
"--out-folder",
|
38
|
-
"-o",
|
39
|
-
type=Path,
|
40
|
-
default=".",
|
41
|
-
help="Run the latex compiler inside this folder",
|
42
|
-
)
|
43
|
-
@click.option(
|
44
|
-
"--clean",
|
45
|
-
"-c",
|
46
|
-
is_flag=True,
|
47
|
-
default=False,
|
48
|
-
help="Clean all latex compilation auxiliary files.",
|
49
|
-
)
|
50
|
-
@click.option(
|
51
|
-
"--show-answers",
|
52
|
-
"-a",
|
53
|
-
is_flag=True,
|
54
|
-
default=False,
|
55
|
-
help="Show the right answers on the pdf",
|
56
|
-
)
|
57
|
-
@click.option(
|
58
|
-
"--overwrite",
|
59
|
-
is_flag=True,
|
60
|
-
default=False,
|
61
|
-
help="Overwrite the out-folder if it already exists (use with caution).",
|
62
|
-
)
|
63
|
-
def main(
|
64
|
-
folder: Path,
|
65
|
-
template_tex_path: Path,
|
66
|
-
out_folder: Path,
|
67
|
-
clean: bool,
|
68
|
-
show_answers: bool,
|
69
|
-
overwrite: bool,
|
70
|
-
) -> None:
|
71
|
-
"""
|
72
|
-
Create a pdf file with all the questions defined in FOLDER.
|
73
|
-
|
74
|
-
The FOLDER is traversed recursively.
|
75
|
-
"""
|
76
|
-
if out_folder is None:
|
77
|
-
out_folder = Path(f"tmp_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}")
|
78
|
-
|
79
|
-
if out_folder.exists():
|
80
|
-
if not overwrite:
|
81
|
-
raise click.UsageError(
|
82
|
-
f"Output folder '{out_folder}' already exists.\n"
|
83
|
-
"Use --overwrite to remove it and continue.",
|
84
|
-
)
|
85
|
-
shutil.rmtree(out_folder)
|
86
|
-
|
87
|
-
pool = Pool(folder=folder)
|
88
|
-
|
89
|
-
pool.print_questions()
|
90
|
-
questions_set = QuestionSet(questions=pool.questions) # type: ignore[arg-type]
|
91
|
-
number_of_questions = questions_set.size()
|
92
|
-
questions = questions_set.sample(n=number_of_questions)
|
93
|
-
exam_template = ExamTemplate.load(template_tex_path)
|
94
|
-
|
95
|
-
exam = Exam(
|
96
|
-
exam_template=exam_template,
|
97
|
-
questions=questions,
|
98
|
-
show_answers=show_answers,
|
99
|
-
)
|
100
|
-
exam.apply_shuffling(shuffle_questions=True, shuffle_answers=True)
|
101
|
-
|
102
|
-
result = exam.compile(path=out_folder, clean=clean)
|
103
|
-
|
104
|
-
print("STDOUT:")
|
105
|
-
print("\n\t".join(result.stdout.splitlines()))
|
106
|
-
print("STDERR:")
|
107
|
-
print("\n\t".join(result.stderr.splitlines()))
|
File without changes
|
File without changes
|
File without changes
|