mdq 0.1.0__py3-none-any.whl
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.
- mdq/__init__.py +19 -0
- mdq/__main__.py +4 -0
- mdq/_diagnostics.py +40 -0
- mdq/cli.py +327 -0
- mdq/convert/__init__.py +14 -0
- mdq/convert/aiken.py +151 -0
- mdq/convert/base.py +106 -0
- mdq/convert/gift.py +876 -0
- mdq/convert/moodle_xml.py +922 -0
- mdq/convert/parser.py +161 -0
- mdq/errors.py +114 -0
- mdq/grading.py +87 -0
- mdq/hypothesis/__init__.py +14 -0
- mdq/hypothesis/aiken.py +59 -0
- mdq/hypothesis/documents.py +625 -0
- mdq/hypothesis/frontmatter.py +88 -0
- mdq/hypothesis/gift.py +140 -0
- mdq/hypothesis/moodle_xml.py +234 -0
- mdq/hypothesis/schedule.py +236 -0
- mdq/hypothesis/slugs.py +247 -0
- mdq/linter.py +905 -0
- mdq/loaders.py +113 -0
- mdq/loading.py +520 -0
- mdq/mdit/__init__.py +0 -0
- mdq/mdit/plugins/__init__.py +0 -0
- mdq/mdit/plugins/choices.py +170 -0
- mdq/mdq.schema.json +1172 -0
- mdq/models.py +1836 -0
- mdq/parser.py +2101 -0
- mdq/py.typed +0 -0
- mdq/regex.py +238 -0
- mdq/render.py +167 -0
- mdq/scaffold.py +438 -0
- mdq/schedule.py +188 -0
- mdq/scripts/__init__.py +0 -0
- mdq/scripts/lint_snapshot.py +159 -0
- mdq/show.py +534 -0
- mdq/slugify.py +221 -0
- mdq/testing.py +129 -0
- mdq/types.py +403 -0
- mdq-0.1.0.dist-info/METADATA +168 -0
- mdq-0.1.0.dist-info/RECORD +45 -0
- mdq-0.1.0.dist-info/WHEEL +4 -0
- mdq-0.1.0.dist-info/entry_points.txt +3 -0
- mdq-0.1.0.dist-info/licenses/LICENSE +21 -0
mdq/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""mdq: tools for the MDQ (Markdown Questions) file format."""
|
|
2
|
+
|
|
3
|
+
from .errors import ParseError
|
|
4
|
+
from .loading import Diagnostic, InvalidDocument, Loaded, Severity, load, parse
|
|
5
|
+
from .models import Exam, Question
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Question",
|
|
10
|
+
"Exam",
|
|
11
|
+
"ParseError",
|
|
12
|
+
#: Loading
|
|
13
|
+
"load",
|
|
14
|
+
"parse",
|
|
15
|
+
"Loaded",
|
|
16
|
+
"Diagnostic",
|
|
17
|
+
"Severity",
|
|
18
|
+
"InvalidDocument",
|
|
19
|
+
]
|
mdq/__main__.py
ADDED
mdq/_diagnostics.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""
|
|
2
|
+
`Diagnostic`, the one problem shape every stage of `mdq.loading`'s
|
|
3
|
+
pipeline reports through -- a parse failure, a pydantic error, a lint
|
|
4
|
+
warning, an unknown frontmatter key.
|
|
5
|
+
|
|
6
|
+
Split out from `mdq.loading` (which owns and re-exports it) so that
|
|
7
|
+
`mdq.linter` and `mdq.parser` -- both upstream of `mdq.loading` in the
|
|
8
|
+
pipeline -- can report `Diagnostic`s of their own without importing back
|
|
9
|
+
into the module that orchestrates them.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Literal
|
|
16
|
+
|
|
17
|
+
Severity = Literal["error", "warning", "info"]
|
|
18
|
+
|
|
19
|
+
#: Severity, ranked so "at or above" (as `Loaded.validate` uses it) is a
|
|
20
|
+
#: plain integer comparison. Higher is more severe.
|
|
21
|
+
RANK: dict[Severity, int] = {"info": 1, "warning": 2, "error": 3}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Diagnostic:
|
|
26
|
+
"""
|
|
27
|
+
One problem found while loading a document.
|
|
28
|
+
|
|
29
|
+
`path` locates the offending value the way a `jsonschema`
|
|
30
|
+
`ValidationError.path` or a pydantic error's `loc` does: a tuple of
|
|
31
|
+
keys/indices from the document's root. `line` is set only for a
|
|
32
|
+
parse failure tied to a source node, and only when that node's
|
|
33
|
+
position in the source is known.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
severity: Severity
|
|
37
|
+
code: str
|
|
38
|
+
message: str
|
|
39
|
+
path: tuple[str | int, ...] = ()
|
|
40
|
+
line: int | None = None
|
mdq/cli.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command-line interface for mdq.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
mdq validate <question.json|question.yaml>
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Annotated, Literal
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
|
|
18
|
+
from . import show as _show
|
|
19
|
+
from .convert import export_question, import_question
|
|
20
|
+
from .loading import Diagnostic, InvalidDocument, load, parse
|
|
21
|
+
from .scaffold import QUESTION_TYPES, default_output_path, render_template
|
|
22
|
+
|
|
23
|
+
app = typer.Typer(
|
|
24
|
+
name="mdq",
|
|
25
|
+
help="Tools for the MDQ (Markdown Questions) file format.",
|
|
26
|
+
add_completion=False,
|
|
27
|
+
no_args_is_help=True,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
Level = Literal["default", "strict"]
|
|
32
|
+
|
|
33
|
+
stderr = Console(file=sys.stderr, highlight=False, force_terminal=True)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main() -> None:
|
|
37
|
+
"""
|
|
38
|
+
Main entry point to the CLI.
|
|
39
|
+
"""
|
|
40
|
+
app()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.command()
|
|
44
|
+
def validate(
|
|
45
|
+
file: Path = typer.Argument(
|
|
46
|
+
...,
|
|
47
|
+
help="Path to a question or exam document (.mdq.md, .mdq, .yaml, .yml, or .json).",
|
|
48
|
+
),
|
|
49
|
+
level: Level = typer.Option(
|
|
50
|
+
"default",
|
|
51
|
+
"--level",
|
|
52
|
+
case_sensitive=False,
|
|
53
|
+
help=(
|
|
54
|
+
"'default' prints errors and warnings; 'strict' also prints "
|
|
55
|
+
"info-level diagnostics. Every lint rule always runs -- this "
|
|
56
|
+
"only changes what gets printed."
|
|
57
|
+
),
|
|
58
|
+
),
|
|
59
|
+
) -> None:
|
|
60
|
+
"""
|
|
61
|
+
Validate a question or exam file, and lint it for issues beyond
|
|
62
|
+
what a schema can express.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
loaded = load(file)
|
|
67
|
+
except (OSError, ValueError) as exc:
|
|
68
|
+
typer.echo(f"error: {exc}", err=True)
|
|
69
|
+
raise typer.Exit(code=2)
|
|
70
|
+
|
|
71
|
+
exit_code = _print_result(file, loaded.diagnostics, level=level)
|
|
72
|
+
if exit_code:
|
|
73
|
+
raise typer.Exit(code=exit_code)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@app.command()
|
|
77
|
+
def new(
|
|
78
|
+
question_type: str = typer.Argument(
|
|
79
|
+
...,
|
|
80
|
+
help=f"Question type to scaffold ({', '.join(QUESTION_TYPES)}).",
|
|
81
|
+
),
|
|
82
|
+
output: Annotated[
|
|
83
|
+
Path | None,
|
|
84
|
+
typer.Option(
|
|
85
|
+
...,
|
|
86
|
+
"-o",
|
|
87
|
+
"--output",
|
|
88
|
+
help="Where to write the new question file (default: <type>.mdq.md).",
|
|
89
|
+
),
|
|
90
|
+
] = None,
|
|
91
|
+
complete: bool = typer.Option(
|
|
92
|
+
False,
|
|
93
|
+
"--complete",
|
|
94
|
+
help=(
|
|
95
|
+
"Illustrate every feature the question type supports, "
|
|
96
|
+
"instead of a bare-bones example."
|
|
97
|
+
),
|
|
98
|
+
),
|
|
99
|
+
) -> None:
|
|
100
|
+
"""
|
|
101
|
+
Scaffold a new question document.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
content = render_template(question_type, complete=complete)
|
|
106
|
+
except KeyError:
|
|
107
|
+
valid = ", ".join(QUESTION_TYPES)
|
|
108
|
+
typer.echo(
|
|
109
|
+
f"error: unknown question type {question_type!r} (expected one of: {valid})",
|
|
110
|
+
err=True,
|
|
111
|
+
)
|
|
112
|
+
raise typer.Exit(code=2)
|
|
113
|
+
|
|
114
|
+
path = output or default_output_path(question_type)
|
|
115
|
+
if path.exists():
|
|
116
|
+
typer.echo(f"error: {path} already exists", err=True)
|
|
117
|
+
raise typer.Exit(code=2)
|
|
118
|
+
|
|
119
|
+
path.write_text(content, encoding="utf-8")
|
|
120
|
+
typer.echo(f"wrote {path}")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@app.command("import")
|
|
124
|
+
def import_(
|
|
125
|
+
file: Path = typer.Argument(
|
|
126
|
+
...,
|
|
127
|
+
help="Path to a question file in an external format (e.g. Aiken, GIFT).",
|
|
128
|
+
),
|
|
129
|
+
output: Annotated[
|
|
130
|
+
Path | None,
|
|
131
|
+
typer.Option(
|
|
132
|
+
"-o",
|
|
133
|
+
"--output",
|
|
134
|
+
help="Where to write the imported MDQ document (default: print to stdout).",
|
|
135
|
+
),
|
|
136
|
+
] = None,
|
|
137
|
+
format: str | None = typer.Option(
|
|
138
|
+
None,
|
|
139
|
+
"--format",
|
|
140
|
+
help="External format to import from (default: inferred from FILE's extension).",
|
|
141
|
+
),
|
|
142
|
+
) -> None:
|
|
143
|
+
"""
|
|
144
|
+
Import a question from an external format into MDQ Markdown.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
if not file.is_file():
|
|
148
|
+
typer.echo(f"error: file not found: {file}", err=True)
|
|
149
|
+
raise typer.Exit(code=2)
|
|
150
|
+
|
|
151
|
+
fmt = format or _format_from_suffix(file)
|
|
152
|
+
if fmt is None:
|
|
153
|
+
typer.echo(
|
|
154
|
+
f"error: cannot infer format from {file} -- pass --format",
|
|
155
|
+
err=True,
|
|
156
|
+
)
|
|
157
|
+
raise typer.Exit(code=2)
|
|
158
|
+
|
|
159
|
+
source = file.read_text(encoding="utf-8")
|
|
160
|
+
try:
|
|
161
|
+
question = import_question(source, format=fmt)
|
|
162
|
+
except (ValueError, NotImplementedError) as exc:
|
|
163
|
+
typer.echo(f"error: {exc}", err=True)
|
|
164
|
+
raise typer.Exit(code=1)
|
|
165
|
+
|
|
166
|
+
_write_output(str(question), output)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@app.command()
|
|
170
|
+
def export(
|
|
171
|
+
file: Path = typer.Argument(
|
|
172
|
+
...,
|
|
173
|
+
help="Path to an MDQ question document (.mdq.md or .mdq).",
|
|
174
|
+
),
|
|
175
|
+
output: Annotated[
|
|
176
|
+
Path | None,
|
|
177
|
+
typer.Option(
|
|
178
|
+
"-o",
|
|
179
|
+
"--output",
|
|
180
|
+
help="Where to write the exported document (default: print to stdout).",
|
|
181
|
+
),
|
|
182
|
+
] = None,
|
|
183
|
+
format: str | None = typer.Option(
|
|
184
|
+
None,
|
|
185
|
+
"--format",
|
|
186
|
+
help=(
|
|
187
|
+
"External format to export to (default: inferred from -o's "
|
|
188
|
+
"extension, falling back to 'aiken')."
|
|
189
|
+
),
|
|
190
|
+
),
|
|
191
|
+
) -> None:
|
|
192
|
+
"""
|
|
193
|
+
Export an MDQ question document to an external format.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
if not file.is_file():
|
|
197
|
+
typer.echo(f"error: file not found: {file}", err=True)
|
|
198
|
+
raise typer.Exit(code=2)
|
|
199
|
+
|
|
200
|
+
fmt = format or (output and _format_from_suffix(output)) or "aiken"
|
|
201
|
+
|
|
202
|
+
source = file.read_text(encoding="utf-8")
|
|
203
|
+
try:
|
|
204
|
+
question = parse(source, kind="question", ids="fill")
|
|
205
|
+
except InvalidDocument as exc:
|
|
206
|
+
typer.echo(f"error: {exc}", err=True)
|
|
207
|
+
raise typer.Exit(code=1)
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
rendered = export_question(question, format=fmt)
|
|
211
|
+
except (ValueError, TypeError, NotImplementedError) as exc:
|
|
212
|
+
typer.echo(f"error: {exc}", err=True)
|
|
213
|
+
raise typer.Exit(code=1)
|
|
214
|
+
|
|
215
|
+
_write_output(rendered, output)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@app.command()
|
|
219
|
+
def show(
|
|
220
|
+
file: Path = typer.Argument(
|
|
221
|
+
...,
|
|
222
|
+
help="Path to a question or exam document (.mdq.md or .mdq), or - for stdin.",
|
|
223
|
+
),
|
|
224
|
+
no_answer_key: bool = typer.Option(
|
|
225
|
+
False,
|
|
226
|
+
"--no-answer-key",
|
|
227
|
+
help=(
|
|
228
|
+
"Hide anything that reveals the correct answer (correctness "
|
|
229
|
+
"marks, an essay's answer key, a numeric answer, ...), as if "
|
|
230
|
+
"previewing the question for a student."
|
|
231
|
+
),
|
|
232
|
+
),
|
|
233
|
+
width: int | None = typer.Option(
|
|
234
|
+
None,
|
|
235
|
+
"--width",
|
|
236
|
+
help="Console width to render at (default: the terminal's own width).",
|
|
237
|
+
),
|
|
238
|
+
) -> None:
|
|
239
|
+
"""
|
|
240
|
+
Show the parsed representation of a question or exam document.
|
|
241
|
+
|
|
242
|
+
Renders rich-text fields (preamble, stem, epilogue, choice text,
|
|
243
|
+
feedback, ...) as Markdown and everything else as structured
|
|
244
|
+
metadata, so the document's shape is easy to check at a glance.
|
|
245
|
+
"""
|
|
246
|
+
|
|
247
|
+
console = Console(
|
|
248
|
+
file=sys.stdout,
|
|
249
|
+
width=width,
|
|
250
|
+
highlight=False,
|
|
251
|
+
force_terminal=True,
|
|
252
|
+
)
|
|
253
|
+
error_console = Console(
|
|
254
|
+
file=sys.stderr,
|
|
255
|
+
highlight=False,
|
|
256
|
+
force_terminal=True,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
# Everything below that can embed a path or an exception message is
|
|
260
|
+
# printed as a `Text` object, never spliced into a markup `str` --
|
|
261
|
+
# `file` and `exc` can both carry document/filesystem content with
|
|
262
|
+
# square brackets, which Rich would otherwise parse as markup (and,
|
|
263
|
+
# for a stray closing tag like `[/]`, raise instead of print).
|
|
264
|
+
#
|
|
265
|
+
# A `Path` source resolves an exam's `include:` entries against its
|
|
266
|
+
# own directory automatically (see `mdq.load`); stdin has no
|
|
267
|
+
# directory to resolve against, so it is read as plain text instead.
|
|
268
|
+
source = sys.stdin.read() if str(file) == "-" else file
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
_show.show_source(source, console, show_answer_key=not no_answer_key)
|
|
272
|
+
except OSError as exc:
|
|
273
|
+
error_console.print("[b red]error:[/]", Text(f"could not read {file}: {exc}"))
|
|
274
|
+
raise typer.Exit(code=2)
|
|
275
|
+
except (ValueError, InvalidDocument) as exc:
|
|
276
|
+
error_console.print("[b red]error:[/]", Text(str(exc)))
|
|
277
|
+
raise typer.Exit(code=1)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
#
|
|
281
|
+
# Utilities
|
|
282
|
+
#
|
|
283
|
+
FORMAT_SUFFIX_ALIASES = {"xml": "moodle-xml", "moodlexml": "moodle-xml"}
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _format_from_suffix(file: Path) -> str | None:
|
|
287
|
+
"""
|
|
288
|
+
Infer a converter format name from a file's extension, e.g. `.aiken`
|
|
289
|
+
-> "aiken". Returns None if the file has no extension.
|
|
290
|
+
"""
|
|
291
|
+
suffix = file.suffix.lstrip(".")
|
|
292
|
+
if not suffix:
|
|
293
|
+
return None
|
|
294
|
+
return FORMAT_SUFFIX_ALIASES.get(suffix, suffix)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _write_output(content: str, output: Path | None) -> None:
|
|
298
|
+
if output is None:
|
|
299
|
+
typer.echo(content)
|
|
300
|
+
else:
|
|
301
|
+
output.write_text(content, encoding="utf-8")
|
|
302
|
+
typer.echo(f"wrote {output}")
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _print_result(file: Path, diagnostics: list[Diagnostic], *, level: Level) -> int:
|
|
306
|
+
"""
|
|
307
|
+
Print one line per diagnostic and return the process exit code.
|
|
308
|
+
|
|
309
|
+
`level="default"` hides `info`-severity diagnostics; `strict` prints
|
|
310
|
+
everything. Either way, only an `error` fails the exit code -- a
|
|
311
|
+
warning or an info diagnostic is advisory.
|
|
312
|
+
"""
|
|
313
|
+
has_error = any(d.severity == "error" for d in diagnostics)
|
|
314
|
+
typer.echo(f"{'FAIL' if has_error else 'OK':<5} {file}", err=has_error)
|
|
315
|
+
|
|
316
|
+
shown = diagnostics if level == "strict" else [d for d in diagnostics if d.severity != "info"]
|
|
317
|
+
for d in shown:
|
|
318
|
+
location = "/".join(str(part) for part in d.path) or "<root>"
|
|
319
|
+
if d.line is not None:
|
|
320
|
+
location = f"{location}:{d.line}"
|
|
321
|
+
typer.echo(f" {d.severity:<7} [{d.code}] {location}: {d.message}", err=True)
|
|
322
|
+
|
|
323
|
+
return 1 if has_error else 0
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
if __name__ == "__main__":
|
|
327
|
+
main()
|
mdq/convert/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Convert questions to different formats.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .base import export_question, import_question, register_format
|
|
6
|
+
|
|
7
|
+
__all__ = ["import_question", "export_question"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
register_format("aiken", converter="mdq.convert.aiken:Aiken")
|
|
11
|
+
register_format("moodle-xml", converter="mdq.convert.moodle_xml:MoodleXml")
|
|
12
|
+
register_format("gift", converter="mdq.convert.gift:Gift")
|
|
13
|
+
|
|
14
|
+
del register_format
|
mdq/convert/aiken.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from string import ascii_lowercase
|
|
6
|
+
|
|
7
|
+
from ..models import MultipleChoiceQuestion, Question, ScoredChoice
|
|
8
|
+
from .base import ConversionBase
|
|
9
|
+
from .parser import StringParser
|
|
10
|
+
|
|
11
|
+
CHOICE_REGEX = re.compile(r"(?P<letter>[a-zA-Z])[.)]\s+(?P<choice>[^\n]*)")
|
|
12
|
+
NEWLINE_REGEX = re.compile(r"\s*\n\s*")
|
|
13
|
+
|
|
14
|
+
__all__ = ["Aiken", "AikenQuestion", "AikenParser", "CHOICE_REGEX"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Aiken(ConversionBase["AikenQuestion"]):
|
|
18
|
+
"""
|
|
19
|
+
Aiken is a very simple format for multiple choice questions.
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
Question stem
|
|
23
|
+
|
|
24
|
+
A. Option 1
|
|
25
|
+
B. Option 2
|
|
26
|
+
C. Option 3
|
|
27
|
+
|
|
28
|
+
ANSWER: a
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Aiken carries no metadata, so a conversion drops `id`, `title` and every
|
|
32
|
+
feedback string, and collapses partial credit to 1.0 on the highest
|
|
33
|
+
scoring choice and 0.0 elsewhere.
|
|
34
|
+
|
|
35
|
+
For more details: https://docs.moodle.org/en/Aiken_format
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
supports = {"multiple-choice": "both"}
|
|
39
|
+
|
|
40
|
+
def from_mdq(self, question: Question) -> AikenQuestion:
|
|
41
|
+
if not isinstance(question, MultipleChoiceQuestion):
|
|
42
|
+
raise ValueError(f"Aiken does not support {question.type!r} questions")
|
|
43
|
+
|
|
44
|
+
scores = [choice.score for choice in question.choices]
|
|
45
|
+
if any(score is None for score in scores):
|
|
46
|
+
raise ValueError(
|
|
47
|
+
"cannot convert to Aiken: every choice must have a score"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
parts = [question.preamble, question.stem, question.epilogue]
|
|
51
|
+
return AikenQuestion(
|
|
52
|
+
stem="\n\n".join(filter(None, parts)),
|
|
53
|
+
choices=[choice.text for choice in question.choices],
|
|
54
|
+
# `-i` breaks ties towards the first of the highest scoring choices.
|
|
55
|
+
answer=max(range(len(scores)), key=lambda i: (scores[i], -i)),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def to_mdq(self, aiken: AikenQuestion) -> MultipleChoiceQuestion:
|
|
59
|
+
return MultipleChoiceQuestion(
|
|
60
|
+
stem=aiken.stem,
|
|
61
|
+
choices=[
|
|
62
|
+
ScoredChoice(text=choice, score=1.0 if i == aiken.answer else 0.0)
|
|
63
|
+
for i, choice in enumerate(aiken.choices)
|
|
64
|
+
],
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def parse(self, source: str) -> AikenQuestion:
|
|
68
|
+
parser = AikenParser(source)
|
|
69
|
+
return parser.parse()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class AikenQuestion:
|
|
74
|
+
stem: str
|
|
75
|
+
choices: list[str]
|
|
76
|
+
answer: int
|
|
77
|
+
|
|
78
|
+
def __post_init__(self) -> None:
|
|
79
|
+
# A choice occupies exactly one Aiken line, so a newline in its text
|
|
80
|
+
# would render source that no longer reparses.
|
|
81
|
+
self.choices = [NEWLINE_REGEX.sub(" ", text).strip() for text in self.choices]
|
|
82
|
+
|
|
83
|
+
if len(self.choices) > len(ascii_lowercase):
|
|
84
|
+
raise ValueError(
|
|
85
|
+
f"Aiken supports at most {len(ascii_lowercase)} choices, "
|
|
86
|
+
f"got {len(self.choices)}"
|
|
87
|
+
)
|
|
88
|
+
if not 0 <= self.answer < len(self.choices):
|
|
89
|
+
raise ValueError(
|
|
90
|
+
f"answer index {self.answer} does not reference one of the "
|
|
91
|
+
f"{len(self.choices)} choices"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def __str__(self) -> str:
|
|
95
|
+
lines = [self.stem, ""]
|
|
96
|
+
for i, choice in enumerate(self.choices):
|
|
97
|
+
letter = ascii_lowercase[i]
|
|
98
|
+
lines.append(f"{letter}. {choice}")
|
|
99
|
+
|
|
100
|
+
answer = ascii_lowercase[self.answer]
|
|
101
|
+
lines.append("")
|
|
102
|
+
lines.append(f"ANSWER: {answer}")
|
|
103
|
+
return "\n".join(lines)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class AikenParser(StringParser[AikenQuestion]):
|
|
107
|
+
def start(self):
|
|
108
|
+
stem_lines = []
|
|
109
|
+
|
|
110
|
+
while not (m := self.match_group(CHOICE_REGEX, full=True)):
|
|
111
|
+
stem_lines.append(self.read())
|
|
112
|
+
|
|
113
|
+
choices = [self._choice_text(m, index=0)]
|
|
114
|
+
while m := self.match_group(CHOICE_REGEX, full=True):
|
|
115
|
+
choices.append(self._choice_text(m, index=len(choices)))
|
|
116
|
+
|
|
117
|
+
self.ws()
|
|
118
|
+
self.expect("ANSWER:", full=False)
|
|
119
|
+
self.ws()
|
|
120
|
+
|
|
121
|
+
char = self.read().strip().lower()
|
|
122
|
+
if len(char) != 1 or char not in ascii_lowercase:
|
|
123
|
+
self.error("answer must be a single letter")
|
|
124
|
+
answer = ascii_lowercase.index(char)
|
|
125
|
+
|
|
126
|
+
if not 0 <= answer < len(choices):
|
|
127
|
+
self.error(
|
|
128
|
+
f"ANSWER {char!r} does not reference one of the "
|
|
129
|
+
f"{len(choices)} parsed choices"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
return AikenQuestion(
|
|
133
|
+
stem="\n".join(stem_lines).strip(),
|
|
134
|
+
choices=choices,
|
|
135
|
+
answer=answer,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def _choice_text(self, match: dict[str, str], *, index: int) -> str:
|
|
139
|
+
"""
|
|
140
|
+
Validate that a matched choice's letter is the expected sequential
|
|
141
|
+
letter (a, b, c, ...) for its position, then return its text.
|
|
142
|
+
"""
|
|
143
|
+
letter = match["letter"].lower()
|
|
144
|
+
if index >= len(ascii_lowercase):
|
|
145
|
+
self.error(f"Aiken supports at most {len(ascii_lowercase)} choices")
|
|
146
|
+
expected = ascii_lowercase[index]
|
|
147
|
+
if letter != expected:
|
|
148
|
+
self.error(
|
|
149
|
+
f"expected choice {expected.upper()!r}, got {letter.upper()!r}"
|
|
150
|
+
)
|
|
151
|
+
return match["choice"]
|
mdq/convert/base.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
from typing import Any, ClassVar, Literal
|
|
5
|
+
|
|
6
|
+
from ..models import Question
|
|
7
|
+
from ..types import QuestionType
|
|
8
|
+
|
|
9
|
+
CONVERSION_REGISTRY: dict[str, ConversionBase | str] = {}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConversionBase[Q]:
|
|
13
|
+
"""
|
|
14
|
+
Base class for conversion types.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
supports: ClassVar[dict[QuestionType, Literal["import", "export", "both"]]]
|
|
18
|
+
|
|
19
|
+
def to_mdq(self, external: Q, /) -> Question:
|
|
20
|
+
"""
|
|
21
|
+
Import question to a MDQ representation.
|
|
22
|
+
"""
|
|
23
|
+
raise NotImplementedError
|
|
24
|
+
|
|
25
|
+
def from_mdq(self, mdq: Question, /) -> Q:
|
|
26
|
+
"""
|
|
27
|
+
Convert MDQ question to the external question format
|
|
28
|
+
"""
|
|
29
|
+
raise NotImplementedError
|
|
30
|
+
|
|
31
|
+
def render(self, external: Q, /) -> str:
|
|
32
|
+
"""
|
|
33
|
+
Render the external format as a string.
|
|
34
|
+
"""
|
|
35
|
+
return str(external)
|
|
36
|
+
|
|
37
|
+
def parse(self, source: str, /) -> Q:
|
|
38
|
+
"""
|
|
39
|
+
Parse source code in the external format into the internal
|
|
40
|
+
representation.
|
|
41
|
+
"""
|
|
42
|
+
raise NotImplementedError
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def register_format(format: str, *, converter: str | ConversionBase):
|
|
46
|
+
"""
|
|
47
|
+
Associate a format to the given conversion class instance.
|
|
48
|
+
"""
|
|
49
|
+
CONVERSION_REGISTRY[format.lower()] = converter
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load_converter(format: str) -> ConversionBase[Any]:
|
|
53
|
+
"""
|
|
54
|
+
Load a conversion instance from the registry.
|
|
55
|
+
"""
|
|
56
|
+
format = format.lower()
|
|
57
|
+
try:
|
|
58
|
+
converter = CONVERSION_REGISTRY[format]
|
|
59
|
+
except KeyError:
|
|
60
|
+
raise ValueError(f"Unknown format: {format}")
|
|
61
|
+
|
|
62
|
+
if isinstance(converter, str):
|
|
63
|
+
mod_name, cls_name = converter.split(":")
|
|
64
|
+
mod = importlib.import_module(mod_name)
|
|
65
|
+
converter_cls = getattr(mod, cls_name)
|
|
66
|
+
if not (
|
|
67
|
+
isinstance(converter_cls, type) and issubclass(converter_cls, ConversionBase)
|
|
68
|
+
):
|
|
69
|
+
msg = f"invalid registry: {format} is not a valid converter"
|
|
70
|
+
raise RuntimeError(msg)
|
|
71
|
+
converter = converter_cls()
|
|
72
|
+
CONVERSION_REGISTRY[format] = converter
|
|
73
|
+
|
|
74
|
+
return converter
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def import_question(source: str, /, *, format: str) -> Question:
|
|
78
|
+
"""
|
|
79
|
+
Load a question in the given format from source.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
source: Source code from the external question.
|
|
83
|
+
format: The external question format.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
converter = load_converter(format)
|
|
87
|
+
external = converter.parse(source)
|
|
88
|
+
return converter.to_mdq(external)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def export_question(question: Question, /, *, format: str):
|
|
92
|
+
"""
|
|
93
|
+
Convert a mdq question to a different format.
|
|
94
|
+
|
|
95
|
+
Renders the resulting source code.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
source: Source code from the external question.
|
|
99
|
+
format: The external question format.
|
|
100
|
+
"""
|
|
101
|
+
converter = load_converter(format)
|
|
102
|
+
if question.type not in converter.supports:
|
|
103
|
+
msg = f"{format!r} does not support {question.type!r} questions"
|
|
104
|
+
raise TypeError(msg)
|
|
105
|
+
external = converter.from_mdq(question)
|
|
106
|
+
return converter.render(external)
|