mcqpy-core 0.2.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 (53) hide show
  1. mcqpy_core-0.2.0/PKG-INFO +26 -0
  2. mcqpy_core-0.2.0/README.md +5 -0
  3. mcqpy_core-0.2.0/pyproject.toml +36 -0
  4. mcqpy_core-0.2.0/src/mcqpy_core/__init__.py +14 -0
  5. mcqpy_core-0.2.0/src/mcqpy_core/cli/__init__.py +29 -0
  6. mcqpy_core-0.2.0/src/mcqpy_core/cli/_selection.py +31 -0
  7. mcqpy_core-0.2.0/src/mcqpy_core/cli/build.py +89 -0
  8. mcqpy_core-0.2.0/src/mcqpy_core/cli/check_latex.py +31 -0
  9. mcqpy_core-0.2.0/src/mcqpy_core/cli/config.py +167 -0
  10. mcqpy_core-0.2.0/src/mcqpy_core/cli/export/__init__.py +12 -0
  11. mcqpy_core-0.2.0/src/mcqpy_core/cli/export/main.py +11 -0
  12. mcqpy_core-0.2.0/src/mcqpy_core/cli/export/token.py +20 -0
  13. mcqpy_core-0.2.0/src/mcqpy_core/cli/export/web.py +73 -0
  14. mcqpy_core-0.2.0/src/mcqpy_core/cli/grade.py +100 -0
  15. mcqpy_core-0.2.0/src/mcqpy_core/cli/init.py +77 -0
  16. mcqpy_core-0.2.0/src/mcqpy_core/cli/main.py +11 -0
  17. mcqpy_core-0.2.0/src/mcqpy_core/cli/question/__init__.py +16 -0
  18. mcqpy_core-0.2.0/src/mcqpy_core/cli/question/check_tags.py +20 -0
  19. mcqpy_core-0.2.0/src/mcqpy_core/cli/question/init.py +25 -0
  20. mcqpy_core-0.2.0/src/mcqpy_core/cli/question/main.py +9 -0
  21. mcqpy_core-0.2.0/src/mcqpy_core/cli/question/render.py +59 -0
  22. mcqpy_core-0.2.0/src/mcqpy_core/cli/question/validate.py +25 -0
  23. mcqpy_core-0.2.0/src/mcqpy_core/cli/utils/__init__.py +3 -0
  24. mcqpy_core-0.2.0/src/mcqpy_core/cli/utils/autofill.py +61 -0
  25. mcqpy_core-0.2.0/src/mcqpy_core/cli/utils/check_filter.py +48 -0
  26. mcqpy_core-0.2.0/src/mcqpy_core/cli/utils/main.py +9 -0
  27. mcqpy_core-0.2.0/src/mcqpy_core/front_config.py +12 -0
  28. mcqpy_core-0.2.0/src/mcqpy_core/grading/__init__.py +23 -0
  29. mcqpy_core-0.2.0/src/mcqpy_core/grading/analysis/__init__.py +7 -0
  30. mcqpy_core-0.2.0/src/mcqpy_core/grading/analysis/overall_analysis.py +153 -0
  31. mcqpy_core-0.2.0/src/mcqpy_core/grading/analysis/question_analysis.py +89 -0
  32. mcqpy_core-0.2.0/src/mcqpy_core/grading/analysis/utils.py +7 -0
  33. mcqpy_core-0.2.0/src/mcqpy_core/grading/grader.py +44 -0
  34. mcqpy_core-0.2.0/src/mcqpy_core/grading/rubric.py +16 -0
  35. mcqpy_core-0.2.0/src/mcqpy_core/grading/types.py +67 -0
  36. mcqpy_core-0.2.0/src/mcqpy_core/header_config.py +12 -0
  37. mcqpy_core-0.2.0/src/mcqpy_core/manifest.py +98 -0
  38. mcqpy_core-0.2.0/src/mcqpy_core/question/__init__.py +31 -0
  39. mcqpy_core-0.2.0/src/mcqpy_core/question/filter/__init__.py +24 -0
  40. mcqpy_core-0.2.0/src/mcqpy_core/question/filter/base_filter.py +56 -0
  41. mcqpy_core-0.2.0/src/mcqpy_core/question/filter/date.py +127 -0
  42. mcqpy_core-0.2.0/src/mcqpy_core/question/filter/difficulty.py +74 -0
  43. mcqpy_core-0.2.0/src/mcqpy_core/question/filter/factory.py +36 -0
  44. mcqpy_core-0.2.0/src/mcqpy_core/question/filter/manifest.py +63 -0
  45. mcqpy_core-0.2.0/src/mcqpy_core/question/filter/slug.py +19 -0
  46. mcqpy_core-0.2.0/src/mcqpy_core/question/filter/stratified.py +72 -0
  47. mcqpy_core-0.2.0/src/mcqpy_core/question/filter/tag.py +56 -0
  48. mcqpy_core-0.2.0/src/mcqpy_core/question/question.py +396 -0
  49. mcqpy_core-0.2.0/src/mcqpy_core/question/question_bank.py +189 -0
  50. mcqpy_core-0.2.0/src/mcqpy_core/question/utils.py +95 -0
  51. mcqpy_core-0.2.0/src/mcqpy_core/web/__init__.py +28 -0
  52. mcqpy_core-0.2.0/src/mcqpy_core/web/bundle.py +253 -0
  53. mcqpy_core-0.2.0/src/mcqpy_core/web/token.py +29 -0
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.3
2
+ Name: mcqpy-core
3
+ Version: 0.2.0
4
+ Summary: Core question, manifest, grading, analysis, and browser-safe runtime logic for MCQPy
5
+ Author: Mads-Peter
6
+ Author-email: Mads-Peter <machri@phys.au.dk>
7
+ Requires-Dist: pydantic>=2.6,<2.12
8
+ Requires-Dist: matplotlib>=3.10.7 ; extra == 'analysis'
9
+ Requires-Dist: numpy>=2.3.3 ; extra == 'analysis'
10
+ Requires-Dist: scipy>=1.17.0 ; extra == 'analysis'
11
+ Requires-Dist: rich>=14.2.0 ; extra == 'cli'
12
+ Requires-Dist: rich-click>=1.9.3 ; extra == 'cli'
13
+ Requires-Dist: pandas>=2.3.3 ; extra == 'grading'
14
+ Requires-Dist: pyyaml>=6.0.3 ; extra == 'yaml'
15
+ Requires-Python: >=3.12
16
+ Provides-Extra: analysis
17
+ Provides-Extra: cli
18
+ Provides-Extra: grading
19
+ Provides-Extra: yaml
20
+ Description-Content-Type: text/markdown
21
+
22
+ # mcqpy-core
23
+
24
+ Core question, manifest, grading, analysis, and browser-safe runtime logic for MCQPy.
25
+
26
+ This package is built from its package-local source tree under `packages/mcqpy-core/src/mcqpy_core`.
@@ -0,0 +1,5 @@
1
+ # mcqpy-core
2
+
3
+ Core question, manifest, grading, analysis, and browser-safe runtime logic for MCQPy.
4
+
5
+ This package is built from its package-local source tree under `packages/mcqpy-core/src/mcqpy_core`.
@@ -0,0 +1,36 @@
1
+ [project]
2
+ name = "mcqpy-core"
3
+ version = "0.2.0"
4
+ description = "Core question, manifest, grading, analysis, and browser-safe runtime logic for MCQPy"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Mads-Peter", email = "machri@phys.au.dk" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "pydantic>=2.6,<2.12",
12
+ ]
13
+
14
+ [project.optional-dependencies]
15
+ yaml = [
16
+ "pyyaml>=6.0.3",
17
+ ]
18
+ grading = [
19
+ "pandas>=2.3.3",
20
+ ]
21
+ analysis = [
22
+ "matplotlib>=3.10.7",
23
+ "numpy>=2.3.3",
24
+ "scipy>=1.17.0",
25
+ ]
26
+ cli = [
27
+ "rich>=14.2.0",
28
+ "rich-click>=1.9.3",
29
+ ]
30
+
31
+ [build-system]
32
+ requires = ["uv_build>=0.8.18,<0.9.0"]
33
+ build-backend = "uv_build"
34
+
35
+ [tool.uv.build-backend]
36
+ module-root = "src"
@@ -0,0 +1,14 @@
1
+ """Core MCQPy models and logic."""
2
+
3
+ from importlib import import_module
4
+
5
+ from .manifest import Manifest, ManifestItem
6
+ from .question import Question
7
+
8
+ __all__ = ["Manifest", "ManifestItem", "Question", "QuestionBank"]
9
+
10
+
11
+ def __getattr__(name: str):
12
+ if name == "QuestionBank":
13
+ return import_module("mcqpy_core.question").QuestionBank
14
+ raise AttributeError(name)
@@ -0,0 +1,29 @@
1
+ """Command registration for mcqpy-core."""
2
+
3
+ from mcqpy_core.cli.main import main
4
+ from mcqpy_core.cli.export import export_group
5
+ from mcqpy_core.cli.export.token import decode_token_command, encode_token_command
6
+ from mcqpy_core.cli.export.web import export_web_command
7
+ from mcqpy_core.cli.init import init_command
8
+ from mcqpy_core.cli.question import question_group
9
+ from mcqpy_core.cli.utils.check_filter import check_filter_command
10
+ from mcqpy_core.cli.utils.main import utils_group
11
+
12
+
13
+ def register_core_commands(parent) -> None:
14
+ for name, command in main.commands.items():
15
+ parent.add_command(command, name)
16
+
17
+
18
+ __all__ = [
19
+ "register_core_commands",
20
+ "main",
21
+ "init_command",
22
+ "export_group",
23
+ "export_web_command",
24
+ "encode_token_command",
25
+ "decode_token_command",
26
+ "question_group",
27
+ "utils_group",
28
+ "check_filter_command",
29
+ ]
@@ -0,0 +1,31 @@
1
+ """Shared question-selection helpers for CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from mcqpy_core.cli.config import SelectionConfig
6
+ from mcqpy_core.question import QuestionBank
7
+ from mcqpy_core.question.filter import FilterFactory
8
+
9
+
10
+ def _build_filter(filter_name: str, filter_params: dict):
11
+ filter_config = {"type": filter_name, **filter_params}
12
+ return FilterFactory.from_config(filter_config)
13
+
14
+
15
+ def build_filters(selection_config: SelectionConfig):
16
+ filter_objs = []
17
+ if selection_config.filters:
18
+ for filter_name, filter_params in selection_config.filters.items():
19
+ filter_objs.append(_build_filter(filter_name, filter_params))
20
+ return filter_objs
21
+
22
+
23
+ def select_questions(question_bank: QuestionBank, selection_config: SelectionConfig):
24
+ for filter_obj in build_filters(selection_config):
25
+ question_bank.add_filter(filter_obj)
26
+
27
+ return question_bank.get_filtered_questions(
28
+ number_of_questions=selection_config.number_of_questions,
29
+ shuffle=selection_config.shuffle,
30
+ sorting=selection_config.sort_type,
31
+ )
@@ -0,0 +1,89 @@
1
+ """
2
+ CLI Build Command
3
+ """
4
+
5
+ from pathlib import Path
6
+
7
+ import rich_click as click
8
+ from rich.pretty import Pretty
9
+ from rich.console import Console
10
+
11
+ from mcqpy_core.cli._selection import select_questions, _build_filter, build_filters
12
+ from mcqpy_core.cli.config import QuizConfig
13
+ from mcqpy_core.cli.main import main
14
+ from mcqpy_pdf.compile import MultipleChoiceQuiz
15
+ from mcqpy_core.manifest import Manifest
16
+ from mcqpy_core.question import QuestionBank
17
+
18
+
19
+ # Backward-compatible private alias kept for older tests/importers.
20
+ _select_questions = select_questions
21
+
22
+
23
+ def build_solution(questions, manifest, output_path: Path):
24
+ from mcqpy_pdf.compile.solution_pdf import SolutionPDF
25
+
26
+ solution_pdf = SolutionPDF(file=output_path, questions=questions, manifest=manifest)
27
+ solution_pdf.build(generate_pdf=True)
28
+
29
+ @main.command(
30
+ name="build",
31
+ help="""Build the quiz PDF from question files based on the provided configuration.
32
+ This command reads the quiz configuration, selects questions from the question bank,
33
+ and generates the quiz PDF along with a solution PDF.
34
+ """,
35
+ )
36
+ @click.option(
37
+ "-c",
38
+ "--config",
39
+ type=click.Path(exists=True, path_type=Path),
40
+ default="config.yaml",
41
+ help="Path to the config file",
42
+ show_default=True,
43
+ )
44
+ def build_command(config):
45
+ """
46
+ Build the quiz PDF from question files based on the provided configuration.
47
+ This command reads the quiz configuration, selects questions from the question bank,
48
+ and generates the quiz PDF along with a solution PDF.
49
+
50
+ ```
51
+ mcqpy build --config config.yaml
52
+ ```
53
+ """
54
+
55
+ config = QuizConfig.read_yaml(config)
56
+ question_bank = QuestionBank.from_directories(
57
+ config.questions_paths, seed=config.selection.seed
58
+ )
59
+ questions = select_questions(question_bank, config.selection)
60
+
61
+ console = Console()
62
+ console.print("[bold green]Quiz Configuration:[/bold green]")
63
+ console.print(Pretty(config))
64
+ console.print(
65
+ f"[bold green]Total questions in bank:[/bold green] {len(question_bank)}"
66
+ )
67
+ console.print(f"[bold green]Selected questions:[/bold green] {len(questions)}")
68
+
69
+ ## Paths:
70
+ for path in [config.root_directory, config.output_directory, config.submission_directory]:
71
+ if path and not path.exists():
72
+ path.mkdir(parents=True, exist_ok=True) # pragma: no cover
73
+
74
+ mcq = MultipleChoiceQuiz(
75
+ file=config.file_path,
76
+ questions=questions,
77
+ front_matter=config.front_matter,
78
+ header_footer=config.header,
79
+ )
80
+
81
+ mcq.build(generate_pdf=True)
82
+
83
+ # Build solution PDF
84
+ manifest_path = mcq.get_manifest_path()
85
+ manifest = Manifest.load_from_file(manifest_path)
86
+ solution_output_path = (
87
+ config.output_directory / f"{config.file_name.replace('.pdf', '')}_solution.pdf"
88
+ )
89
+ build_solution(questions, manifest, solution_output_path)
@@ -0,0 +1,31 @@
1
+ """
2
+ CLI command to check LaTeX installation and configuration.
3
+ """
4
+
5
+ from mcqpy_core.cli import main
6
+
7
+ @main.command('check-latex', help="Check LaTeX installation and configuration.")
8
+ def check_latex_command():
9
+ """
10
+ Check if LaTeX is installed and properly configured.
11
+ Provides the CLI command:
12
+ ```
13
+ mcqpy check-latex
14
+ ```
15
+ This command verifies the presence of LaTeX tools such as `pdflatex` and `latexmk`,
16
+ and attempts to compile a simple LaTeX document to ensure everything is functioning correctly.
17
+ """
18
+
19
+ from mcqpy_pdf.utils.check_latex import check_latex_installation
20
+ from rich.console import Console
21
+
22
+ console = Console()
23
+ success, details = check_latex_installation()
24
+
25
+ if success:
26
+ console.print("[bold green]✓ LaTeX is properly installed![/bold green]")
27
+ console.print(f"[green]pdflatex version[/green]: {details['pdflatex'].version}")
28
+ console.print(f"[green]latexmk version[/green]: {details['latexmk'].version}")
29
+ console.print("[green]Compilation test passed successfully.[/green]")
30
+ else:
31
+ console.print(f"[bold red]✗ LaTeX installation issue: {details['error_message']}[/bold red]")
@@ -0,0 +1,167 @@
1
+ """Quiz configuration management using Pydantic and YAML."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any, Literal
5
+
6
+ from pydantic import BaseModel, Field, ConfigDict, field_validator, model_validator
7
+
8
+ from mcqpy_core.front_config import FrontMatterOptions
9
+ from mcqpy_core.header_config import HeaderFooterOptions
10
+
11
+
12
+ def _import_yaml():
13
+ try:
14
+ import yaml
15
+ except ModuleNotFoundError as exc: # pragma: no cover - environment dependent
16
+ raise ModuleNotFoundError(
17
+ "PyYAML is required for quiz config YAML operations. "
18
+ "Install mcqpy-core with the 'yaml' extra."
19
+ ) from exc
20
+ return yaml
21
+
22
+
23
+ class GradingConfig(BaseModel):
24
+ model_config = ConfigDict(extra="forbid", validate_assignment=True)
25
+ submission_directory: str = Field(
26
+ default="submissions", description="Directory for student submissions relative to quiz root directory"
27
+ )
28
+ anonymous_pattern: str | None = Field(
29
+ default=None,
30
+ description="Regex pattern to extract identifiers from anonymous exam filenames (e.g., 'exam_(?P<id1>\\w+)_(?P<id2>\\w+)\\.pdf')",
31
+ )
32
+ output_sort_key: str | None = Field(
33
+ default=None,
34
+ description="Key to sort output files, e.g., 'student_name' or 'student_id' or one based on the anonymous pattern",
35
+ )
36
+
37
+ @field_validator("anonymous_pattern")
38
+ @classmethod
39
+ def validate_pattern(cls, v):
40
+ """Validate that the pattern is a valid regex"""
41
+ if v is not None:
42
+ import re
43
+
44
+ try:
45
+ re.compile(v)
46
+ except re.error as e:
47
+ raise ValueError(f"Invalid regex pattern: {e}")
48
+ return v
49
+
50
+
51
+ class SelectionConfig(BaseModel):
52
+ model_config = ConfigDict(extra="forbid", validate_assignment=True)
53
+ number_of_questions: int | None = Field(
54
+ default=20, description="Number of questions to select"
55
+ )
56
+ filters: dict[str, dict[str, Any]] | None = Field(
57
+ default=None, description="Filters to apply when selecting questions"
58
+ )
59
+ seed: int | None = Field(
60
+ default=None, description="Random seed for question selection"
61
+ )
62
+ shuffle: bool = Field(
63
+ default=False, description="Whether to shuffle selected questions"
64
+ )
65
+ sort_type: Literal["slug", "none"] = Field(
66
+ default="none", description="Sort type for selected questions"
67
+ )
68
+
69
+
70
+ class QuizConfig(BaseModel):
71
+ model_config = ConfigDict(extra="forbid", validate_assignment=True, populate_by_name=True)
72
+ questions_paths_: list[str] | str = Field(
73
+ default=["questions"], description="Paths to question files or directories, relative to the config file's directory", alias="questions_paths"
74
+ )
75
+ file_name: str = Field(
76
+ default="quiz.pdf", description="Name of the output PDF file"
77
+ )
78
+
79
+ root_directory_: str = Field(
80
+ default=".", description="Root directory for the quiz project, relative to the location of the config file",
81
+ alias="root_directory"
82
+ )
83
+ output_directory_: str = Field(
84
+ default="output", description="Directory for output files, relative to the root directory", alias="output_directory"
85
+ )
86
+ grading: GradingConfig = Field(default_factory=GradingConfig)
87
+ front_matter: FrontMatterOptions = Field(default_factory=FrontMatterOptions)
88
+ header: HeaderFooterOptions = Field(default_factory=HeaderFooterOptions)
89
+ selection: SelectionConfig = Field(default_factory=SelectionConfig)
90
+ path: Path | None = Field(None, description="Internal: file path from which the config was loaded", exclude=True)
91
+
92
+ @property
93
+ def root_directory(self) -> Path:
94
+ """Get root directory as a Path object"""
95
+ return (Path(self.path).parent / self.root_directory_).resolve()
96
+
97
+ @property
98
+ def output_directory(self) -> Path:
99
+ """Get output directory as a Path object"""
100
+ return self.root_directory / self.output_directory_
101
+
102
+ @property
103
+ def questions_paths(self) -> list[Path]:
104
+ """Get questions paths as Path objects"""
105
+ return [(self.path.parent / p).resolve() for p in self.questions_paths_]
106
+
107
+ @property
108
+ def file_path(self) -> Path:
109
+ """Get full file path for the output PDF"""
110
+ return self.output_directory / self.file_name
111
+
112
+ @property
113
+ def submission_directory(self) -> Path:
114
+ """Get submission directory as a Path object"""
115
+ return self.root_directory / self.grading.submission_directory
116
+
117
+
118
+ @model_validator(mode="before")
119
+ @classmethod
120
+ def migrate_submission_directory(cls, data):
121
+ """Migrate submission_directory from top level to grading config"""
122
+ if isinstance(data, dict) and "submission_directory" in data:
123
+ if "grading" not in data:
124
+ data["grading"] = {}
125
+ data["grading"]["submission_directory"] = data.pop("submission_directory")
126
+ return data
127
+
128
+ @model_validator(mode="before")
129
+ def check_paths_exist(cls, data):
130
+ """Resolve questions_paths relative to the config file's directory"""
131
+ path = data.get("path")
132
+ if not path:
133
+ return data
134
+
135
+ config_dir = path.parent
136
+
137
+ # Questions:
138
+ paths_to_check = data.get("questions_paths", []) + data.get("questions_paths_", [])
139
+ for path in paths_to_check:
140
+ resolved_path = (config_dir / path).resolve()
141
+ if not resolved_path.exists():
142
+ raise FileNotFoundError(f"Questions path does not exist: {resolved_path}")
143
+
144
+ return data
145
+
146
+ def yaml_dump(self) -> str:
147
+ """Dump the current configuration to a YAML string"""
148
+ yaml = _import_yaml()
149
+ config_dict = self.model_dump(by_alias=True)
150
+ yaml_content = yaml.dump(config_dict, default_flow_style=False, sort_keys=False)
151
+ return yaml_content
152
+
153
+ @classmethod
154
+ def generate_example_yaml(cls) -> str:
155
+ """Generate example YAML with comments"""
156
+ example = cls()
157
+ return example.yaml_dump()
158
+
159
+ @classmethod
160
+ def read_yaml(cls, file_path: str) -> "QuizConfig":
161
+ """Read YAML file and return a QuizConfig instance"""
162
+ yaml = _import_yaml()
163
+ with open(file_path, "r") as file:
164
+ yaml_string = file.read()
165
+ data = yaml.safe_load(yaml_string)
166
+ data['path'] = Path(file_path).resolve()
167
+ return cls(**data)
@@ -0,0 +1,12 @@
1
+ """Export command registrations."""
2
+
3
+ from .main import export_group
4
+ from .token import decode_token_command, encode_token_command
5
+ from .web import export_web_command
6
+
7
+ __all__ = [
8
+ "export_group",
9
+ "encode_token_command",
10
+ "decode_token_command",
11
+ "export_web_command",
12
+ ]
@@ -0,0 +1,11 @@
1
+ """CLI group for web export utilities."""
2
+
3
+ import rich_click as click
4
+
5
+ from mcqpy_core.cli.main import main
6
+
7
+
8
+ @main.group(name="export")
9
+ def export_group() -> None:
10
+ """Commands for exporting browser-ready quiz artifacts."""
11
+ return None # pragma: no cover
@@ -0,0 +1,20 @@
1
+ """CLI helpers for quiz link obfuscation tokens."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import rich_click as click
6
+
7
+ from mcqpy_core.cli.export.main import export_group
8
+ from mcqpy_core.web import decode_quiz_token, encode_quiz_token
9
+
10
+
11
+ @export_group.command(name="encode-token", help="Encode a public quiz bundle URL.")
12
+ @click.argument("url")
13
+ def encode_token_command(url: str) -> None:
14
+ click.echo(encode_quiz_token(url))
15
+
16
+
17
+ @export_group.command(name="decode-token", help="Decode a quiz token to its public URL.")
18
+ @click.argument("token")
19
+ def decode_token_command(token: str) -> None:
20
+ click.echo(decode_quiz_token(token))
@@ -0,0 +1,73 @@
1
+ """Export browser-ready quiz bundles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import rich_click as click
8
+ from rich.console import Console
9
+
10
+ from mcqpy_core.cli._selection import select_questions
11
+ from mcqpy_core.cli.config import QuizConfig
12
+ from mcqpy_core.cli.export.main import export_group
13
+ from mcqpy_core.question import QuestionBank
14
+ from mcqpy_core.web import build_web_quiz_bundle
15
+
16
+
17
+ def _default_web_output_dir(config: QuizConfig) -> Path:
18
+ quiz_stem = Path(config.file_name).stem
19
+ return config.output_directory / "web" / quiz_stem
20
+
21
+
22
+ @export_group.command(name="web", help="Export a static web quiz bundle.")
23
+ @click.option(
24
+ "-c",
25
+ "--config",
26
+ type=click.Path(exists=True, path_type=Path),
27
+ default="config.yaml",
28
+ show_default=True,
29
+ help="Path to the quiz config file.",
30
+ )
31
+ @click.option(
32
+ "-o",
33
+ "--output-dir",
34
+ type=click.Path(path_type=Path),
35
+ default=None,
36
+ help="Directory for the exported bundle. Defaults to output/web/<quiz-name>/.",
37
+ )
38
+ @click.option(
39
+ "--asset-base-url",
40
+ default=None,
41
+ help="Optional published base URL used to rewrite copied local assets.",
42
+ )
43
+ def export_web_command(
44
+ config: Path,
45
+ output_dir: Path | None,
46
+ asset_base_url: str | None,
47
+ ) -> None:
48
+ """Export selected questions as a browser-ready quiz bundle."""
49
+
50
+ quiz_config = QuizConfig.read_yaml(config)
51
+ target_dir = output_dir or _default_web_output_dir(quiz_config)
52
+ target_dir.mkdir(parents=True, exist_ok=True)
53
+
54
+ question_bank = QuestionBank.from_directories(
55
+ quiz_config.questions_paths,
56
+ seed=quiz_config.selection.seed,
57
+ )
58
+ questions = select_questions(question_bank, quiz_config.selection)
59
+
60
+ bundle = build_web_quiz_bundle(
61
+ questions,
62
+ title=quiz_config.front_matter.title or Path(quiz_config.file_name).stem,
63
+ description=quiz_config.front_matter.exam_information,
64
+ source=str(config),
65
+ asset_dir=target_dir / "assets",
66
+ asset_base_url=asset_base_url,
67
+ )
68
+ bundle_path = bundle.save_to_file(target_dir / "quiz.json")
69
+
70
+ console = Console()
71
+ console.print(f"[bold green]Exported web bundle:[/bold green] {bundle_path}")
72
+ console.print(f"[bold green]Questions:[/bold green] {len(bundle.questions)}")
73
+ console.print(f"[bold green]Assets directory:[/bold green] {target_dir / 'assets'}")
@@ -0,0 +1,100 @@
1
+ """Grade student submissions from a quiz."""
2
+
3
+ import rich_click as click
4
+ from mcqpy_core.cli.main import main
5
+ from mcqpy_core.cli.config import QuizConfig
6
+ from pathlib import Path
7
+
8
+ from mcqpy_core.grading import MCQGrader, get_grade_dataframe
9
+ from mcqpy_core.manifest import Manifest
10
+ from mcqpy_core.grading.rubric import StrictRubric
11
+ from rich.progress import track
12
+
13
+ from mcqpy_core.question.question_bank import QuestionBank
14
+
15
+
16
+ @main.command(
17
+ name="grade",
18
+ help="""Grade student submissions.
19
+ Generates a grade report in the specified format (Excel or CSV).
20
+ Students submissions are expected to be in submission directory specified in the config file.
21
+ """,
22
+ )
23
+ @click.option(
24
+ "-c",
25
+ "--config",
26
+ type=click.Path(exists=True, path_type=Path),
27
+ default="config.yaml",
28
+ help="Path to the config file",
29
+ show_default=True,
30
+ )
31
+ @click.option("-v", "--verbose", is_flag=True, help="Enable verbose output")
32
+ @click.option(
33
+ "-f",
34
+ "--file-format",
35
+ type=click.Choice(["xlsx", "csv"]),
36
+ default="xlsx",
37
+ help="Output format for the grades",
38
+ show_default=True,
39
+ )
40
+ @click.option(
41
+ "-a",
42
+ "--analysis",
43
+ is_flag=True,
44
+ help="Generate question analysis reports",
45
+ default=False,
46
+ )
47
+ def grade_command(config, verbose: bool, file_format: str, analysis: bool):
48
+ """
49
+ Grade student submissions based on the provided configuration file.
50
+ Generates a grade report in the specified format (Excel or CSV).
51
+ Students submissions are expected to be in submission directory specified in the config file.
52
+
53
+ Provides the CLI command:
54
+ ```
55
+ mcqpy grade --config config.yaml --file-format xlsx --analysis
56
+ ```
57
+ """
58
+ # Load config
59
+ config = QuizConfig.read_yaml(config)
60
+ manifest_path = config.output_directory / f"{config.file_path.stem}_manifest.json"
61
+ manifest = Manifest.load_from_file(manifest_path)
62
+
63
+ # Read & Grade submissions
64
+ graded_sets = []
65
+ grader = MCQGrader(
66
+ manifest, StrictRubric(), regex_pattern=config.grading.anonymous_pattern
67
+ )
68
+ submissions = list(config.submission_directory.glob("*.pdf"))
69
+ for submission in track(
70
+ submissions,
71
+ description=f"Grading submissions ({len(submissions)})",
72
+ total=len(submissions),
73
+ ):
74
+ graded_set = grader.grade(submission)
75
+ graded_sets.append(graded_set)
76
+
77
+ # Export grades to dataframe
78
+ df = get_grade_dataframe(graded_sets, sort_key=config.grading.output_sort_key)
79
+ output_path = (config.root_directory / f"{config.file_path.stem}_grades.{file_format}")
80
+ if file_format == "xlsx":
81
+ df.to_excel(output_path, index=False)
82
+ elif file_format == "csv":
83
+ df.to_csv(output_path, index=False)
84
+
85
+ if analysis:
86
+ from mcqpy_pdf.analysis.report import QuizAnalysis
87
+
88
+ analysis_directory = config.root_directory / "analysis/"
89
+ analysis_directory.mkdir(exist_ok=True)
90
+
91
+ question_bank = QuestionBank.from_directories(config.questions_paths)
92
+ print(f"Question bank loaded for analysis - {len(question_bank)}")
93
+
94
+ quiz_analysis = QuizAnalysis(
95
+ graded_sets,
96
+ question_bank=question_bank,
97
+ output_dir=analysis_directory,
98
+ grading_config=config.grading,
99
+ )
100
+ quiz_analysis.build()