mcqpy 0.1.0__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.
Files changed (49) hide show
  1. mcqpy-0.1.0/PKG-INFO +118 -0
  2. mcqpy-0.1.0/README.md +95 -0
  3. mcqpy-0.1.0/pyproject.toml +56 -0
  4. mcqpy-0.1.0/src/mcqpy/__init__.py +2 -0
  5. mcqpy-0.1.0/src/mcqpy/cli/__init__.py +7 -0
  6. mcqpy-0.1.0/src/mcqpy/cli/autofill.py +56 -0
  7. mcqpy-0.1.0/src/mcqpy/cli/build.py +88 -0
  8. mcqpy-0.1.0/src/mcqpy/cli/check_latex.py +18 -0
  9. mcqpy-0.1.0/src/mcqpy/cli/config.py +43 -0
  10. mcqpy-0.1.0/src/mcqpy/cli/grade.py +51 -0
  11. mcqpy-0.1.0/src/mcqpy/cli/init.py +76 -0
  12. mcqpy-0.1.0/src/mcqpy/cli/main.py +10 -0
  13. mcqpy-0.1.0/src/mcqpy/cli/question/__init__.py +4 -0
  14. mcqpy-0.1.0/src/mcqpy/cli/question/init.py +25 -0
  15. mcqpy-0.1.0/src/mcqpy/cli/question/main.py +9 -0
  16. mcqpy-0.1.0/src/mcqpy/cli/question/render.py +58 -0
  17. mcqpy-0.1.0/src/mcqpy/cli/question/validate.py +25 -0
  18. mcqpy-0.1.0/src/mcqpy/compile/__init__.py +4 -0
  19. mcqpy-0.1.0/src/mcqpy/compile/front_config.py +12 -0
  20. mcqpy-0.1.0/src/mcqpy/compile/header_config.py +13 -0
  21. mcqpy-0.1.0/src/mcqpy/compile/latex_helpers.py +52 -0
  22. mcqpy-0.1.0/src/mcqpy/compile/latex_questions.py +187 -0
  23. mcqpy-0.1.0/src/mcqpy/compile/manifest.py +84 -0
  24. mcqpy-0.1.0/src/mcqpy/compile/mcq.py +176 -0
  25. mcqpy-0.1.0/src/mcqpy/compile/preamble.py +15 -0
  26. mcqpy-0.1.0/src/mcqpy/compile/solution_pdf.py +90 -0
  27. mcqpy-0.1.0/src/mcqpy/grade/__init__.py +2 -0
  28. mcqpy-0.1.0/src/mcqpy/grade/analysis.py +241 -0
  29. mcqpy-0.1.0/src/mcqpy/grade/grader.py +50 -0
  30. mcqpy-0.1.0/src/mcqpy/grade/parse_pdf.py +80 -0
  31. mcqpy-0.1.0/src/mcqpy/grade/rubric.py +17 -0
  32. mcqpy-0.1.0/src/mcqpy/grade/utils.py +34 -0
  33. mcqpy-0.1.0/src/mcqpy/question/__init__.py +15 -0
  34. mcqpy-0.1.0/src/mcqpy/question/filter/__init__.py +8 -0
  35. mcqpy-0.1.0/src/mcqpy/question/filter/base_filter.py +53 -0
  36. mcqpy-0.1.0/src/mcqpy/question/filter/date.py +118 -0
  37. mcqpy-0.1.0/src/mcqpy/question/filter/difficulty.py +64 -0
  38. mcqpy-0.1.0/src/mcqpy/question/filter/factory.py +34 -0
  39. mcqpy-0.1.0/src/mcqpy/question/filter/manifest.py +43 -0
  40. mcqpy-0.1.0/src/mcqpy/question/filter/slug.py +15 -0
  41. mcqpy-0.1.0/src/mcqpy/question/filter/stratified.py +46 -0
  42. mcqpy-0.1.0/src/mcqpy/question/filter/tag.py +49 -0
  43. mcqpy-0.1.0/src/mcqpy/question/question.py +378 -0
  44. mcqpy-0.1.0/src/mcqpy/question/question_bank.py +90 -0
  45. mcqpy-0.1.0/src/mcqpy/question/utils.py +91 -0
  46. mcqpy-0.1.0/src/mcqpy/utils/__init__.py +0 -0
  47. mcqpy-0.1.0/src/mcqpy/utils/check_latex.py +86 -0
  48. mcqpy-0.1.0/src/mcqpy/utils/fill_form.py +90 -0
  49. mcqpy-0.1.0/src/mcqpy/utils/image.py +92 -0
