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.
Files changed (50) hide show
  1. mcqpy/__init__.py +2 -0
  2. mcqpy/cli/__init__.py +7 -0
  3. mcqpy/cli/autofill.py +56 -0
  4. mcqpy/cli/build.py +88 -0
  5. mcqpy/cli/check_latex.py +18 -0
  6. mcqpy/cli/config.py +43 -0
  7. mcqpy/cli/grade.py +51 -0
  8. mcqpy/cli/init.py +76 -0
  9. mcqpy/cli/main.py +10 -0
  10. mcqpy/cli/question/__init__.py +4 -0
  11. mcqpy/cli/question/init.py +25 -0
  12. mcqpy/cli/question/main.py +9 -0
  13. mcqpy/cli/question/render.py +58 -0
  14. mcqpy/cli/question/validate.py +25 -0
  15. mcqpy/compile/__init__.py +4 -0
  16. mcqpy/compile/front_config.py +12 -0
  17. mcqpy/compile/header_config.py +13 -0
  18. mcqpy/compile/latex_helpers.py +52 -0
  19. mcqpy/compile/latex_questions.py +187 -0
  20. mcqpy/compile/manifest.py +84 -0
  21. mcqpy/compile/mcq.py +176 -0
  22. mcqpy/compile/preamble.py +15 -0
  23. mcqpy/compile/solution_pdf.py +90 -0
  24. mcqpy/grade/__init__.py +2 -0
  25. mcqpy/grade/analysis.py +241 -0
  26. mcqpy/grade/grader.py +50 -0
  27. mcqpy/grade/parse_pdf.py +80 -0
  28. mcqpy/grade/rubric.py +17 -0
  29. mcqpy/grade/utils.py +34 -0
  30. mcqpy/question/__init__.py +15 -0
  31. mcqpy/question/filter/__init__.py +8 -0
  32. mcqpy/question/filter/base_filter.py +53 -0
  33. mcqpy/question/filter/date.py +118 -0
  34. mcqpy/question/filter/difficulty.py +64 -0
  35. mcqpy/question/filter/factory.py +34 -0
  36. mcqpy/question/filter/manifest.py +43 -0
  37. mcqpy/question/filter/slug.py +15 -0
  38. mcqpy/question/filter/stratified.py +46 -0
  39. mcqpy/question/filter/tag.py +49 -0
  40. mcqpy/question/question.py +378 -0
  41. mcqpy/question/question_bank.py +90 -0
  42. mcqpy/question/utils.py +91 -0
  43. mcqpy/utils/__init__.py +0 -0
  44. mcqpy/utils/check_latex.py +86 -0
  45. mcqpy/utils/fill_form.py +90 -0
  46. mcqpy/utils/image.py +92 -0
  47. mcqpy-0.1.0.dist-info/METADATA +118 -0
  48. mcqpy-0.1.0.dist-info/RECORD +50 -0
  49. mcqpy-0.1.0.dist-info/WHEEL +4 -0
  50. mcqpy-0.1.0.dist-info/entry_points.txt +3 -0
mcqpy/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ from .question import Question
2
+
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)
@@ -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,10 @@
1
+ import rich_click as click
2
+
3
+ @click.group(name="mcqpy")
4
+ @click.version_option()
5
+ def main() -> None:
6
+ """
7
+ Command line interface for mcqpy.
8
+ """
9
+ return None # pragma: no cover
10
+
@@ -0,0 +1,4 @@
1
+ from .main import question_group
2
+ from .validate import validate_command
3
+ from .init import init_command
4
+ from .render import render_command
@@ -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,9 @@
1
+ import rich_click as click
2
+ from mcqpy.cli.main import main
3
+
4
+ @main.group(name="question")
5
+ def question_group() -> None:
6
+ """
7
+ Commands related to question management.
8
+ """
9
+ return None # pragma: no cover
@@ -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,4 @@
1
+ from .header_config import HeaderFooterOptions
2
+ from .front_config import FrontMatterOptions
3
+ from .latex_helpers import Form, multi_checkbox, radio_option
4
+ from .mcq import MultipleChoiceQuiz
@@ -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)