randex 0.2.0__py3-none-any.whl → 0.3.1__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/cli.py +130 -0
- randex/exam.py +20 -9
- {randex-0.2.0.dist-info → randex-0.3.1.dist-info}/METADATA +1 -1
- randex-0.3.1.dist-info/RECORD +13 -0
- randex-0.3.1.dist-info/entry_points.txt +3 -0
- scripts/batch.py +99 -0
- scripts/{randex_download_examples.py → download_examples.py} +18 -13
- scripts/randex.py +228 -0
- scripts/validate.py +89 -65
- randex-0.2.0.dist-info/RECORD +0 -11
- randex-0.2.0.dist-info/entry_points.txt +0 -5
- scripts/exams.py +0 -136
- {randex-0.2.0.dist-info → randex-0.3.1.dist-info}/LICENSE +0 -0
- {randex-0.2.0.dist-info → randex-0.3.1.dist-info}/WHEEL +0 -0
randex/cli.py
ADDED
@@ -0,0 +1,130 @@
|
|
1
|
+
"""Shared CLI utilities for randex scripts."""
|
2
|
+
|
3
|
+
import logging
|
4
|
+
import sys
|
5
|
+
|
6
|
+
import click
|
7
|
+
|
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
|
+
|
80
|
+
class CustomCommand(click.Command):
|
81
|
+
"""Custom Click command that provides better error messages."""
|
82
|
+
|
83
|
+
def parse_args(
|
84
|
+
self,
|
85
|
+
ctx: click.Context,
|
86
|
+
args: list[str],
|
87
|
+
) -> list[str]:
|
88
|
+
"""
|
89
|
+
Override parse_args to catch parameter parsing errors.
|
90
|
+
|
91
|
+
This is a workaround to catch the error when the user passes multiple
|
92
|
+
folder arguments to the command.
|
93
|
+
|
94
|
+
Parameters
|
95
|
+
----------
|
96
|
+
ctx : click.Context
|
97
|
+
The click context.
|
98
|
+
args : list[str]
|
99
|
+
The arguments passed to the command.
|
100
|
+
|
101
|
+
Returns
|
102
|
+
-------
|
103
|
+
list[str]:
|
104
|
+
The remaining unparsed arguments.
|
105
|
+
|
106
|
+
Examples
|
107
|
+
--------
|
108
|
+
```bash
|
109
|
+
exams examples/en/folder_* -t template.yaml -n 2
|
110
|
+
```
|
111
|
+
"""
|
112
|
+
try:
|
113
|
+
return super().parse_args(ctx, args)
|
114
|
+
except click.UsageError as e:
|
115
|
+
if "Got unexpected extra arguments" in str(e):
|
116
|
+
# Extract the extra arguments from the error message
|
117
|
+
error_msg = str(e)
|
118
|
+
if "(" in error_msg and ")" in error_msg:
|
119
|
+
extra_args = error_msg.split("(")[1].split(")")[0]
|
120
|
+
|
121
|
+
raise click.UsageError(
|
122
|
+
f"❌ Multiple folder arguments detected: {extra_args}\n\n"
|
123
|
+
f"💡 This usually happens when your shell expands a glob pattern like 'examples/en/folder_*'\n" # noqa: E501
|
124
|
+
f" into multiple folder names before passing them to the command.\n\n" # noqa: E501
|
125
|
+
f"🔧 Solutions:\n"
|
126
|
+
f' • Put quotes around your glob pattern: "examples/en/folder_*"\n' # noqa: E501
|
127
|
+
f" • Or specify a single folder path instead of a glob pattern\n\n" # noqa: E501
|
128
|
+
f'Example: exams "examples/en/folder_*" -t template.yaml -n 2'
|
129
|
+
) from e
|
130
|
+
raise
|
randex/exam.py
CHANGED
@@ -202,8 +202,14 @@ class Pool:
|
|
202
202
|
matched = [p.resolve() for p in Path().glob(folder_input) if p.is_dir()]
|
203
203
|
if not matched:
|
204
204
|
raise ValueError(
|
205
|
-
f"
|
206
|
-
"
|
205
|
+
f"❌ No folders found matching the pattern: '{folder_input}'\n\n"
|
206
|
+
f"💡 Suggestions:\n"
|
207
|
+
f" • Check if the path exists and contains folders\n"
|
208
|
+
f" • Verify the spelling (common mistake: 'example' vs 'examples')\n" # noqa: E501
|
209
|
+
f" • Use quotes around the pattern to prevent shell expansion\n"
|
210
|
+
f" • Try listing the directory to see available folders\n\n"
|
211
|
+
f"🔍 If '{folder_input}' is meant to be a literal folder name (not a pattern), " # noqa: E501
|
212
|
+
f"remove the special characters or use a Path object instead."
|
207
213
|
)
|
208
214
|
return matched
|
209
215
|
|
@@ -293,6 +299,10 @@ class Pool:
|
|
293
299
|
|
294
300
|
def print_questions(self) -> None:
|
295
301
|
"""Print all questions in the pool grouped by folder."""
|
302
|
+
from randex.cli import get_logger
|
303
|
+
|
304
|
+
logger = get_logger(__name__)
|
305
|
+
|
296
306
|
first = True
|
297
307
|
for folder, question_list in sorted(
|
298
308
|
self.questions.items(),
|
@@ -302,18 +312,19 @@ class Pool:
|
|
302
312
|
continue
|
303
313
|
|
304
314
|
if not first:
|
305
|
-
|
315
|
+
s = "\n" + "-" * 60
|
316
|
+
logger.info(s)
|
306
317
|
first = False
|
307
318
|
|
308
|
-
|
319
|
+
logger.info("\n\U0001f4c1 %s\n", folder)
|
309
320
|
for i, question in enumerate(question_list):
|
310
|
-
|
311
|
-
|
312
|
-
|
321
|
+
logger.info(" \U0001f4c4 Question %d", i + 1)
|
322
|
+
logger.info(" Q: %s", question.question)
|
323
|
+
logger.info(" Answers:")
|
313
324
|
for j, ans in enumerate(question.answers):
|
314
325
|
mark = "✅" if j == question.right_answer else " "
|
315
|
-
|
316
|
-
|
326
|
+
logger.info(" %s %d. %s", mark, j, ans)
|
327
|
+
logger.info("\n")
|
317
328
|
|
318
329
|
|
319
330
|
class QuestionSet(BaseModel):
|
@@ -0,0 +1,13 @@
|
|
1
|
+
randex/__init__.py,sha256=6bBAISO6MqVCSCvqLcK3SGUnDODuwobLWouO3GY8SRU,187
|
2
|
+
randex/cli.py,sha256=TGB8XqfNajWfKju9rhUctQM3M_CbyjrZ2LGDlY3Bw7M,3851
|
3
|
+
randex/exam.py,sha256=4XAwz9Vv4ej0iza2bxzirLVnHgHpHUQhKTGJgxi_1as,27129
|
4
|
+
scripts/__init__.py,sha256=F8pjiW8AlL0IhnvVh1TEfgiD--YdFE_PggtSevHp5y4,38
|
5
|
+
scripts/batch.py,sha256=ceRZM4qf6_gVdVWKktsgmGp65iyM1jxorjEpOGdXrRQ,3159
|
6
|
+
scripts/download_examples.py,sha256=wpe_d7QR1iHhPOL5LBawBb1Feu4DnScyGiaMTMAyqRQ,2103
|
7
|
+
scripts/randex.py,sha256=92bbt6ovheWKsbeHwUjKZeK_jv35pFs4a6AygLrbilA,5383
|
8
|
+
scripts/validate.py,sha256=ojlcme_6-H1XcxUgN-2VgP1k_kfPZSIaILPqD7L0tCw,3738
|
9
|
+
randex-0.3.1.dist-info/LICENSE,sha256=NxH5Y8BdC-gNU-WSMwim3uMbID2iNDXJz7fHtuTdXhk,19346
|
10
|
+
randex-0.3.1.dist-info/METADATA,sha256=PPLhrjeTPtFIDTWx0twoZM_EeNm91qajHJQe1-gSVGM,6551
|
11
|
+
randex-0.3.1.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
12
|
+
randex-0.3.1.dist-info/entry_points.txt,sha256=TFWMzZGZpVk-j2F7gPjrZJT0wEJ2J9P7GrKOOv9n5NM,45
|
13
|
+
randex-0.3.1.dist-info/RECORD,,
|
scripts/batch.py
ADDED
@@ -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)
|
@@ -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()
|
scripts/randex.py
ADDED
@@ -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()
|
scripts/validate.py
CHANGED
@@ -1,95 +1,121 @@
|
|
1
1
|
"""Script that validates a single question, or all questions inside a folder."""
|
2
2
|
|
3
|
+
import logging
|
3
4
|
import shutil
|
5
|
+
import subprocess
|
4
6
|
from datetime import datetime
|
5
7
|
from pathlib import Path
|
6
8
|
|
7
9
|
import click
|
8
10
|
|
11
|
+
from randex.cli import get_logger
|
9
12
|
from randex.exam import Exam, ExamTemplate, Pool, QuestionSet
|
10
13
|
|
14
|
+
logger = get_logger(__name__)
|
11
15
|
|
12
|
-
|
13
|
-
|
14
|
-
)
|
15
|
-
@click.argument(
|
16
|
-
"folder",
|
17
|
-
type=str,
|
18
|
-
nargs=1,
|
19
|
-
required=True,
|
20
|
-
)
|
21
|
-
@click.option(
|
22
|
-
"--template-tex-path",
|
23
|
-
"-t",
|
24
|
-
type=click.Path(
|
25
|
-
exists=True,
|
26
|
-
resolve_path=True,
|
27
|
-
file_okay=True,
|
28
|
-
dir_okay=False,
|
29
|
-
path_type=Path,
|
30
|
-
),
|
31
|
-
required=True,
|
32
|
-
help="Path to the YAML file that contains the template for the exam configuration",
|
33
|
-
)
|
34
|
-
@click.option(
|
35
|
-
"--out-folder",
|
36
|
-
"-o",
|
37
|
-
type=Path,
|
38
|
-
default=".",
|
39
|
-
help="Run the latex compiler inside this folder",
|
40
|
-
)
|
41
|
-
@click.option(
|
42
|
-
"--clean",
|
43
|
-
"-c",
|
44
|
-
is_flag=True,
|
45
|
-
default=False,
|
46
|
-
help="Clean all latex compilation auxiliary files.",
|
47
|
-
)
|
48
|
-
@click.option(
|
49
|
-
"--show-answers",
|
50
|
-
"-a",
|
51
|
-
is_flag=True,
|
52
|
-
default=False,
|
53
|
-
help="Show the right answers on the pdf",
|
54
|
-
)
|
55
|
-
@click.option(
|
56
|
-
"--overwrite",
|
57
|
-
is_flag=True,
|
58
|
-
default=False,
|
59
|
-
help="Overwrite the out-folder if it already exists (use with caution).",
|
60
|
-
)
|
61
|
-
def main(
|
62
|
-
folder: Path,
|
63
|
-
template_tex_path: Path,
|
64
|
-
out_folder: Path,
|
65
|
-
clean: bool,
|
66
|
-
show_answers: bool,
|
67
|
-
overwrite: bool,
|
68
|
-
) -> None:
|
16
|
+
|
17
|
+
def _setup_output_folder(out_folder: Path | str | None, overwrite: bool) -> Path:
|
69
18
|
"""
|
70
|
-
|
19
|
+
Set up the output folder for validation.
|
71
20
|
|
72
|
-
|
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.
|
73
32
|
"""
|
74
33
|
if out_folder is None:
|
75
34
|
out_folder = Path(f"tmp_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}")
|
76
35
|
|
36
|
+
out_folder = Path(out_folder)
|
37
|
+
|
38
|
+
logger.info("📁 Output folder: %s", out_folder)
|
39
|
+
|
77
40
|
if out_folder.exists():
|
78
41
|
if not overwrite:
|
79
42
|
raise click.UsageError(
|
80
43
|
f"Output folder '{out_folder}' already exists.\n"
|
81
44
|
"Use --overwrite to remove it and continue.",
|
82
45
|
)
|
46
|
+
logger.warning("🗑️ Removing existing output folder: %s", out_folder)
|
83
47
|
shutil.rmtree(out_folder)
|
84
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)
|
85
106
|
pool = Pool(folder=folder)
|
86
107
|
|
87
|
-
|
108
|
+
if logger.isEnabledFor(logging.DEBUG):
|
109
|
+
pool.print_questions()
|
110
|
+
|
88
111
|
questions_set = QuestionSet(questions=pool.questions) # type: ignore[arg-type]
|
89
112
|
number_of_questions = questions_set.size()
|
90
113
|
questions = questions_set.sample(n=number_of_questions)
|
91
|
-
exam_template = ExamTemplate.load(template_tex_path)
|
92
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)
|
93
119
|
exam = Exam(
|
94
120
|
exam_template=exam_template,
|
95
121
|
questions=questions,
|
@@ -97,9 +123,7 @@ def main(
|
|
97
123
|
)
|
98
124
|
exam.apply_shuffling(shuffle_questions=True, shuffle_answers=True)
|
99
125
|
|
126
|
+
logger.info("🔨 Compiling exam...")
|
100
127
|
result = exam.compile(path=out_folder, clean=clean)
|
101
128
|
|
102
|
-
|
103
|
-
print("\n\t".join(result.stdout.splitlines()))
|
104
|
-
print("STDERR:")
|
105
|
-
print("\n\t".join(result.stderr.splitlines()))
|
129
|
+
_handle_compilation_result(result, out_folder)
|
randex-0.2.0.dist-info/RECORD
DELETED
@@ -1,11 +0,0 @@
|
|
1
|
-
randex/__init__.py,sha256=6bBAISO6MqVCSCvqLcK3SGUnDODuwobLWouO3GY8SRU,187
|
2
|
-
randex/exam.py,sha256=l-_6GnrstLgVMORXNEIKzgW9k6h_YbXndZd8YmEGKBI,26450
|
3
|
-
scripts/__init__.py,sha256=F8pjiW8AlL0IhnvVh1TEfgiD--YdFE_PggtSevHp5y4,38
|
4
|
-
scripts/exams.py,sha256=L4QSOZpU8Hw5QkW_2x5s8NESQAwlEmZTSaT6ZZ8-c04,3341
|
5
|
-
scripts/randex_download_examples.py,sha256=fj88XkZV8bpHPCeXu1f3nVNws_hbcJ_3Zso7XOPB0RQ,1682
|
6
|
-
scripts/validate.py,sha256=xruN1P1rgJnc0MTklLgSrsx9TcFC2SY1a9eksNT_-KM,2599
|
7
|
-
randex-0.2.0.dist-info/LICENSE,sha256=NxH5Y8BdC-gNU-WSMwim3uMbID2iNDXJz7fHtuTdXhk,19346
|
8
|
-
randex-0.2.0.dist-info/METADATA,sha256=1KJfVvoQSFmykT68kvqLkq2YAiu6AxC5hdUSY15Fks0,6551
|
9
|
-
randex-0.2.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
10
|
-
randex-0.2.0.dist-info/entry_points.txt,sha256=RayNUArGNT0MLZi7uh1MHjnd1L1V4X6C4sBdMLbvAfc,138
|
11
|
-
randex-0.2.0.dist-info/RECORD,,
|
scripts/exams.py
DELETED
@@ -1,136 +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.exam import ExamBatch, ExamTemplate, Pool, QuestionSet
|
10
|
-
|
11
|
-
|
12
|
-
@click.command(
|
13
|
-
context_settings={"help_option_names": ["--help"]},
|
14
|
-
)
|
15
|
-
@click.argument(
|
16
|
-
"folder",
|
17
|
-
type=str,
|
18
|
-
nargs=1,
|
19
|
-
required=True,
|
20
|
-
)
|
21
|
-
@click.option(
|
22
|
-
"--batch-size",
|
23
|
-
"-b",
|
24
|
-
type=int,
|
25
|
-
default=1,
|
26
|
-
help="Number of exams to be created",
|
27
|
-
)
|
28
|
-
@click.option(
|
29
|
-
"--number_of_questions",
|
30
|
-
"-n",
|
31
|
-
type=int,
|
32
|
-
default=[1],
|
33
|
-
multiple=True,
|
34
|
-
help="""
|
35
|
-
Specify how many questions to sample.
|
36
|
-
|
37
|
-
- Use once: sample total number of questions from all folders.
|
38
|
-
Example: -n 10
|
39
|
-
|
40
|
-
- Use multiple times: sample per-folder counts, in order.
|
41
|
-
Example: -n 5 -n 3 # 5 from folder 1, 3 from folder 2
|
42
|
-
""",
|
43
|
-
)
|
44
|
-
@click.option(
|
45
|
-
"--template-tex-path",
|
46
|
-
"-t",
|
47
|
-
type=click.Path(
|
48
|
-
exists=True,
|
49
|
-
resolve_path=True,
|
50
|
-
file_okay=True,
|
51
|
-
dir_okay=False,
|
52
|
-
path_type=Path,
|
53
|
-
),
|
54
|
-
required=True,
|
55
|
-
help="Path to the YAML file that contains the template for the exam configuration",
|
56
|
-
)
|
57
|
-
@click.option(
|
58
|
-
"--out-folder",
|
59
|
-
"-o",
|
60
|
-
type=Path,
|
61
|
-
help="Create the batch exams in this folder (default: tmp_HH-MM-SS)",
|
62
|
-
)
|
63
|
-
@click.option(
|
64
|
-
"--clean",
|
65
|
-
"-c",
|
66
|
-
is_flag=True,
|
67
|
-
default=False,
|
68
|
-
help="Clean all latex compilation auxiliary files",
|
69
|
-
)
|
70
|
-
@click.option(
|
71
|
-
"--overwrite",
|
72
|
-
is_flag=True,
|
73
|
-
default=False,
|
74
|
-
help="Overwrite the out-folder if it already exists (use with caution).",
|
75
|
-
)
|
76
|
-
def main(
|
77
|
-
folder: str,
|
78
|
-
number_of_questions: list | int,
|
79
|
-
batch_size: int,
|
80
|
-
template_tex_path: Path,
|
81
|
-
out_folder: Path | None,
|
82
|
-
clean: bool,
|
83
|
-
overwrite: bool,
|
84
|
-
) -> None:
|
85
|
-
"""
|
86
|
-
Create a batch of exams with randomly chosen multiple choice questions.
|
87
|
-
|
88
|
-
The questions are loaded from a list of FOLDERS.
|
89
|
-
|
90
|
-
FOLDER: Path or quoted glob (e.g. "data/unit_*").
|
91
|
-
|
92
|
-
The questions are loaded from the FOLDERs and must follow the format:
|
93
|
-
|
94
|
-
question: What is $1+1$?
|
95
|
-
answers: ["0", "1", "2", "3"]
|
96
|
-
right_answer: 2
|
97
|
-
|
98
|
-
💡 Remember to wrap glob patterns in quotes to prevent shell expansion!
|
99
|
-
"""
|
100
|
-
if out_folder is None:
|
101
|
-
out_folder = Path(f"tmp_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}")
|
102
|
-
|
103
|
-
if out_folder.exists():
|
104
|
-
if not overwrite:
|
105
|
-
raise click.UsageError(
|
106
|
-
f"Output folder '{out_folder}' already exists.\n"
|
107
|
-
"Use --overwrite to remove it and continue.",
|
108
|
-
)
|
109
|
-
shutil.rmtree(out_folder)
|
110
|
-
|
111
|
-
if isinstance(number_of_questions, list | tuple) and len(number_of_questions) == 1:
|
112
|
-
number_of_questions = number_of_questions[0]
|
113
|
-
|
114
|
-
pool = Pool(folder=folder)
|
115
|
-
|
116
|
-
pool.print_questions()
|
117
|
-
questions_set = QuestionSet(questions=pool.questions) # type: ignore[arg-type]
|
118
|
-
questions_set.sample(n=number_of_questions)
|
119
|
-
exam_template = ExamTemplate.load(template_tex_path)
|
120
|
-
|
121
|
-
b = ExamBatch(
|
122
|
-
N=batch_size,
|
123
|
-
questions_set=questions_set,
|
124
|
-
exam_template=exam_template,
|
125
|
-
n=number_of_questions,
|
126
|
-
)
|
127
|
-
|
128
|
-
b.make_batch()
|
129
|
-
|
130
|
-
b.compile(clean=clean, path=out_folder)
|
131
|
-
|
132
|
-
b.save(out_folder / "exams.yaml")
|
133
|
-
|
134
|
-
b = ExamBatch.load(out_folder / "exams.yaml")
|
135
|
-
|
136
|
-
b.compile(clean=clean, path=out_folder)
|
File without changes
|
File without changes
|