mcqpy-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,118 @@
1
+ Metadata-Version: 2.3
2
+ Name: mcqpy
3
+ Version: 0.1.0
4
+ Summary: Multiple choice quiz generation and grading
5
+ Author: Mads-Peter
6
+ Author-email: Mads-Peter <machri@phys.au.dk>
7
+ Requires-Dist: click>=8.3.0
8
+ Requires-Dist: matplotlib>=3.10.7
9
+ Requires-Dist: numpy>=2.3.3
10
+ Requires-Dist: openpyxl>=3.1.5
11
+ Requires-Dist: pandas>=2.3.3
12
+ Requires-Dist: pillow>=11.3.0
13
+ Requires-Dist: pydantic>=2.12.0
14
+ Requires-Dist: pylatex>=1.4.2
15
+ Requires-Dist: pylatexenc>=2.10
16
+ Requires-Dist: pypdf>=6.1.1
17
+ Requires-Dist: pyyaml>=6.0.3
18
+ Requires-Dist: requests>=2.28.0
19
+ Requires-Dist: rich>=14.2.0
20
+ Requires-Dist: rich-click>=1.9.3
21
+ Requires-Python: >=3.13
22
+ Description-Content-Type: text/markdown
23
+
24
+ # mcqpy
25
+
26
+ [![codecov](https://codecov.io/github/au-mbg/mcqpy/graph/badge.svg?token=JIXBIKWVOQ)](https://codecov.io/github/au-mbg/mcqpy)
27
+
28
+ Generate and grade interactive multiple-choice quiz PDF documents.
29
+ Rendered documents with
30
+
31
+ - LaTeX formatting - including for math/equations.
32
+ - Include figures from files or from links.
33
+ - Code snippets with highlighted code.
34
+ - Headers and footers.
35
+
36
+ Easily grade hundreds of quizzes with grades exported to `.xlsx` or `.csv` while
37
+ obtaining question-level statistics.
38
+
39
+ ## Example
40
+
41
+ The simplest way to use `mcqpy` is through the command line interface (CLI). A project
42
+ can be initialized like so
43
+ ```
44
+ mcqpy init <PROJECT_NAME>
45
+ ```
46
+ Which will create a directory `<PROJECT_NAME>` containing the following:
47
+
48
+ - `config.yaml`: Overall configuration of the project, including author, document name, header, footer and preface options.
49
+ - `questions/`: A directory where the project expects question `.yaml` files to be located.
50
+ - `output/`: Where the built documents will be put.
51
+ - `submissions/`: A directory where submitted quizzes should be put for grading.
52
+
53
+ For a quiz to be interesting it needs questions, the template structure of a question
54
+ can be created using
55
+ ```
56
+ mcqpy question init <QUESTION_PATH>
57
+ ```
58
+ where `<QUESTION_PATH>` could be `questions/q1.yaml`. Once the desired number of questions have been
59
+ written the quiz can be compiled using
60
+ ```
61
+ mcqpy build
62
+ ```
63
+ Which produces a number of files in the `output/` directory the important ones being
64
+
65
+ - `<NAME>.pdf`: The quiz document
66
+ - `<NAME>_solution.pdf`: A human readable solution key to all questions contained in the quiz.
67
+ - `<NAME>_manifest.json`: Quiz key used by `mcqpy` to grade quizzes.
68
+
69
+ The `<NMAE>.pdf` document can be distributed to quiz takers through any means and once
70
+ returned and placed in the `submissions/` directory be graded by the program.
71
+ A number of test submissions can be created using
72
+ ```
73
+ mcqpy test-autofill -n 50
74
+ ```
75
+ Which here generates 50 randomly filled quizzes. To grade run
76
+ ```
77
+ mcqpy grade -a
78
+ ```
79
+ Which will produce the files `<NAME>_grades.xlsx` containing the grades of all submissions and `analysis/<NAME>_analysis.pdf` containing statistics about overall point distributions as well as question level statistics.
80
+
81
+ ## Installation
82
+
83
+ `mcqpy` requires Python, a small number of Python packages and a working LaTeX installation.
84
+
85
+ ### Installing `mcqpy` in a venv.
86
+
87
+ If you have a working Python installation we recommend installing `mcqpy` in a suitable virtual environment (venv) using `pip`
88
+
89
+ ```
90
+ pip install git+https://github.com/au-mbg/mcqpy.git
91
+ ```
92
+
93
+ ### Installing Python & `mcqpy`.
94
+
95
+ If you do not have a suitable Python version, `uv` can be recommended for installing and managing Python, see [Installing uv](https://docs.astral.sh/uv/getting-started/installation/). With uv installed you can create a venv with `mcqpy` using
96
+ ```
97
+ uv venv
98
+ source .venv/bin/activate # On Mac/Linux
99
+ uv pip install git+https://github.com/au-mbg/mcqpy.git
100
+ ```
101
+
102
+ ### Install LaTeX
103
+
104
+ You will also need a working LaTeX installation, once `mcqpy` is installed you can check for that using
105
+
106
+ ```
107
+ mcqpy check-latex
108
+ ```
109
+ Which will output the versions of `pdflatex` and `latexmk` if they are installed, if not you should install an OS
110
+ appropriate LaTeX distribution for example from one of these sources:
111
+
112
+ - **macOS**: [MacTeX](https://www.tug.org/mactex/)
113
+ - **Windows**: [TeX Live](https://www.tug.org/texlive/) or [MiKTeX](https://miktex.org/)
114
+ - **Linux**: TeX Live (usually available through your package manager, e.g., `sudo apt install texlive-full` on Ubuntu/Debian)
115
+
116
+
117
+
118
+
mcqpy-0.1.0/README.md ADDED
@@ -0,0 +1,95 @@
1
+ # mcqpy
2
+
3
+ [![codecov](https://codecov.io/github/au-mbg/mcqpy/graph/badge.svg?token=JIXBIKWVOQ)](https://codecov.io/github/au-mbg/mcqpy)
4
+
5
+ Generate and grade interactive multiple-choice quiz PDF documents.
6
+ Rendered documents with
7
+
8
+ - LaTeX formatting - including for math/equations.
9
+ - Include figures from files or from links.
10
+ - Code snippets with highlighted code.
11
+ - Headers and footers.
12
+
13
+ Easily grade hundreds of quizzes with grades exported to `.xlsx` or `.csv` while
14
+ obtaining question-level statistics.
15
+
16
+ ## Example
17
+
18
+ The simplest way to use `mcqpy` is through the command line interface (CLI). A project
19
+ can be initialized like so
20
+ ```
21
+ mcqpy init <PROJECT_NAME>
22
+ ```
23
+ Which will create a directory `<PROJECT_NAME>` containing the following:
24
+
25
+ - `config.yaml`: Overall configuration of the project, including author, document name, header, footer and preface options.
26
+ - `questions/`: A directory where the project expects question `.yaml` files to be located.
27
+ - `output/`: Where the built documents will be put.
28
+ - `submissions/`: A directory where submitted quizzes should be put for grading.
29
+
30
+ For a quiz to be interesting it needs questions, the template structure of a question
31
+ can be created using
32
+ ```
33
+ mcqpy question init <QUESTION_PATH>
34
+ ```
35
+ where `<QUESTION_PATH>` could be `questions/q1.yaml`. Once the desired number of questions have been
36
+ written the quiz can be compiled using
37
+ ```
38
+ mcqpy build
39
+ ```
40
+ Which produces a number of files in the `output/` directory the important ones being
41
+
42
+ - `<NAME>.pdf`: The quiz document
43
+ - `<NAME>_solution.pdf`: A human readable solution key to all questions contained in the quiz.
44
+ - `<NAME>_manifest.json`: Quiz key used by `mcqpy` to grade quizzes.
45
+
46
+ The `<NMAE>.pdf` document can be distributed to quiz takers through any means and once
47
+ returned and placed in the `submissions/` directory be graded by the program.
48
+ A number of test submissions can be created using
49
+ ```
50
+ mcqpy test-autofill -n 50
51
+ ```
52
+ Which here generates 50 randomly filled quizzes. To grade run
53
+ ```
54
+ mcqpy grade -a
55
+ ```
56
+ Which will produce the files `<NAME>_grades.xlsx` containing the grades of all submissions and `analysis/<NAME>_analysis.pdf` containing statistics about overall point distributions as well as question level statistics.
57
+
58
+ ## Installation
59
+
60
+ `mcqpy` requires Python, a small number of Python packages and a working LaTeX installation.
61
+
62
+ ### Installing `mcqpy` in a venv.
63
+
64
+ If you have a working Python installation we recommend installing `mcqpy` in a suitable virtual environment (venv) using `pip`
65
+
66
+ ```
67
+ pip install git+https://github.com/au-mbg/mcqpy.git
68
+ ```
69
+
70
+ ### Installing Python & `mcqpy`.
71
+
72
+ If you do not have a suitable Python version, `uv` can be recommended for installing and managing Python, see [Installing uv](https://docs.astral.sh/uv/getting-started/installation/). With uv installed you can create a venv with `mcqpy` using
73
+ ```
74
+ uv venv
75
+ source .venv/bin/activate # On Mac/Linux
76
+ uv pip install git+https://github.com/au-mbg/mcqpy.git
77
+ ```
78
+
79
+ ### Install LaTeX
80
+
81
+ You will also need a working LaTeX installation, once `mcqpy` is installed you can check for that using
82
+
83
+ ```
84
+ mcqpy check-latex
85
+ ```
86
+ Which will output the versions of `pdflatex` and `latexmk` if they are installed, if not you should install an OS
87
+ appropriate LaTeX distribution for example from one of these sources:
88
+
89
+ - **macOS**: [MacTeX](https://www.tug.org/mactex/)
90
+ - **Windows**: [TeX Live](https://www.tug.org/texlive/) or [MiKTeX](https://miktex.org/)
91
+ - **Linux**: TeX Live (usually available through your package manager, e.g., `sudo apt install texlive-full` on Ubuntu/Debian)
92
+
93
+
94
+
95
+
@@ -0,0 +1,56 @@
1
+ [project]
2
+ name = "mcqpy"
3
+ version = "0.1.0"
4
+ description = "Multiple choice quiz generation and grading"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Mads-Peter", email = "machri@phys.au.dk" }
8
+ ]
9
+ requires-python = ">=3.13"
10
+ dependencies = [
11
+ "click>=8.3.0",
12
+ "matplotlib>=3.10.7",
13
+ "numpy>=2.3.3",
14
+ "openpyxl>=3.1.5",
15
+ "pandas>=2.3.3",
16
+ "pillow>=11.3.0",
17
+ "pydantic>=2.12.0",
18
+ "pylatex>=1.4.2",
19
+ "pylatexenc>=2.10",
20
+ "pypdf>=6.1.1",
21
+ "pyyaml>=6.0.3",
22
+ "requests>=2.28.0",
23
+ "rich>=14.2.0",
24
+ "rich-click>=1.9.3",
25
+ ]
26
+
27
+ [project.scripts]
28
+ mcqpy = "mcqpy.cli.main:main"
29
+
30
+ [build-system]
31
+ requires = ["uv_build>=0.8.18,<0.9.0"]
32
+ build-backend = "uv_build"
33
+
34
+ [tool.ruff]
35
+ line-length = 88
36
+
37
+
38
+ [tool.pytest.ini_options]
39
+ testpaths = ["pytest/"]
40
+ addopts = [
41
+ "--cov-report", "term",
42
+ "--cov-report", "html:.coverage-html",
43
+ "--cov-report", "xml:.coverage.xml",
44
+ "--junitxml", ".report.xml",
45
+ "--cov", "src/mcqpy"
46
+ ]
47
+
48
+ [tool.coverage.run]
49
+ omit = []
50
+
51
+ [dependency-groups]
52
+ dev = [
53
+ "pytest>=9.0.1",
54
+ "pytest-cov>=7.0.0",
55
+ "pytest-mock>=3.15.1",
56
+ ]
@@ -0,0 +1,2 @@
1
+ from .question import Question
2
+
@@ -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
@@ -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}")
@@ -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
+
@@ -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)
@@ -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()
@@ -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}")
@@ -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