mcqpy 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mcqpy/__init__.py +2 -0
- mcqpy/cli/__init__.py +7 -0
- mcqpy/cli/autofill.py +56 -0
- mcqpy/cli/build.py +88 -0
- mcqpy/cli/check_latex.py +18 -0
- mcqpy/cli/config.py +43 -0
- mcqpy/cli/grade.py +51 -0
- mcqpy/cli/init.py +76 -0
- mcqpy/cli/main.py +10 -0
- mcqpy/cli/question/__init__.py +4 -0
- mcqpy/cli/question/init.py +25 -0
- mcqpy/cli/question/main.py +9 -0
- mcqpy/cli/question/render.py +58 -0
- mcqpy/cli/question/validate.py +25 -0
- mcqpy/compile/__init__.py +4 -0
- mcqpy/compile/front_config.py +12 -0
- mcqpy/compile/header_config.py +13 -0
- mcqpy/compile/latex_helpers.py +52 -0
- mcqpy/compile/latex_questions.py +187 -0
- mcqpy/compile/manifest.py +84 -0
- mcqpy/compile/mcq.py +176 -0
- mcqpy/compile/preamble.py +15 -0
- mcqpy/compile/solution_pdf.py +90 -0
- mcqpy/grade/__init__.py +2 -0
- mcqpy/grade/analysis.py +241 -0
- mcqpy/grade/grader.py +50 -0
- mcqpy/grade/parse_pdf.py +80 -0
- mcqpy/grade/rubric.py +17 -0
- mcqpy/grade/utils.py +34 -0
- mcqpy/question/__init__.py +15 -0
- mcqpy/question/filter/__init__.py +8 -0
- mcqpy/question/filter/base_filter.py +53 -0
- mcqpy/question/filter/date.py +118 -0
- mcqpy/question/filter/difficulty.py +64 -0
- mcqpy/question/filter/factory.py +34 -0
- mcqpy/question/filter/manifest.py +43 -0
- mcqpy/question/filter/slug.py +15 -0
- mcqpy/question/filter/stratified.py +46 -0
- mcqpy/question/filter/tag.py +49 -0
- mcqpy/question/question.py +378 -0
- mcqpy/question/question_bank.py +90 -0
- mcqpy/question/utils.py +91 -0
- mcqpy/utils/__init__.py +0 -0
- mcqpy/utils/check_latex.py +86 -0
- mcqpy/utils/fill_form.py +90 -0
- mcqpy/utils/image.py +92 -0
- mcqpy-0.1.0.dist-info/METADATA +118 -0
- mcqpy-0.1.0.dist-info/RECORD +50 -0
- mcqpy-0.1.0.dist-info/WHEEL +4 -0
- mcqpy-0.1.0.dist-info/entry_points.txt +3 -0
mcqpy/__init__.py
ADDED
mcqpy/cli/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from mcqpy.cli.main import main
|
|
2
|
+
from mcqpy.cli.init import init_command
|
|
3
|
+
from mcqpy.cli.build import build_command
|
|
4
|
+
from mcqpy.cli.grade import grade_command
|
|
5
|
+
from mcqpy.cli.autofill import autofill_command
|
|
6
|
+
from mcqpy.cli.question import question_group
|
|
7
|
+
from mcqpy.cli.check_latex import check_latex_command
|
mcqpy/cli/autofill.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import io
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import rich_click as click
|
|
6
|
+
from rich.progress import track
|
|
7
|
+
|
|
8
|
+
from mcqpy.cli.config import QuizConfig
|
|
9
|
+
from mcqpy.cli.main import main
|
|
10
|
+
from mcqpy.compile.manifest import Manifest
|
|
11
|
+
from mcqpy.utils.fill_form import fill_pdf_form
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@main.command(
|
|
15
|
+
name="test-autofill",
|
|
16
|
+
help="Make answered versions of quiz to test mcqpy functionality",
|
|
17
|
+
)
|
|
18
|
+
@click.option(
|
|
19
|
+
"-c",
|
|
20
|
+
"--config",
|
|
21
|
+
type=click.Path(exists=True, path_type=Path),
|
|
22
|
+
default="config.yaml",
|
|
23
|
+
help="Path to the config file",
|
|
24
|
+
show_default=True,
|
|
25
|
+
)
|
|
26
|
+
@click.option(
|
|
27
|
+
"-n",
|
|
28
|
+
"--num-forms",
|
|
29
|
+
type=int,
|
|
30
|
+
default=10,
|
|
31
|
+
help="Number of filled forms to generate",
|
|
32
|
+
show_default=True,
|
|
33
|
+
)
|
|
34
|
+
@click.option('--correct', is_flag=True, help="Fill forms with correct answers")
|
|
35
|
+
def autofill_command(config, num_forms, correct):
|
|
36
|
+
# Directories & files
|
|
37
|
+
config = QuizConfig.read_yaml(config)
|
|
38
|
+
file_path = Path(config.output_directory) / config.file_name
|
|
39
|
+
output_dir = Path(config.submission_directory)
|
|
40
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
file_name = Path(config.file_name).stem
|
|
42
|
+
manifest_path = Path(config.output_directory) / f"{file_name}_manifest.json"
|
|
43
|
+
manifest = Manifest.load_from_file(manifest_path)
|
|
44
|
+
|
|
45
|
+
# In the autofill_command function
|
|
46
|
+
for i in track(range(num_forms), description="Generating filled forms..."):
|
|
47
|
+
stdout_capture = io.StringIO()
|
|
48
|
+
stderr_capture = io.StringIO()
|
|
49
|
+
|
|
50
|
+
with (
|
|
51
|
+
contextlib.redirect_stdout(stdout_capture),
|
|
52
|
+
contextlib.redirect_stderr(stderr_capture),
|
|
53
|
+
):
|
|
54
|
+
fill_pdf_form(file_path, out_path=output_dir, index=i, manifest=manifest, correct_only=correct)
|
|
55
|
+
|
|
56
|
+
click.echo(f"Generated {num_forms} filled forms based on {file_path}")
|
mcqpy/cli/build.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import rich_click as click
|
|
2
|
+
import numpy as np
|
|
3
|
+
from mcqpy.cli.main import main
|
|
4
|
+
from mcqpy.cli.config import QuizConfig, SelectionConfig
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from mcqpy.question.filter import FilterFactory
|
|
7
|
+
|
|
8
|
+
from mcqpy.compile import MultipleChoiceQuiz
|
|
9
|
+
from mcqpy.question import QuestionBank
|
|
10
|
+
from mcqpy.compile.manifest import Manifest
|
|
11
|
+
|
|
12
|
+
from rich.pretty import Pretty
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_solution(questions, manifest, output_path: Path):
|
|
17
|
+
from mcqpy.compile.solution_pdf import SolutionPDF
|
|
18
|
+
solution_pdf = SolutionPDF(file=output_path, questions=questions, manifest=manifest)
|
|
19
|
+
solution_pdf.build(generate_pdf=True)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _select_questions(question_bank: QuestionBank, selection_config: SelectionConfig):
|
|
23
|
+
## Setup filters:
|
|
24
|
+
if selection_config.filters:
|
|
25
|
+
filter_objs = []
|
|
26
|
+
for filter_name, filter_params in selection_config.filters.items():
|
|
27
|
+
filter_config = {"type": filter_name, **filter_params}
|
|
28
|
+
filter_obj = FilterFactory.from_config(filter_config)
|
|
29
|
+
filter_objs.append(filter_obj)
|
|
30
|
+
question_bank.add_filter(filter_obj)
|
|
31
|
+
|
|
32
|
+
## Apply filters and select questions
|
|
33
|
+
questions = question_bank.get_filtered_questions(
|
|
34
|
+
number_of_questions=selection_config.number_of_questions,
|
|
35
|
+
shuffle=selection_config.shuffle,
|
|
36
|
+
sorting=selection_config.sort_type,
|
|
37
|
+
)
|
|
38
|
+
return questions
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@main.command(name="build", help="Build the quiz PDF from question files")
|
|
42
|
+
@click.option(
|
|
43
|
+
"-c",
|
|
44
|
+
"--config",
|
|
45
|
+
type=click.Path(exists=True, path_type=Path),
|
|
46
|
+
default="config.yaml",
|
|
47
|
+
help="Path to the config file",
|
|
48
|
+
show_default=True,
|
|
49
|
+
)
|
|
50
|
+
def build_command(config):
|
|
51
|
+
config = QuizConfig.read_yaml(config)
|
|
52
|
+
question_bank = QuestionBank.from_directories(config.questions_paths, seed=config.selection.seed)
|
|
53
|
+
questions = _select_questions(question_bank, config.selection)
|
|
54
|
+
|
|
55
|
+
console = Console()
|
|
56
|
+
console.print("[bold green]Quiz Configuration:[/bold green]")
|
|
57
|
+
console.print(Pretty(config))
|
|
58
|
+
console.print(f"[bold green]Total questions in bank:[/bold green] {len(question_bank)}")
|
|
59
|
+
console.print(f"[bold green]Selected questions:[/bold green] {len(questions)}")
|
|
60
|
+
|
|
61
|
+
## Paths:
|
|
62
|
+
root = Path(config.root_directory)
|
|
63
|
+
output_dir = root / config.output_directory
|
|
64
|
+
file_path = output_dir / config.file_name
|
|
65
|
+
submission_dir = (
|
|
66
|
+
root / config.submission_directory if config.submission_directory else None
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
for path in [root, output_dir, submission_dir]:
|
|
70
|
+
if path and not path.exists():
|
|
71
|
+
path.mkdir(parents=True, exist_ok=True) # pragma: no cover
|
|
72
|
+
|
|
73
|
+
mcq = MultipleChoiceQuiz(
|
|
74
|
+
file=file_path,
|
|
75
|
+
questions=questions,
|
|
76
|
+
front_matter=config.front_matter,
|
|
77
|
+
header_footer=config.header,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
mcq.build(generate_pdf=True)
|
|
81
|
+
|
|
82
|
+
# Build solution PDF
|
|
83
|
+
manifest_path = mcq.get_manifest_path()
|
|
84
|
+
manifest = Manifest.load_from_file(manifest_path)
|
|
85
|
+
solution_output_path = (
|
|
86
|
+
output_dir / f"{config.file_name.replace('.pdf', '')}_solution.pdf"
|
|
87
|
+
)
|
|
88
|
+
build_solution(questions, manifest, solution_output_path)
|
mcqpy/cli/check_latex.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from mcqpy.cli import main
|
|
2
|
+
|
|
3
|
+
@main.command('check-latex', help="Check LaTeX installation and configuration.")
|
|
4
|
+
def check_latex_command():
|
|
5
|
+
from mcqpy.utils.check_latex import check_latex_installation
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
console = Console()
|
|
9
|
+
success, details = check_latex_installation()
|
|
10
|
+
|
|
11
|
+
if success:
|
|
12
|
+
console.print("[bold green]✓ LaTeX is properly installed![/bold green]")
|
|
13
|
+
console.print(f"[green]pdflatex version[/green]: {details['pdflatex'].version}")
|
|
14
|
+
console.print(f"[green]latexmk version[/green]: {details['latexmk'].version}")
|
|
15
|
+
console.print("[green]Compilation test passed successfully.[/green]")
|
|
16
|
+
else:
|
|
17
|
+
console.print(f"[bold red]✗ LaTeX installation issue: {details['error_message']}[/bold red]")
|
|
18
|
+
|
mcqpy/cli/config.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field, ConfigDict
|
|
2
|
+
import yaml
|
|
3
|
+
from mcqpy.compile import HeaderFooterOptions, FrontMatterOptions
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
class SelectionConfig(BaseModel):
|
|
7
|
+
model_config = ConfigDict(extra="forbid", validate_assignment=True)
|
|
8
|
+
number_of_questions: int | None = Field(default=20, description="Number of questions to select")
|
|
9
|
+
filters: dict[str, dict[str, Any]] | None = Field(default=None, description="Filters to apply when selecting questions")
|
|
10
|
+
seed: int | None = Field(default=None, description="Random seed for question selection")
|
|
11
|
+
shuffle: bool = Field(default=False, description="Whether to shuffle selected questions")
|
|
12
|
+
sort_type: Literal['slug', 'none'] = Field(default='none', description="Sort type for selected questions")
|
|
13
|
+
|
|
14
|
+
class QuizConfig(BaseModel):
|
|
15
|
+
model_config = ConfigDict(extra="forbid", validate_assignment=True)
|
|
16
|
+
questions_paths: list[str] | str = Field(default=["questions"], description="Paths to question files or directories")
|
|
17
|
+
file_name: str = Field(default="quiz.pdf", description="Name of the output PDF file")
|
|
18
|
+
root_directory: str = Field(default=".", description="Root directory for the quiz project")
|
|
19
|
+
output_directory: str = Field(default="output", description="Directory for output files")
|
|
20
|
+
submission_directory: str | None = Field(default=None, description="Directory for submission files (if any)")
|
|
21
|
+
front_matter: FrontMatterOptions = Field(default_factory=FrontMatterOptions)
|
|
22
|
+
header: HeaderFooterOptions = Field(default_factory=HeaderFooterOptions)
|
|
23
|
+
selection: SelectionConfig = Field(default_factory=SelectionConfig)
|
|
24
|
+
|
|
25
|
+
def yaml_dump(self) -> str:
|
|
26
|
+
"""Dump the current configuration to a YAML string"""
|
|
27
|
+
config_dict = self.model_dump()
|
|
28
|
+
yaml_content = yaml.dump(config_dict, default_flow_style=False, sort_keys=False)
|
|
29
|
+
return yaml_content
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def generate_example_yaml(cls) -> str:
|
|
33
|
+
"""Generate example YAML with comments"""
|
|
34
|
+
example = cls()
|
|
35
|
+
return example.yaml_dump()
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def read_yaml(cls, file_path: str) -> "QuizConfig":
|
|
39
|
+
"""Read YAML file and return a QuizConfig instance"""
|
|
40
|
+
with open(file_path, "r") as file:
|
|
41
|
+
yaml_string = file.read()
|
|
42
|
+
data = yaml.safe_load(yaml_string)
|
|
43
|
+
return cls(**data)
|
mcqpy/cli/grade.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import rich_click as click
|
|
2
|
+
from mcqpy.cli.main import main
|
|
3
|
+
from mcqpy.cli.config import QuizConfig
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from mcqpy.grade import MCQGrader, get_grade_dataframe
|
|
7
|
+
from mcqpy.compile.manifest import Manifest
|
|
8
|
+
from mcqpy.grade.rubric import StrictRubric
|
|
9
|
+
from rich.progress import track
|
|
10
|
+
|
|
11
|
+
from mcqpy.question.question_bank import QuestionBank
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@main.command(name="grade", help="Grade student submissions")
|
|
15
|
+
@click.option("-c", "--config", type=click.Path(exists=True, path_type=Path), default="config.yaml", help="Path to the config file", show_default=True)
|
|
16
|
+
@click.option("-v", "--verbose", is_flag=True, help="Enable verbose output")
|
|
17
|
+
@click.option("-f", "--file-format", type=click.Choice(["xlsx", "csv"]), default="xlsx", help="Output format for the grades", show_default=True)
|
|
18
|
+
@click.option('-a', '--analysis', is_flag=True, help="Generate question analysis reports", default=False)
|
|
19
|
+
def grade_command(config, verbose: bool, file_format: str, analysis: bool):
|
|
20
|
+
|
|
21
|
+
# Load config
|
|
22
|
+
config = QuizConfig.read_yaml(config)
|
|
23
|
+
file_name = Path(config.file_name).stem
|
|
24
|
+
manifest_path = Path(config.output_directory) / f"{file_name}_manifest.json"
|
|
25
|
+
manifest = Manifest.load_from_file(manifest_path)
|
|
26
|
+
|
|
27
|
+
# Read & Grade submissions
|
|
28
|
+
graded_sets = []
|
|
29
|
+
submissions = list(Path(config.submission_directory).glob("*.pdf"))
|
|
30
|
+
for submission in track(submissions, description=f"Grading submissions ({len(submissions)})", total=len(submissions)):
|
|
31
|
+
grader = MCQGrader(manifest, StrictRubric())
|
|
32
|
+
graded_set = grader.grade(submission)
|
|
33
|
+
graded_sets.append(graded_set)
|
|
34
|
+
|
|
35
|
+
# Export grades to dataframe
|
|
36
|
+
df = get_grade_dataframe(graded_sets)
|
|
37
|
+
output_path = Path(config.submission_directory).parent / f"{file_name}_grades.{file_format}"
|
|
38
|
+
if file_format == "xlsx":
|
|
39
|
+
df.to_excel(output_path, index=False)
|
|
40
|
+
elif file_format == "csv":
|
|
41
|
+
df.to_csv(output_path, index=False)
|
|
42
|
+
|
|
43
|
+
if analysis:
|
|
44
|
+
from mcqpy.grade.analysis import QuizAnalysis
|
|
45
|
+
analysis_directory = Path('analysis/')
|
|
46
|
+
analysis_directory.mkdir(exist_ok=True)
|
|
47
|
+
|
|
48
|
+
question_bank = QuestionBank.from_directories(config.questions_paths)
|
|
49
|
+
|
|
50
|
+
quiz_analysis = QuizAnalysis(graded_sets, question_bank=question_bank, output_dir=analysis_directory)
|
|
51
|
+
quiz_analysis.build()
|
mcqpy/cli/init.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import rich_click as click
|
|
2
|
+
from mcqpy.cli.main import main
|
|
3
|
+
from mcqpy.cli.config import QuizConfig
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@main.command(name="init", help="Initialize a new quiz project")
|
|
9
|
+
@click.argument("name", type=str, help="The name of the quiz project")
|
|
10
|
+
@click.option(
|
|
11
|
+
"-f",
|
|
12
|
+
"--file-name",
|
|
13
|
+
type=str,
|
|
14
|
+
default="quiz.pdf",
|
|
15
|
+
help="Name of the output PDF file",
|
|
16
|
+
show_default=True,
|
|
17
|
+
)
|
|
18
|
+
@click.option(
|
|
19
|
+
"-mqd",
|
|
20
|
+
"--make-questions-directory",
|
|
21
|
+
is_flag=True,
|
|
22
|
+
help="Create a questions directory",
|
|
23
|
+
default=True,
|
|
24
|
+
)
|
|
25
|
+
@click.option(
|
|
26
|
+
"-o",
|
|
27
|
+
"--output-directory",
|
|
28
|
+
type=str,
|
|
29
|
+
default="output",
|
|
30
|
+
help="Directory for output files",
|
|
31
|
+
show_default=True,
|
|
32
|
+
)
|
|
33
|
+
@click.option(
|
|
34
|
+
"-s",
|
|
35
|
+
"--submission-directory",
|
|
36
|
+
type=str,
|
|
37
|
+
default="submissions",
|
|
38
|
+
help="Directory for student submissions",
|
|
39
|
+
show_default=True,
|
|
40
|
+
)
|
|
41
|
+
def init_command(
|
|
42
|
+
name: str,
|
|
43
|
+
file_name: str,
|
|
44
|
+
make_questions_directory: bool,
|
|
45
|
+
output_directory: str,
|
|
46
|
+
submission_directory: str,
|
|
47
|
+
):
|
|
48
|
+
# Create project directory
|
|
49
|
+
project_path = Path(name)
|
|
50
|
+
project_path.mkdir(parents=True, exist_ok=False)
|
|
51
|
+
print(f"Initialized quiz project at: {project_path}")
|
|
52
|
+
|
|
53
|
+
# Create questions directory if flag is set
|
|
54
|
+
if make_questions_directory:
|
|
55
|
+
questions_dir = project_path / "questions"
|
|
56
|
+
questions_dir.mkdir(parents=True, exist_ok=False)
|
|
57
|
+
print(f"Created questions directory at: {questions_dir}")
|
|
58
|
+
|
|
59
|
+
submission_directory_path = project_path / submission_directory
|
|
60
|
+
submission_directory_path.mkdir(parents=True, exist_ok=False)
|
|
61
|
+
|
|
62
|
+
# Create example config.yaml
|
|
63
|
+
config_file = project_path / "config.yaml"
|
|
64
|
+
config = QuizConfig(
|
|
65
|
+
questions_paths=["questions"],
|
|
66
|
+
file_name=file_name,
|
|
67
|
+
output_directory=output_directory,
|
|
68
|
+
submission_directory=submission_directory,
|
|
69
|
+
)
|
|
70
|
+
config_file.write_text(config.yaml_dump())
|
|
71
|
+
print(f"Created config file at: {config_file}")
|
|
72
|
+
|
|
73
|
+
# Output directory
|
|
74
|
+
output_dir = project_path / output_directory
|
|
75
|
+
output_dir.mkdir(parents=True, exist_ok=False)
|
|
76
|
+
print(f"Created output directory at: {output_dir}")
|
mcqpy/cli/main.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import rich_click as click
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
from mcqpy.cli.question.main import question_group
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@question_group.command(name="init", help="Initialize question file.")
|
|
8
|
+
@click.argument("path", type=click.Path(exists=False))
|
|
9
|
+
def init_command(path):
|
|
10
|
+
from mcqpy.question import Question
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.pretty import Pretty
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Get the yaml schema for a question
|
|
16
|
+
schema = Question.get_yaml_template()
|
|
17
|
+
# Write to the specified path
|
|
18
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
19
|
+
f.write(schema)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import rich_click as click
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
from mcqpy.cli.question.main import question_group
|
|
5
|
+
|
|
6
|
+
def _render_question(name, question):
|
|
7
|
+
from pylatex import Document
|
|
8
|
+
from mcqpy.compile.latex_questions import build_question
|
|
9
|
+
|
|
10
|
+
document = Document(
|
|
11
|
+
documentclass="article",
|
|
12
|
+
geometry_options={
|
|
13
|
+
"paper": "a4paper",
|
|
14
|
+
"includeheadfoot": True,
|
|
15
|
+
"left": "2cm",
|
|
16
|
+
"right": "3cm",
|
|
17
|
+
"top": "2.5cm",
|
|
18
|
+
"bottom": "2.5cm",
|
|
19
|
+
},
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
build_question(document, question, quiz_index=0)
|
|
23
|
+
document.generate_pdf(f"{name}", clean_tex=True)
|
|
24
|
+
|
|
25
|
+
return name
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@question_group.command(name="render", help="Render a question as PDF. Useful to check LaTeX formatting.")
|
|
30
|
+
@click.argument("path", type=click.Path(exists=True))
|
|
31
|
+
def render_command(path):
|
|
32
|
+
from mcqpy.question import Question
|
|
33
|
+
from rich.console import Console
|
|
34
|
+
import subprocess
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
console = Console()
|
|
38
|
+
try:
|
|
39
|
+
question = Question.load_yaml(path)
|
|
40
|
+
except Exception as e:
|
|
41
|
+
console.print(f"[bold red]Error loading question from {path}:[/bold red]")
|
|
42
|
+
console.print(e)
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
name = Path(path).stem
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
_render_question(name, question)
|
|
49
|
+
except subprocess.CalledProcessError as e:
|
|
50
|
+
console.print(f"[bold red]Invalid latex for question {path}[/bold red]")
|
|
51
|
+
except Exception as e:
|
|
52
|
+
console.print(f"[bold red]Error generating question PDF for {path}:[/bold red]")
|
|
53
|
+
console.print(e)
|
|
54
|
+
else:
|
|
55
|
+
console.print(f"[bold green]Generated question PDF at: {name}.pdf[/bold green]")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import rich_click as click
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
from mcqpy.cli.question.main import question_group
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@question_group.command(name="validate", help="Validate question files")
|
|
8
|
+
@click.argument("paths", type=click.Path(exists=True), nargs=-1)
|
|
9
|
+
def validate_command(paths):
|
|
10
|
+
from mcqpy.question import Question
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.pretty import Pretty
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
for path in paths:
|
|
17
|
+
try:
|
|
18
|
+
question = Question.load_yaml(path)
|
|
19
|
+
console.print(f"[bold green]Valid question file:[/bold green] {path}")
|
|
20
|
+
except Exception as e:
|
|
21
|
+
console.print(f"[bold red]Error loading question from {path}:[/bold red]")
|
|
22
|
+
console.print(e)
|
|
23
|
+
|
|
24
|
+
console.print()
|
|
25
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field, ConfigDict
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
class FrontMatterOptions(BaseModel):
|
|
5
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
6
|
+
|
|
7
|
+
title: Optional[str] = Field(default=None, description="Title of the document")
|
|
8
|
+
author: Optional[str] = Field(default=None, description="Author of the document")
|
|
9
|
+
date: Optional[str | bool] = Field(default=None, description="Date of the document")
|
|
10
|
+
exam_information: Optional[str] = Field(default=None, description="Exam information")
|
|
11
|
+
id_fields: bool = Field(default=False, description="Include ID fields")
|
|
12
|
+
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field, ConfigDict
|
|
2
|
+
from typing import Optional
|
|
3
|
+
import yaml
|
|
4
|
+
|
|
5
|
+
class HeaderFooterOptions(BaseModel):
|
|
6
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
7
|
+
|
|
8
|
+
header_left: Optional[str] = Field(default=None, description="Left header content")
|
|
9
|
+
header_center: Optional[str] = Field(default=None, description="Center header content")
|
|
10
|
+
header_right: Optional[str] = Field(default=r"Page \thepage \ of \ \pageref{LastPage}", description="Right header content")
|
|
11
|
+
footer_left: Optional[str] = Field(default=None, description="Left footer content")
|
|
12
|
+
footer_center: Optional[str] = Field(default=None, description="Center footer content")
|
|
13
|
+
footer_right: Optional[str] = Field(default=None, description="Right footer content")
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from pylatex.base_classes import Environment
|
|
2
|
+
from pylatex.package import Package
|
|
3
|
+
from pylatex.utils import NoEscape
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Form(Environment):
|
|
7
|
+
"""Form environment from hyperref."""
|
|
8
|
+
|
|
9
|
+
_latex_name = "Form"
|
|
10
|
+
|
|
11
|
+
packages = [Package("hyperref")]
|
|
12
|
+
escape = False
|
|
13
|
+
content_separator = " "
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def radio_option(quiz_index: int, q_slug: str, q_qid: str, i: int, checked=False) -> NoEscape:
|
|
17
|
+
return multi_checkbox(
|
|
18
|
+
quiz_index=quiz_index,
|
|
19
|
+
q_slug=q_slug,
|
|
20
|
+
q_qid=q_qid,
|
|
21
|
+
i=i,
|
|
22
|
+
checked=checked,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def multi_checkbox(quiz_index: int, q_slug: str, q_qid: str, i: int, checked=False) -> NoEscape:
|
|
27
|
+
command = NoEscape(
|
|
28
|
+
r"\raisebox{0pt}[0pt][0pt]{\CheckBox"
|
|
29
|
+
+ f"[name=Q{quiz_index}-opt={i}-slug={q_slug}-qid={q_qid},"
|
|
30
|
+
+ r"width=1em,"
|
|
31
|
+
+ r"height=1em,"
|
|
32
|
+
+ r"bordercolor=0 0 0,"
|
|
33
|
+
+ r"backgroundcolor=1 1 1,"
|
|
34
|
+
+ (r"checked=true," if checked else "")
|
|
35
|
+
+ r"]{{}}"
|
|
36
|
+
+ "}"
|
|
37
|
+
)
|
|
38
|
+
return command
|
|
39
|
+
|
|
40
|
+
def code_block(code: str, language: str = "python") -> NoEscape:
|
|
41
|
+
latex_block = rf"""\begin{{minted}}
|
|
42
|
+
[
|
|
43
|
+
frame=lines,
|
|
44
|
+
framesep=2mm,
|
|
45
|
+
baselinestretch=1.2,
|
|
46
|
+
fontsize=\footnotesize,
|
|
47
|
+
linenos
|
|
48
|
+
]
|
|
49
|
+
{{{language}}}
|
|
50
|
+
{code}\end{{minted}}
|
|
51
|
+
"""
|
|
52
|
+
return NoEscape(latex_block)
|