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.
- mcqpy_core-0.2.0/PKG-INFO +26 -0
- mcqpy_core-0.2.0/README.md +5 -0
- mcqpy_core-0.2.0/pyproject.toml +36 -0
- mcqpy_core-0.2.0/src/mcqpy_core/__init__.py +14 -0
- mcqpy_core-0.2.0/src/mcqpy_core/cli/__init__.py +29 -0
- mcqpy_core-0.2.0/src/mcqpy_core/cli/_selection.py +31 -0
- mcqpy_core-0.2.0/src/mcqpy_core/cli/build.py +89 -0
- mcqpy_core-0.2.0/src/mcqpy_core/cli/check_latex.py +31 -0
- mcqpy_core-0.2.0/src/mcqpy_core/cli/config.py +167 -0
- mcqpy_core-0.2.0/src/mcqpy_core/cli/export/__init__.py +12 -0
- mcqpy_core-0.2.0/src/mcqpy_core/cli/export/main.py +11 -0
- mcqpy_core-0.2.0/src/mcqpy_core/cli/export/token.py +20 -0
- mcqpy_core-0.2.0/src/mcqpy_core/cli/export/web.py +73 -0
- mcqpy_core-0.2.0/src/mcqpy_core/cli/grade.py +100 -0
- mcqpy_core-0.2.0/src/mcqpy_core/cli/init.py +77 -0
- mcqpy_core-0.2.0/src/mcqpy_core/cli/main.py +11 -0
- mcqpy_core-0.2.0/src/mcqpy_core/cli/question/__init__.py +16 -0
- mcqpy_core-0.2.0/src/mcqpy_core/cli/question/check_tags.py +20 -0
- mcqpy_core-0.2.0/src/mcqpy_core/cli/question/init.py +25 -0
- mcqpy_core-0.2.0/src/mcqpy_core/cli/question/main.py +9 -0
- mcqpy_core-0.2.0/src/mcqpy_core/cli/question/render.py +59 -0
- mcqpy_core-0.2.0/src/mcqpy_core/cli/question/validate.py +25 -0
- mcqpy_core-0.2.0/src/mcqpy_core/cli/utils/__init__.py +3 -0
- mcqpy_core-0.2.0/src/mcqpy_core/cli/utils/autofill.py +61 -0
- mcqpy_core-0.2.0/src/mcqpy_core/cli/utils/check_filter.py +48 -0
- mcqpy_core-0.2.0/src/mcqpy_core/cli/utils/main.py +9 -0
- mcqpy_core-0.2.0/src/mcqpy_core/front_config.py +12 -0
- mcqpy_core-0.2.0/src/mcqpy_core/grading/__init__.py +23 -0
- mcqpy_core-0.2.0/src/mcqpy_core/grading/analysis/__init__.py +7 -0
- mcqpy_core-0.2.0/src/mcqpy_core/grading/analysis/overall_analysis.py +153 -0
- mcqpy_core-0.2.0/src/mcqpy_core/grading/analysis/question_analysis.py +89 -0
- mcqpy_core-0.2.0/src/mcqpy_core/grading/analysis/utils.py +7 -0
- mcqpy_core-0.2.0/src/mcqpy_core/grading/grader.py +44 -0
- mcqpy_core-0.2.0/src/mcqpy_core/grading/rubric.py +16 -0
- mcqpy_core-0.2.0/src/mcqpy_core/grading/types.py +67 -0
- mcqpy_core-0.2.0/src/mcqpy_core/header_config.py +12 -0
- mcqpy_core-0.2.0/src/mcqpy_core/manifest.py +98 -0
- mcqpy_core-0.2.0/src/mcqpy_core/question/__init__.py +31 -0
- mcqpy_core-0.2.0/src/mcqpy_core/question/filter/__init__.py +24 -0
- mcqpy_core-0.2.0/src/mcqpy_core/question/filter/base_filter.py +56 -0
- mcqpy_core-0.2.0/src/mcqpy_core/question/filter/date.py +127 -0
- mcqpy_core-0.2.0/src/mcqpy_core/question/filter/difficulty.py +74 -0
- mcqpy_core-0.2.0/src/mcqpy_core/question/filter/factory.py +36 -0
- mcqpy_core-0.2.0/src/mcqpy_core/question/filter/manifest.py +63 -0
- mcqpy_core-0.2.0/src/mcqpy_core/question/filter/slug.py +19 -0
- mcqpy_core-0.2.0/src/mcqpy_core/question/filter/stratified.py +72 -0
- mcqpy_core-0.2.0/src/mcqpy_core/question/filter/tag.py +56 -0
- mcqpy_core-0.2.0/src/mcqpy_core/question/question.py +396 -0
- mcqpy_core-0.2.0/src/mcqpy_core/question/question_bank.py +189 -0
- mcqpy_core-0.2.0/src/mcqpy_core/question/utils.py +95 -0
- mcqpy_core-0.2.0/src/mcqpy_core/web/__init__.py +28 -0
- mcqpy_core-0.2.0/src/mcqpy_core/web/bundle.py +253 -0
- 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,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()
|