mcqpy-core 0.2.2__tar.gz → 0.2.4__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 (54) hide show
  1. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/PKG-INFO +1 -1
  2. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/pyproject.toml +1 -1
  3. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/cli/config.py +23 -0
  4. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/grading/__init__.py +10 -1
  5. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/grading/analysis/overall_analysis.py +56 -3
  6. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/grading/grader.py +12 -8
  7. mcqpy_core-0.2.2/src/mcqpy_core/grading/types.py → mcqpy_core-0.2.4/src/mcqpy_core/grading/helpers.py +6 -35
  8. mcqpy_core-0.2.4/src/mcqpy_core/grading/types.py +56 -0
  9. mcqpy_core-0.2.2/src/mcqpy_core/cli/build.py +0 -89
  10. mcqpy_core-0.2.2/src/mcqpy_core/cli/check_latex.py +0 -31
  11. mcqpy_core-0.2.2/src/mcqpy_core/cli/grade.py +0 -100
  12. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/README.md +0 -0
  13. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/__init__.py +0 -0
  14. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/cli/__init__.py +0 -0
  15. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/cli/_selection.py +0 -0
  16. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/cli/export/__init__.py +0 -0
  17. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/cli/export/main.py +0 -0
  18. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/cli/export/token.py +0 -0
  19. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/cli/export/web.py +0 -0
  20. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/cli/init.py +0 -0
  21. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/cli/main.py +0 -0
  22. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/cli/question/__init__.py +0 -0
  23. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/cli/question/check_tags.py +0 -0
  24. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/cli/question/init.py +0 -0
  25. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/cli/question/main.py +0 -0
  26. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/cli/question/render.py +0 -0
  27. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/cli/question/validate.py +0 -0
  28. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/cli/utils/__init__.py +0 -0
  29. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/cli/utils/autofill.py +0 -0
  30. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/cli/utils/check_filter.py +0 -0
  31. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/cli/utils/main.py +0 -0
  32. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/front_config.py +0 -0
  33. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/grading/analysis/__init__.py +0 -0
  34. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/grading/analysis/question_analysis.py +0 -0
  35. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/grading/analysis/utils.py +0 -0
  36. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/grading/rubric.py +0 -0
  37. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/header_config.py +0 -0
  38. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/manifest.py +0 -0
  39. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/question/__init__.py +0 -0
  40. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/question/filter/__init__.py +0 -0
  41. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/question/filter/base_filter.py +0 -0
  42. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/question/filter/date.py +0 -0
  43. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/question/filter/difficulty.py +0 -0
  44. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/question/filter/factory.py +0 -0
  45. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/question/filter/manifest.py +0 -0
  46. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/question/filter/slug.py +0 -0
  47. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/question/filter/stratified.py +0 -0
  48. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/question/filter/tag.py +0 -0
  49. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/question/question.py +0 -0
  50. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/question/question_bank.py +0 -0
  51. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/question/utils.py +0 -0
  52. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/web/__init__.py +0 -0
  53. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/web/bundle.py +0 -0
  54. {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/web/token.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: mcqpy-core
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Core question, manifest, grading, analysis, and browser-safe runtime logic for MCQPy
5
5
  Author: Mads-Peter
6
6
  Author-email: Mads-Peter <machri@phys.au.dk>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mcqpy-core"
3
- version = "0.2.2"
3
+ version = "0.2.4"
4
4
  description = "Core question, manifest, grading, analysis, and browser-safe runtime logic for MCQPy"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -1,6 +1,7 @@
1
1
  """Quiz configuration management using Pydantic and YAML."""
2
2
 
3
3
  from pathlib import Path
4
+ from sys import path
4
5
  from typing import Any, Literal
5
6
 
6
7
  from pydantic import BaseModel, Field, ConfigDict, field_validator, model_validator
@@ -142,6 +143,28 @@ class QuizConfig(BaseModel):
142
143
  raise FileNotFoundError(f"Questions path does not exist: {resolved_path}")
143
144
 
144
145
  return data
146
+
147
+ @model_validator(mode="before")
148
+ def selection_path_make_absolute(cls, data):
149
+ """Ensure that selection paths are relative to the config file's directory"""
150
+ selection = data.get("selection", None)
151
+ if selection is None:
152
+ return data
153
+
154
+ filters = selection.get("filters", None)
155
+
156
+ if filters is None:
157
+ return data
158
+
159
+ config_path = data.get("path")
160
+ for filter_name, filter_params in filters.items():
161
+ for name, param in filter_params.items():
162
+ if name.endswith("_path") and isinstance(param, str) and config_path is not None:
163
+ absolute_path = (Path(config_path).parent / param).resolve()
164
+ filter_params[name] = str(absolute_path)
165
+
166
+ return data
167
+
145
168
 
146
169
  def yaml_dump(self) -> str:
147
170
  """Dump the current configuration to a YAML string"""
@@ -7,9 +7,14 @@ from .types import (
7
7
  GradedSet,
8
8
  ParsedQuestion,
9
9
  ParsedSet,
10
- get_grade_dataframe,
10
+ GradeResult,
11
+ GradeState,
12
+ ParseResult,
13
+ ParseState,
11
14
  )
12
15
 
16
+ from .helpers import get_grade_dataframe
17
+
13
18
  __all__ = [
14
19
  "MCQGrader",
15
20
  "Rubric",
@@ -18,6 +23,10 @@ __all__ = [
18
23
  "ParsedSet",
19
24
  "GradedQuestion",
20
25
  "GradedSet",
26
+ "GradeResult",
27
+ "GradeState",
28
+ "ParseResult",
29
+ "ParseState",
21
30
  "get_grade_dataframe",
22
31
  "grade_parsed_set",
23
32
  ]
@@ -8,8 +8,13 @@ from mcqpy_core.grading.types import GradedSet
8
8
  from scipy.stats import pearsonr
9
9
  from mcqpy_core.grading.analysis.utils import AnalysisFigure
10
10
 
11
+ COLROMAP = plt.cm.plasma
12
+
11
13
  def get_points_array(graded_sets: list[GradedSet]):
12
- point_arrays = np.zeros((len(graded_sets), len(graded_sets[0].graded_questions)))
14
+
15
+ n_questions = max(len(graded_set.graded_questions) for graded_set in graded_sets)
16
+
17
+ point_arrays = np.zeros((len(graded_sets), n_questions))
13
18
  for i, graded_set in enumerate(graded_sets):
14
19
  for j, question in enumerate(graded_set.graded_questions):
15
20
  point_arrays[i, j] = question.point_value
@@ -34,11 +39,15 @@ def correlation_analysis(graded_sets: list[GradedSet], output_dir: str | Path):
34
39
  correlations[qidx] = correlation
35
40
  p_values[qidx] = p_value
36
41
 
42
+
43
+ # Colors based on correlation
44
+ colors = COLROMAP((correlations - np.min(correlations)) / (np.max(correlations) - np.min(correlations)))
45
+
37
46
  fig, ax = plt.subplots(figsize=(8, 5))
38
47
  ax.bar(
39
48
  np.arange(1, n_questions + 1),
40
49
  correlations,
41
- color="mediumpurple",
50
+ color=colors,
42
51
  alpha=0.7,
43
52
  edgecolor="black",
44
53
  )
@@ -108,11 +117,15 @@ def discrimination_index_analysis(graded_sets: list[GradedSet], output_dir: str
108
117
  D = (np.mean(q_points[high_group]) - np.mean(q_points[low_group])) / max_score
109
118
  discrimination_indices[qidx] = D
110
119
 
120
+
121
+ # Colors based on discrimination index
122
+ colors = COLROMAP((discrimination_indices - np.min(discrimination_indices)) / (np.max(discrimination_indices) - np.min(discrimination_indices)))
123
+
111
124
  fig, ax = plt.subplots(figsize=(8, 5))
112
125
  ax.bar(
113
126
  np.arange(1, n_questions + 1),
114
127
  discrimination_indices,
115
- color="mediumpurple",
128
+ color=colors,
116
129
  alpha=0.7,
117
130
  edgecolor="black",
118
131
  )
@@ -135,6 +148,42 @@ def discrimination_index_analysis(graded_sets: list[GradedSet], output_dir: str
135
148
  )
136
149
 
137
150
 
151
+ def quesstion_point_average_analysis(graded_sets: list[GradedSet], output_dir: str | Path):
152
+ point_array = get_points_array(graded_sets)
153
+
154
+ n_questions = point_array.shape[1]
155
+
156
+ average_points = np.mean(point_array, axis=0)
157
+
158
+ # Colors based on average points
159
+ colors = COLROMAP(average_points / np.max(point_array))
160
+
161
+ fig, ax = plt.subplots(figsize=(8, 5))
162
+ ax.bar(
163
+ np.arange(1, n_questions + 1),
164
+ average_points,
165
+ color=colors,
166
+ alpha=0.7,
167
+ edgecolor="black",
168
+ )
169
+ plt.xlabel("Question Number")
170
+ plt.ylabel("Average Points")
171
+ plt.title("Average Points per Question")
172
+
173
+ ax.set_xticks(np.arange(1, n_questions + 1))
174
+
175
+ plt.tight_layout()
176
+
177
+ name = "quiz_question_point_average_analysis.pdf"
178
+ output_path = output_dir / name
179
+
180
+ plt.savefig(output_path)
181
+ plt.close()
182
+ return AnalysisFigure(
183
+ name=name,
184
+ caption=r"\textbf{Average points per question}: Shows the average points earned on each question across all students.",
185
+ )
186
+
138
187
  def make_quiz_analysis(graded_sets: list[GradedSet], out_directory: str | Path):
139
188
  out_directory = Path(out_directory)
140
189
  out_directory.mkdir(parents=True, exist_ok=True)
@@ -144,10 +193,14 @@ def make_quiz_analysis(graded_sets: list[GradedSet], out_directory: str | Path):
144
193
  pd_fig = point_distribution_analysis(graded_sets, out_directory)
145
194
  figures.append(pd_fig)
146
195
 
196
+ avg_fig = quesstion_point_average_analysis(graded_sets, out_directory)
197
+ figures.append(avg_fig)
198
+
147
199
  corr_fig = correlation_analysis(graded_sets, out_directory)
148
200
  figures.append(corr_fig)
149
201
 
150
202
  di_fig = discrimination_index_analysis(graded_sets, out_directory)
151
203
  figures.append(di_fig)
152
204
 
205
+
153
206
  return figures
@@ -1,13 +1,14 @@
1
1
  from pathlib import Path
2
2
 
3
3
  from mcqpy_core.manifest import Manifest
4
- from mcqpy_core.grading.types import GradedQuestion, GradedSet, ParsedSet
4
+ from mcqpy_core.grading.types import GradedQuestion, GradedSet, ParsedSet, GradeResult, GradeState
5
5
  from mcqpy_core.grading.rubric import Rubric
6
6
 
7
7
 
8
- def grade_parsed_set(manifest: Manifest, rubric: Rubric, parsed_set: ParsedSet) -> GradedSet:
8
+ def grade_parsed_set(manifest: Manifest, rubric: Rubric, parsed_set: ParsedSet) -> GradeResult:
9
9
  graded_set = GradedSet(student_info=parsed_set.student_info, graded_questions=[])
10
10
 
11
+ other_info = {}
11
12
  for parsed_question in parsed_set.questions:
12
13
  manifest_item = manifest.get_item_by_qid(parsed_question.qid)
13
14
 
@@ -19,17 +20,20 @@ def grade_parsed_set(manifest: Manifest, rubric: Rubric, parsed_set: ParsedSet)
19
20
  max_point_value=manifest_item.point_value,
20
21
  )
21
22
 
22
- if sum(graded_question.student_answers) == 0:
23
- raise Warning(
24
- f"No answers provided for question {graded_question.qid} ({graded_question.slug})"
25
- )
23
+ if sum(parsed_question.onehot) == 0:
24
+ message = f"No answers provided for question {parsed_question.qid} ({parsed_question.slug})."
25
+ other_info[parsed_question.qid] = message
26
26
 
27
27
  graded_question.point_value = rubric.score_question(graded_question)
28
28
  graded_set.graded_questions.append(graded_question)
29
29
 
30
30
  graded_set.points = sum(q.point_value for q in graded_set.graded_questions)
31
31
  graded_set.max_points = sum(q.max_point_value for q in graded_set.graded_questions)
32
- return graded_set
32
+ return GradeResult(
33
+ state=GradeState.SUCCESS,
34
+ graded_set=graded_set,
35
+ other_info=other_info if other_info else None,
36
+ )
33
37
 
34
38
 
35
39
  class MCQGrader:
@@ -38,7 +42,7 @@ class MCQGrader:
38
42
  self.rubric = rubric
39
43
  self.regex_pattern = regex_pattern
40
44
 
41
- def grade(self, parsed_set: ParsedSet | None = None, student_answer: str | Path = None) -> GradedSet:
45
+ def grade(self, parsed_set: ParsedSet | None = None, student_answer: str | Path = None) -> GradeResult:
42
46
  if parsed_set is None:
43
47
  raise ValueError("MCQGrader.grade requires a ParsedSet in mcqpy-core")
44
48
  return grade_parsed_set(self.manifest, self.rubric, parsed_set)
@@ -1,38 +1,4 @@
1
- from dataclasses import dataclass
2
-
3
-
4
- @dataclass
5
- class ParsedQuestion:
6
- qid: str
7
- slug: str
8
- answers: list[int]
9
- onehot: list[int]
10
-
11
-
12
- @dataclass
13
- class ParsedSet:
14
- student_info: dict[str, str]
15
- questions: list[ParsedQuestion]
16
- file: str | None = None
17
-
18
-
19
- @dataclass
20
- class GradedQuestion:
21
- qid: str
22
- slug: str
23
- student_answers: list[int]
24
- correct_answers: list[int]
25
- max_point_value: int
26
- point_value: int = 0
27
-
28
-
29
- @dataclass
30
- class GradedSet:
31
- student_info: dict[str, str]
32
- graded_questions: list[GradedQuestion]
33
- points: int = 0
34
- max_points: int = 0
35
-
1
+ from mcqpy_core.grading.types import GradedSet
36
2
 
37
3
  def get_grade_dataframe(
38
4
  graded_sets: list[GradedSet], sort_key: str | None = None
@@ -47,12 +13,17 @@ def get_grade_dataframe(
47
13
 
48
14
  records = []
49
15
  for graded_set in graded_sets:
16
+
17
+ # Determine the number of questions the student gave an answer to
18
+ answered_questions = sum(1 in q.student_answers for q in graded_set.graded_questions)
19
+
50
20
  record = {}
51
21
  record.update(graded_set.student_info)
52
22
  record.update(
53
23
  {
54
24
  "total_points": graded_set.points,
55
25
  "max_points": graded_set.max_points,
26
+ "answered_questions": answered_questions,
56
27
  }
57
28
  )
58
29
 
@@ -0,0 +1,56 @@
1
+ from dataclasses import dataclass
2
+ from enum import Enum
3
+
4
+ @dataclass
5
+ class ParsedQuestion:
6
+ qid: str
7
+ slug: str
8
+ answers: list[int]
9
+ onehot: list[int]
10
+
11
+
12
+ @dataclass
13
+ class ParsedSet:
14
+ student_info: dict[str, str]
15
+ questions: list[ParsedQuestion]
16
+ file: str | None = None
17
+
18
+
19
+ @dataclass
20
+ class GradedQuestion:
21
+ qid: str
22
+ slug: str
23
+ student_answers: list[int]
24
+ correct_answers: list[int]
25
+ max_point_value: int
26
+ point_value: int = 0
27
+
28
+
29
+ @dataclass
30
+ class GradedSet:
31
+ student_info: dict[str, str]
32
+ graded_questions: list[GradedQuestion]
33
+ points: int = 0
34
+ max_points: int = 0
35
+
36
+ class ParseState(Enum):
37
+ READER_ERROR = "Error reading PDF file. The file may be corrupted or not a valid PDF."
38
+ SUCCESS = "Successfully parsed PDF file."
39
+
40
+ class GradeState(Enum):
41
+ READER_ERROR = ParseState.READER_ERROR.value
42
+ SUCCESS = "Successfully graded the parsed set."
43
+
44
+ @dataclass
45
+ class ParseResult:
46
+ state: ParseState
47
+ parsed_set: ParsedSet | None = None
48
+ error_message: str | None = None
49
+ other_info: dict | None = None
50
+
51
+ @dataclass
52
+ class GradeResult:
53
+ state: GradeState
54
+ graded_set: GradedSet | None = None
55
+ error_message: str | None = None
56
+ other_info: dict | None = None
@@ -1,89 +0,0 @@
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)
@@ -1,31 +0,0 @@
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]")
@@ -1,100 +0,0 @@
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()
File without changes