mcqpy-pdf 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 (42) hide show
  1. mcqpy_pdf-0.2.0/PKG-INFO +23 -0
  2. mcqpy_pdf-0.2.0/README.md +5 -0
  3. mcqpy_pdf-0.2.0/pyproject.toml +30 -0
  4. mcqpy_pdf-0.2.0/src/mcqpy_pdf/__init__.py +6 -0
  5. mcqpy_pdf-0.2.0/src/mcqpy_pdf/analysis/report.py +168 -0
  6. mcqpy_pdf-0.2.0/src/mcqpy_pdf/cli/__init__.py +24 -0
  7. mcqpy_pdf-0.2.0/src/mcqpy_pdf/cli/_selection.py +31 -0
  8. mcqpy_pdf-0.2.0/src/mcqpy_pdf/cli/build.py +89 -0
  9. mcqpy_pdf-0.2.0/src/mcqpy_pdf/cli/check_latex.py +31 -0
  10. mcqpy_pdf-0.2.0/src/mcqpy_pdf/cli/config.py +154 -0
  11. mcqpy_pdf-0.2.0/src/mcqpy_pdf/cli/export/__init__.py +12 -0
  12. mcqpy_pdf-0.2.0/src/mcqpy_pdf/cli/export/main.py +11 -0
  13. mcqpy_pdf-0.2.0/src/mcqpy_pdf/cli/export/token.py +20 -0
  14. mcqpy_pdf-0.2.0/src/mcqpy_pdf/cli/export/web.py +73 -0
  15. mcqpy_pdf-0.2.0/src/mcqpy_pdf/cli/grade.py +102 -0
  16. mcqpy_pdf-0.2.0/src/mcqpy_pdf/cli/init.py +77 -0
  17. mcqpy_pdf-0.2.0/src/mcqpy_pdf/cli/main.py +11 -0
  18. mcqpy_pdf-0.2.0/src/mcqpy_pdf/cli/question/__init__.py +8 -0
  19. mcqpy_pdf-0.2.0/src/mcqpy_pdf/cli/question/check_tags.py +20 -0
  20. mcqpy_pdf-0.2.0/src/mcqpy_pdf/cli/question/init.py +25 -0
  21. mcqpy_pdf-0.2.0/src/mcqpy_pdf/cli/question/main.py +9 -0
  22. mcqpy_pdf-0.2.0/src/mcqpy_pdf/cli/question/render.py +60 -0
  23. mcqpy_pdf-0.2.0/src/mcqpy_pdf/cli/question/validate.py +25 -0
  24. mcqpy_pdf-0.2.0/src/mcqpy_pdf/cli/utils/__init__.py +4 -0
  25. mcqpy_pdf-0.2.0/src/mcqpy_pdf/cli/utils/autofill.py +61 -0
  26. mcqpy_pdf-0.2.0/src/mcqpy_pdf/cli/utils/check_filter.py +49 -0
  27. mcqpy_pdf-0.2.0/src/mcqpy_pdf/cli/utils/main.py +9 -0
  28. mcqpy_pdf-0.2.0/src/mcqpy_pdf/compile/__init__.py +12 -0
  29. mcqpy_pdf-0.2.0/src/mcqpy_pdf/compile/front_config.py +12 -0
  30. mcqpy_pdf-0.2.0/src/mcqpy_pdf/compile/header_config.py +13 -0
  31. mcqpy_pdf-0.2.0/src/mcqpy_pdf/compile/latex_helpers.py +59 -0
  32. mcqpy_pdf-0.2.0/src/mcqpy_pdf/compile/latex_questions.py +207 -0
  33. mcqpy_pdf-0.2.0/src/mcqpy_pdf/compile/manifest.py +5 -0
  34. mcqpy_pdf-0.2.0/src/mcqpy_pdf/compile/mcq.py +197 -0
  35. mcqpy_pdf-0.2.0/src/mcqpy_pdf/compile/preamble.py +21 -0
  36. mcqpy_pdf-0.2.0/src/mcqpy_pdf/compile/solution_pdf.py +107 -0
  37. mcqpy_pdf-0.2.0/src/mcqpy_pdf/grader.py +21 -0
  38. mcqpy_pdf-0.2.0/src/mcqpy_pdf/parse_pdf.py +104 -0
  39. mcqpy_pdf-0.2.0/src/mcqpy_pdf/utils/__init__.py +1 -0
  40. mcqpy_pdf-0.2.0/src/mcqpy_pdf/utils/check_latex.py +86 -0
  41. mcqpy_pdf-0.2.0/src/mcqpy_pdf/utils/fill_form.py +90 -0
  42. mcqpy_pdf-0.2.0/src/mcqpy_pdf/utils/image.py +92 -0
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.3
2
+ Name: mcqpy-pdf
3
+ Version: 0.2.0
4
+ Summary: PDF compilation, parsing, and report rendering for MCQPy
5
+ Author: Mads-Peter
6
+ Author-email: Mads-Peter <machri@phys.au.dk>
7
+ Requires-Dist: mcqpy-core[analysis,grading,yaml]>=0.1.1
8
+ Requires-Dist: click>=8.3.0
9
+ Requires-Dist: openpyxl>=3.1.5
10
+ Requires-Dist: pylatex>=1.4.2
11
+ Requires-Dist: pylatexenc>=2.10
12
+ Requires-Dist: pypdf>=6.1.1
13
+ Requires-Dist: rich>=14.2.0
14
+ Requires-Dist: rich-click>=1.9.3
15
+ Requires-Dist: requests
16
+ Requires-Python: >=3.12
17
+ Description-Content-Type: text/markdown
18
+
19
+ # mcqpy-pdf
20
+
21
+ PDF compilation, parsing, and report rendering package for MCQPy.
22
+
23
+ This package is built from its package-local source tree under `packages/mcqpy-pdf/src/mcqpy_pdf`.
@@ -0,0 +1,5 @@
1
+ # mcqpy-pdf
2
+
3
+ PDF compilation, parsing, and report rendering package for MCQPy.
4
+
5
+ This package is built from its package-local source tree under `packages/mcqpy-pdf/src/mcqpy_pdf`.
@@ -0,0 +1,30 @@
1
+ [project]
2
+ name = "mcqpy-pdf"
3
+ version = "0.2.0"
4
+ description = "PDF compilation, parsing, and report rendering 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
+ "mcqpy-core[analysis,grading,yaml]>=0.1.1",
12
+ "click>=8.3.0",
13
+ "openpyxl>=3.1.5",
14
+ "pylatex>=1.4.2",
15
+ "pylatexenc>=2.10",
16
+ "pypdf>=6.1.1",
17
+ "rich>=14.2.0",
18
+ "rich-click>=1.9.3",
19
+ "requests",
20
+ ]
21
+
22
+ [tool.uv.sources]
23
+ mcqpy-core = { workspace = true }
24
+
25
+ [build-system]
26
+ requires = ["uv_build>=0.8.18,<0.9.0"]
27
+ build-backend = "uv_build"
28
+
29
+ [tool.uv.build-backend]
30
+ module-root = "src"
@@ -0,0 +1,6 @@
1
+ """PDF-specific MCQPy functionality."""
2
+
3
+ from .grader import grade_pdf
4
+ from .parse_pdf import MCQPDFParser
5
+
6
+ __all__ = ["MCQPDFParser", "grade_pdf"]
@@ -0,0 +1,168 @@
1
+ import re
2
+ from pathlib import Path
3
+
4
+ from pylatex import (
5
+ Command,
6
+ Document,
7
+ Enumerate,
8
+ Figure,
9
+ LongTable,
10
+ MultiColumn,
11
+ NewPage,
12
+ NoEscape,
13
+ Package,
14
+ Section,
15
+ )
16
+ from rich.console import Console
17
+ from rich.progress import track
18
+
19
+ from mcqpy_pdf.cli.config import GradingConfig
20
+ from mcqpy_pdf.compile.preamble import add_preamble
21
+ from mcqpy_core.grading.types import GradedSet, get_grade_dataframe
22
+ from mcqpy_core.question import QuestionBank
23
+ from mcqpy_core.grading.analysis.question_analysis import question_analysis
24
+ from mcqpy_core.grading.analysis.overall_analysis import make_quiz_analysis
25
+
26
+
27
+ class QuizAnalysis(Document):
28
+ def __init__(
29
+ self,
30
+ graded_sets: list[GradedSet],
31
+ question_bank: QuestionBank,
32
+ output_dir: str | Path = None,
33
+ console: Console = None,
34
+ grading_config: GradingConfig | None = None,
35
+ ):
36
+ super().__init__(
37
+ documentclass="article",
38
+ geometry_options={
39
+ "paper": "a4paper",
40
+ "includeheadfoot": True,
41
+ "left": "2cm",
42
+ "right": "3cm",
43
+ "top": "2.5cm",
44
+ "bottom": "2.5cm",
45
+ },
46
+ )
47
+ self.graded_sets = graded_sets
48
+ self.output_dir = Path(output_dir)
49
+ self.figure_directory = self.output_dir / "figures"
50
+ self.figure_directory.mkdir(parents=True, exist_ok=True)
51
+ self.console = console or Console()
52
+ self.question_bank = question_bank
53
+ self.grading_config = grading_config or GradingConfig()
54
+
55
+ def build(self):
56
+ # Added TOC
57
+ add_preamble(self)
58
+ self.preamble.append(Package("xcolor", options=["dvipsnames"]))
59
+ self.preamble.append(Command("title", "Quiz Analysis Report"))
60
+ self.preamble.append(Command("author", "MCQPy"))
61
+ self.preamble.append(Command("date", NoEscape(r"\today")))
62
+ self.append(NoEscape(r"\maketitle"))
63
+ self.append(NoEscape(r"\tableofcontents"))
64
+ self.append(NewPage())
65
+
66
+ self.console.log("Building quiz analysis...")
67
+ self.build_quiz_analysis()
68
+ self.console.log("Building question analysis...")
69
+ self.build_question_analyses()
70
+ self.console.log("Building grade table...")
71
+ self.build_grade_table()
72
+
73
+ self.console.log("Generating PDF...")
74
+ self.generate_pdf(self.output_dir / "quiz_analysis", clean_tex=True)
75
+ self.console.log("Finished generating PDF.")
76
+
77
+ def build_quiz_analysis(self):
78
+ figures = make_quiz_analysis(self.graded_sets, self.figure_directory)
79
+ with self.create(Section("Overall Quiz Analysis", numbering="0")):
80
+ for figure in figures:
81
+ with self.create(Figure(position="h!")) as fig:
82
+ fig.add_image(
83
+ (Path("figures") / figure.name).as_posix(),
84
+ width=NoEscape(r"0.8\textwidth"),
85
+ )
86
+ fig.add_caption(NoEscape(figure.caption))
87
+
88
+ self.append(NoEscape(r"\clearpage"))
89
+ self.append(NewPage())
90
+
91
+ def build_question_analyses(self):
92
+ num_questions = len(self.graded_sets[0].graded_questions)
93
+ for q_index in track(
94
+ range(num_questions), description="Generating question analyses"
95
+ ):
96
+ with self.create(Section(f"Question {q_index + 1} Analysis")):
97
+ graded_questions = [
98
+ gs.graded_questions[q_index] for gs in self.graded_sets
99
+ ]
100
+
101
+ figures = question_analysis(
102
+ graded_questions=graded_questions,
103
+ graded_sets=self.graded_sets,
104
+ out_directory=self.figure_directory,
105
+ )
106
+
107
+ question = self.question_bank.get_by_qid(graded_questions[0].qid)
108
+
109
+ self.append(NoEscape(question.text))
110
+
111
+ with self.create(
112
+ Enumerate(enumeration_symbol=r"(\alph*)", options={})
113
+ ) as enum:
114
+ for choice in question.choices:
115
+ enum.add_item(NoEscape(choice))
116
+
117
+ for figure in figures:
118
+ with self.create(Figure(position="h!")) as fig:
119
+ self.append(NoEscape(r"\centering"))
120
+ fig.add_image(
121
+ (Path("figures") / figure.name).as_posix(), width=NoEscape(r"0.8\textwidth")
122
+ )
123
+ fig.add_caption(
124
+ f"Analysis for Question {q_index + 1}: {figure.caption}"
125
+ )
126
+
127
+ self.append(NewPage())
128
+
129
+ def build_grade_table(self):
130
+ df = get_grade_dataframe(
131
+ self.graded_sets, sort_key=self.grading_config.output_sort_key
132
+ )
133
+
134
+ keys = []
135
+
136
+ for key in df.keys().tolist():
137
+ if re.match(r"Q\d+_points", key) or key in ["max_points"]:
138
+ continue
139
+ keys.append(key)
140
+
141
+ with self.create(Section("Grade Summary Table")):
142
+ with self.create(LongTable("l" * len(keys))) as data_table:
143
+ data_table.add_hline()
144
+ data_table.add_row(keys)
145
+ data_table.add_hline()
146
+ data_table.end_table_header()
147
+ data_table.add_hline()
148
+ data_table.add_row(
149
+ (MultiColumn(len(keys), align="r", data="Continued on Next Page"),)
150
+ )
151
+ data_table.add_hline()
152
+ data_table.end_table_footer()
153
+ data_table.add_hline()
154
+ data_table.add_row(
155
+ (
156
+ MultiColumn(
157
+ len(keys), align="r", data="Not Continued on Next Page"
158
+ ),
159
+ )
160
+ )
161
+ data_table.add_hline()
162
+ data_table.end_table_last_footer()
163
+
164
+ for row in df.itertuples(index=False):
165
+ row_data = []
166
+ for key in keys:
167
+ row_data.append(getattr(row, key))
168
+ data_table.add_row(row_data)
@@ -0,0 +1,24 @@
1
+ """Command registration for mcqpy-pdf."""
2
+
3
+ from mcqpy_pdf.cli.main import main
4
+ from mcqpy_pdf.cli.build import build_command
5
+ from mcqpy_pdf.cli.check_latex import check_latex_command
6
+ from mcqpy_pdf.cli.grade import grade_command
7
+ from mcqpy_pdf.cli.utils.autofill import autofill_command
8
+ from mcqpy_pdf.cli.utils.main import utils_group
9
+
10
+
11
+ def register_pdf_commands(parent) -> None:
12
+ for name, command in main.commands.items():
13
+ parent.add_command(command, name)
14
+
15
+
16
+ __all__ = [
17
+ "register_pdf_commands",
18
+ "main",
19
+ "build_command",
20
+ "grade_command",
21
+ "check_latex_command",
22
+ "utils_group",
23
+ "autofill_command",
24
+ ]
@@ -0,0 +1,31 @@
1
+ """Shared question-selection helpers for CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from mcqpy_pdf.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_pdf.cli._selection import select_questions, _build_filter, build_filters
12
+ from mcqpy_pdf.cli.config import QuizConfig
13
+ from mcqpy_pdf.cli.main import main
14
+ from mcqpy_pdf.compile import MultipleChoiceQuiz
15
+ from mcqpy_pdf.compile.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, progress_bar=True
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_pdf.cli.main 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,154 @@
1
+ """
2
+ Quiz configuration management using Pydantic and YAML.
3
+ """
4
+
5
+ from pydantic import BaseModel, Field, ConfigDict, field_validator, model_validator
6
+ import yaml
7
+ from mcqpy_pdf.compile import HeaderFooterOptions, FrontMatterOptions
8
+ from typing import Any, Literal
9
+ from pathlib import Path
10
+
11
+
12
+ class GradingConfig(BaseModel):
13
+ model_config = ConfigDict(extra="forbid", validate_assignment=True)
14
+ submission_directory: str = Field(
15
+ default="submissions", description="Directory for student submissions relative to quiz root directory"
16
+ )
17
+ anonymous_pattern: str | None = Field(
18
+ default=None,
19
+ description="Regex pattern to extract identifiers from anonymous exam filenames (e.g., 'exam_(?P<id1>\\w+)_(?P<id2>\\w+)\\.pdf')",
20
+ )
21
+ output_sort_key: str | None = Field(
22
+ default=None,
23
+ description="Key to sort output files, e.g., 'student_name' or 'student_id' or one based on the anonymous pattern",
24
+ )
25
+
26
+ @field_validator("anonymous_pattern")
27
+ @classmethod
28
+ def validate_pattern(cls, v):
29
+ """Validate that the pattern is a valid regex"""
30
+ if v is not None:
31
+ import re
32
+
33
+ try:
34
+ re.compile(v)
35
+ except re.error as e:
36
+ raise ValueError(f"Invalid regex pattern: {e}")
37
+ return v
38
+
39
+
40
+ class SelectionConfig(BaseModel):
41
+ model_config = ConfigDict(extra="forbid", validate_assignment=True)
42
+ number_of_questions: int | None = Field(
43
+ default=20, description="Number of questions to select"
44
+ )
45
+ filters: dict[str, dict[str, Any]] | None = Field(
46
+ default=None, description="Filters to apply when selecting questions"
47
+ )
48
+ seed: int | None = Field(
49
+ default=None, description="Random seed for question selection"
50
+ )
51
+ shuffle: bool = Field(
52
+ default=False, description="Whether to shuffle selected questions"
53
+ )
54
+ sort_type: Literal["slug", "none"] = Field(
55
+ default="none", description="Sort type for selected questions"
56
+ )
57
+
58
+
59
+ class QuizConfig(BaseModel):
60
+ model_config = ConfigDict(extra="forbid", validate_assignment=True, populate_by_name=True)
61
+ questions_paths_: list[str] | str = Field(
62
+ default=["questions"], description="Paths to question files or directories, relative to the config file's directory", alias="questions_paths"
63
+ )
64
+ file_name: str = Field(
65
+ default="quiz.pdf", description="Name of the output PDF file"
66
+ )
67
+
68
+ root_directory_: str = Field(
69
+ default=".", description="Root directory for the quiz project, relative to the location of the config file",
70
+ alias="root_directory"
71
+ )
72
+ output_directory_: str = Field(
73
+ default="output", description="Directory for output files, relative to the root directory", alias="output_directory"
74
+ )
75
+ grading: GradingConfig = Field(default_factory=GradingConfig)
76
+ front_matter: FrontMatterOptions = Field(default_factory=FrontMatterOptions)
77
+ header: HeaderFooterOptions = Field(default_factory=HeaderFooterOptions)
78
+ selection: SelectionConfig = Field(default_factory=SelectionConfig)
79
+ path: Path | None = Field(None, description="Internal: file path from which the config was loaded", exclude=True)
80
+
81
+ @property
82
+ def root_directory(self) -> Path:
83
+ """Get root directory as a Path object"""
84
+ return (Path(self.path).parent / self.root_directory_).resolve()
85
+
86
+ @property
87
+ def output_directory(self) -> Path:
88
+ """Get output directory as a Path object"""
89
+ return self.root_directory / self.output_directory_
90
+
91
+ @property
92
+ def questions_paths(self) -> list[Path]:
93
+ """Get questions paths as Path objects"""
94
+ return [(self.path.parent / p).resolve() for p in self.questions_paths_]
95
+
96
+ @property
97
+ def file_path(self) -> Path:
98
+ """Get full file path for the output PDF"""
99
+ return self.output_directory / self.file_name
100
+
101
+ @property
102
+ def submission_directory(self) -> Path:
103
+ """Get submission directory as a Path object"""
104
+ return self.root_directory / self.grading.submission_directory
105
+
106
+
107
+ @model_validator(mode="before")
108
+ @classmethod
109
+ def migrate_submission_directory(cls, data):
110
+ """Migrate submission_directory from top level to grading config"""
111
+ if isinstance(data, dict) and "submission_directory" in data:
112
+ if "grading" not in data:
113
+ data["grading"] = {}
114
+ data["grading"]["submission_directory"] = data.pop("submission_directory")
115
+ return data
116
+
117
+ @model_validator(mode="before")
118
+ def check_paths_exist(cls, data):
119
+ """Resolve questions_paths relative to the config file's directory"""
120
+ path = data.get("path")
121
+ if not path:
122
+ return data
123
+
124
+ config_dir = path.parent
125
+
126
+ # Questions:
127
+ paths_to_check = data.get("questions_paths", []) + data.get("questions_paths_", [])
128
+ for path in paths_to_check:
129
+ resolved_path = (config_dir / path).resolve()
130
+ if not resolved_path.exists():
131
+ raise FileNotFoundError(f"Questions path does not exist: {resolved_path}")
132
+
133
+ return data
134
+
135
+ def yaml_dump(self) -> str:
136
+ """Dump the current configuration to a YAML string"""
137
+ config_dict = self.model_dump(by_alias=True)
138
+ yaml_content = yaml.dump(config_dict, default_flow_style=False, sort_keys=False)
139
+ return yaml_content
140
+
141
+ @classmethod
142
+ def generate_example_yaml(cls) -> str:
143
+ """Generate example YAML with comments"""
144
+ example = cls()
145
+ return example.yaml_dump()
146
+
147
+ @classmethod
148
+ def read_yaml(cls, file_path: str) -> "QuizConfig":
149
+ """Read YAML file and return a QuizConfig instance"""
150
+ with open(file_path, "r") as file:
151
+ yaml_string = file.read()
152
+ data = yaml.safe_load(yaml_string)
153
+ data['path'] = Path(file_path).resolve()
154
+ 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_pdf.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_pdf.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))