Skip to content

Commit aab451b

Browse files
Generate the question-format tables from the export (t33)
Generate the question-format tables from the export
2 parents c2aac73 + 70231fd commit aab451b

4 files changed

Lines changed: 315 additions & 51 deletions

File tree

‎docs/source/conf.py‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@
1717
sys.path.insert(0, os.path.abspath("."))
1818

1919
from filters import generate_filters_docs
20+
from question_format import generate_question_format_tables
2021

2122
generate_filters_docs()
23+
generate_question_format_tables()
2224

2325
sys.path.insert(0, os.path.abspath("../.."))
2426

@@ -60,7 +62,13 @@
6062
graphviz_output_format = "svg"
6163

6264
templates_path = ["_templates"]
63-
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
65+
exclude_patterns = [
66+
"_build",
67+
"Thumbs.db",
68+
".DS_Store",
69+
# Tables that question-format.md includes: pages of their own otherwise.
70+
"_autosummary/question-format/*.md",
71+
]
6472
source_suffix = [".rst", ".md"]
6573

6674
copybutton_prompt_text = "$ "

‎docs/source/question-format.md‎

Lines changed: 8 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -122,68 +122,26 @@ Files are written on a single line.
122122

123123
### Set
124124

125-
| key | set by | meaning |
126-
|---|---|---|
127-
| `name` | `Set.set_name` | names the folder, the zip and the file |
128-
| `description` | `Set.set_description` | shown with the set |
129-
| `finalAnswerVisibility` | `Set._finalAnswerVisibility` | `OPEN`, `HIDE` or `OPEN_WITH_WARNINGS` |
130-
| `workedSolutionVisibility` | `Set._workedSolutionVisibility` | as above |
131-
| `structuredTutorialVisibility` | `Set._structuredTutorialVisibility` | as above |
132-
| `manuallyHidden` | fixed by template (`true`) | |
133-
| `chatbotVisibility` | fixed by template (`"HIDE"`) | |
125+
```{include} _autosummary/question-format/set.md
126+
```
134127

135128
Each visibility is a {class}`~in2lambda.api.visibility_status.VisibilityController`, changed with
136129
`to_open()`, `to_hide()` or `to_open_with_warnings()`.
137130

138131
### Question
139132

140-
| key | set by | meaning |
141-
|---|---|---|
142-
| `orderNumber` | position in `Set.questions`, from 0 | matches the filename |
143-
| `title` | `Question.title` | shown to students; an empty title becomes `Question N` |
144-
| `masterContent` | `Question.main_text` | markdown shared by every part: setup, data, figure |
145-
| `skill` | `Question.skill` | difficulty; exports use 1/3 and 2/3. Omitted if unset |
146-
| `guidance` | `Question.guidance` | a sentence to students about the question's purpose. Omitted if unset |
147-
| `durationLowerBound`, `durationUpperBound` | `Question.duration_lower_bound`, `.duration_upper_bound` | expected minutes. Omitted if unset |
148-
| `publish` | `Question.publish` | |
149-
| `displayFinalAnswer` | `Question.display_final_answer` | |
150-
| `displayWorkedSolution` | `Question.display_worked_solution` | |
151-
| `displayStructuredTutorial` | `Question.display_structured_tutorial` | |
152-
| `displayChatbot` | `Question.display_chatbot` | |
153-
| `parts` | `Question.parts` | ordered from 0; students see (a), (b), ... |
133+
```{include} _autosummary/question-format/question.md
134+
```
154135

155136
### Part
156137

157-
| key | set by | meaning |
158-
|---|---|---|
159-
| `orderNumber` | position in `Question.parts`, from 0 | |
160-
| `content` | `Part.text` | markdown for this part |
161-
| `answerContent` | `Part.answer` | the final answer shown to students, markdown |
162-
| `responseAreas` | `Part.response_areas` | the answer boxes; may be empty |
163-
| `workedSolution.content` | `Part.worked_solution` | markdown, split into tutorial steps on `---` |
164-
| `workedSolution.children` | fixed by template (`[]`) | |
138+
```{include} _autosummary/question-format/part.md
139+
```
165140

166141
### Response area
167142

168-
| key | set by | meaning |
169-
|---|---|---|
170-
| `orderNumber` | position in `Part.response_areas`, from 0 | |
171-
| `preResponseText`, `postResponseText` | `pre_text`, `post_text` | labels either side of the box |
172-
| `contentAfter` | `content_after` | markdown shown after the box, before the next one |
173-
| `inputSymbols` | `input_symbols` | see below |
174-
| `displayInputSymbols` | `display_input_symbols` | whether students are shown the palette |
175-
| `evaluationFunctionName` | `evaluation_function` | how Lambda Feedback marks the response |
176-
| `gradeParams` | `grade_params` | that function's settings; see below |
177-
| `livePreview` | `live_preview` | render what is typed as the student types |
178-
| `includeInPdf` | `include_in_pdf` | |
179-
| `saveAllowed` | `save_allowed` | |
180-
| `separateFeedback` | `separate_feedback` | |
181-
| `commonFeedbackColor`, `correctFeedbackColor`, `correctFeedbackPrefix`, `incorrectFeedbackColor`, `incorrectFeedbackPrefix` | `common_feedback_color`, `correct_feedback_color`, `correct_feedback_prefix`, `incorrect_feedback_color`, `incorrect_feedback_prefix` | default to what Lambda Feedback fills in |
182-
| `tests` | `tests` | see below |
183-
| `cases` | `cases` | see below |
184-
| `response.responseInput.responseType` | `response_type` | which box students get |
185-
| `response.responseInput.answer` | `answer` | the correct answer |
186-
| `response.responseInput.config` | `config` | the box's own settings; see below |
143+
```{include} _autosummary/question-format/response_area.md
144+
```
187145

188146
The three types in2lambda writes:
189147

‎docs/source/question_format.py‎

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
"""Builds the key tables on the question format page from the real exports.
2+
3+
Those tables used to be written by hand, so a change to Lambda Feedback's schema left
4+
the page quietly describing the old one. They are now collected at docs build from the
5+
exports in ``tests/fixtures/exports``: one table per kind of object, listing every key
6+
those exports carry, its JSON type and an example value. What a key *means* cannot come
7+
from the data, so it is kept in ``NOTES`` below, and a key with no note is an error
8+
rather than a blank cell.
9+
"""
10+
11+
import json
12+
from pathlib import Path
13+
from typing import Any, Iterator, Sequence
14+
15+
EXPORTS_DIR = Path(__file__).parents[2] / "tests" / "fixtures" / "exports"
16+
"""Real Lambda Feedback exports, the same ones the test suite round-trips."""
17+
18+
EXPORTS = sorted(path for path in EXPORTS_DIR.iterdir() if path.is_dir())
19+
"""Every export folder, found rather than listed, as the test suite finds them."""
20+
21+
OUT_DIR = Path(__file__).parent / "_autosummary" / "question-format"
22+
"""Where the tables are written for ``question-format.md`` to include."""
23+
24+
NOTES: dict[str, dict[str, str]] = {
25+
"set": {
26+
"name": "`Set.set_name` — names the folder, the zip and the file",
27+
"description": "`Set.set_description` — shown with the set",
28+
"isSurvey": "Neither read nor written by in2lambda; import fills it in",
29+
"releasedAt": "Neither read nor written by in2lambda; import fills it in",
30+
"manuallyHidden": "Fixed by the template (`true`)",
31+
"finalAnswerVisibility": (
32+
"`Set._finalAnswerVisibility` — `OPEN`, `HIDE` or `OPEN_WITH_WARNINGS`"
33+
),
34+
"workedSolutionVisibility": "`Set._workedSolutionVisibility` — as above",
35+
"structuredTutorialVisibility": "`Set._structuredTutorialVisibility` — as above",
36+
"chatbotVisibility": 'Fixed by the template (`"HIDE"`), whatever an export says',
37+
},
38+
"question": {
39+
"orderNumber": "Position in `Set.questions`, from 0; matches the filename",
40+
"title": "`Question.title` — shown to students; an empty title becomes `Question N`",
41+
"masterContent": (
42+
"`Question.main_text` — markdown shared by every part: setup, data, figure"
43+
),
44+
"skill": "`Question.skill` — difficulty; exports use 1/3 and 2/3. Omitted if unset",
45+
"guidance": (
46+
"`Question.guidance` — a sentence to students about the question's purpose."
47+
" Omitted if unset"
48+
),
49+
"durationLowerBound": (
50+
"`Question.duration_lower_bound` — expected minutes. Omitted if unset"
51+
),
52+
"durationUpperBound": (
53+
"`Question.duration_upper_bound` — expected minutes. Omitted if unset"
54+
),
55+
"publish": "`Question.publish` — whether the question is visible to students",
56+
"displayFinalAnswer": "`Question.display_final_answer`",
57+
"displayWorkedSolution": "`Question.display_worked_solution`",
58+
"displayStructuredTutorial": "`Question.display_structured_tutorial`",
59+
"displayChatbot": "`Question.display_chatbot`",
60+
"parts": "`Question.parts` — ordered from 0; students see (a), (b), ...",
61+
},
62+
"part": {
63+
"orderNumber": "Position in `Question.parts`, from 0",
64+
"content": "`Part.text` — markdown for this part",
65+
"answerContent": "`Part.answer` — the final answer shown to students, markdown",
66+
"responseAreas": "`Part.response_areas` — the answer boxes; may be empty",
67+
"workedSolution.content": (
68+
"`Part.worked_solution` — markdown, split into tutorial steps on `---`"
69+
),
70+
"workedSolution.children": "Fixed by the template (`[]`)",
71+
},
72+
"response_area": {
73+
"orderNumber": "Position in `Part.response_areas`, from 0",
74+
"preResponseText": "`pre_text` — the label before the box",
75+
"postResponseText": "`post_text` — the label after the box",
76+
"contentAfter": "`content_after` — markdown shown after the box, before the next",
77+
"inputSymbols": "`input_symbols` — the symbol palette; see below",
78+
"displayInputSymbols": (
79+
"`display_input_symbols` — whether students are shown the palette"
80+
),
81+
"evaluationFunctionName": (
82+
"`evaluation_function` — how Lambda Feedback marks the response"
83+
),
84+
"gradeParams": "`grade_params` — that function's settings; see below",
85+
"livePreview": "`live_preview` — render what is typed as the student types",
86+
"includeInPdf": "`include_in_pdf` — whether the box appears in the printed question",
87+
"saveAllowed": "`save_allowed` — whether a response can be saved unsubmitted",
88+
"separateFeedback": "`separate_feedback` — show feedback apart from the mark",
89+
"commonFeedbackColor": (
90+
"`common_feedback_color` — defaults to what Lambda Feedback fills in"
91+
),
92+
"correctFeedbackColor": (
93+
"`correct_feedback_color` — defaults to what Lambda Feedback fills in"
94+
),
95+
"correctFeedbackPrefix": (
96+
"`correct_feedback_prefix` — defaults to what Lambda Feedback fills in"
97+
),
98+
"incorrectFeedbackColor": (
99+
"`incorrect_feedback_color` — defaults to what Lambda Feedback fills in"
100+
),
101+
"incorrectFeedbackPrefix": (
102+
"`incorrect_feedback_prefix` — defaults to what Lambda Feedback fills in"
103+
),
104+
"tests": "`tests` — the author's own checks of the marking; see below",
105+
"cases": "`cases` — feedback for particular responses; see below",
106+
"response.responseInput.responseType": "`response_type` — which box students get",
107+
"response.responseInput.answer": "`answer` — the correct answer",
108+
"response.responseInput.config": "`config` — the box's own settings; see below",
109+
},
110+
}
111+
"""What each key means, per kind of object, keyed by the key path in the table."""
112+
113+
114+
def _json_type(value: Any) -> str:
115+
if value is None:
116+
return "null"
117+
if isinstance(value, bool):
118+
return "boolean"
119+
if isinstance(value, (int, float)):
120+
return "number"
121+
if isinstance(value, str):
122+
return "string"
123+
if isinstance(value, list):
124+
return "array"
125+
return "object"
126+
127+
128+
def _flatten(
129+
obj: dict, notes: dict[str, str], path: str = ""
130+
) -> Iterator[tuple[str, Any]]:
131+
"""Each key path in `obj` and its value.
132+
133+
A key holding an object is descended into, so that `workedSolution` becomes
134+
`workedSolution.content`, unless it has a note of its own — `gradeParams` and
135+
`config` hold whatever the box type needs, and are described in prose instead.
136+
An empty object is a row like any other: descending into it would yield nothing,
137+
so a new key holding `{}` would go unnoticed rather than asking for a note.
138+
"""
139+
for key, value in obj.items():
140+
full_path = f"{path}{key}"
141+
if isinstance(value, dict) and value and full_path not in notes:
142+
yield from _flatten(value, notes, f"{full_path}.")
143+
else:
144+
yield full_path, value
145+
146+
147+
def _keys(objects: list[dict], notes: dict[str, str]) -> dict[str, dict]:
148+
"""Every key path the objects carry, in order of first appearance."""
149+
keys: dict[str, dict] = {}
150+
for obj in objects:
151+
for path, value in _flatten(obj, notes):
152+
key = keys.setdefault(path, {"types": set(), "example": value, "seen": 0})
153+
key["seen"] += 1
154+
key["types"].add(_json_type(value))
155+
# An empty string or list says nothing about the key, so keep looking.
156+
if key["example"] in (None, "", [], {}):
157+
key["example"] = value
158+
for key in keys.values():
159+
key["optional"] = key["seen"] < len(objects)
160+
return keys
161+
162+
163+
def _cell(value: Any) -> str:
164+
"""One example value, short enough to sit in a table and not read as markdown."""
165+
example = json.dumps(value)
166+
if len(example) > 40:
167+
example = f"{example[:39]}…"
168+
return f"`{example.replace('|', chr(92) + '|')}`"
169+
170+
171+
def _table(keys: dict[str, dict], notes: dict[str, str]) -> str:
172+
rows = ["| key | type | example | meaning |", "|---|---|---|---|"]
173+
for path, key in keys.items():
174+
types = " or ".join(sorted(key["types"]))
175+
if key["optional"]:
176+
types += ", optional"
177+
rows.append(f"| `{path}` | {types} | {_cell(key['example'])} | {notes[path]} |")
178+
return "\n".join(rows) + "\n"
179+
180+
181+
def _roots(export_dirs: Sequence[Path]) -> dict[str, list[dict]]:
182+
"""The objects each table is rooted on, gathered from every export."""
183+
roots: dict[str, list[dict]] = {
184+
"set": [],
185+
"question": [],
186+
"part": [],
187+
"response_area": [],
188+
}
189+
for export_dir in export_dirs:
190+
for file in sorted(export_dir.glob("set_*.json")):
191+
roots["set"].append(json.loads(file.read_text()))
192+
for file in sorted(export_dir.glob("question_*.json")):
193+
question = json.loads(file.read_text())
194+
roots["question"].append(question)
195+
for part in question.get("parts", []):
196+
roots["part"].append(part)
197+
roots["response_area"] += part.get("responseAreas", [])
198+
return roots
199+
200+
201+
def generate_question_format_tables(
202+
export_dirs: Sequence[Path] = EXPORTS, out_dir: Path = OUT_DIR
203+
) -> None:
204+
"""Write a markdown table of keys per kind of object for the page to include.
205+
206+
Args:
207+
export_dirs: Lambda Feedback export folders to read the keys from.
208+
out_dir: where `set.md`, `question.md`, `part.md` and `response_area.md` go.
209+
210+
Raises:
211+
ValueError: if an export carries a key with no note in `NOTES`, or a note
212+
names a key no export carries. Either way the page and the schema have
213+
drifted apart, and the error names every key that has to be dealt with.
214+
"""
215+
collected = {
216+
kind: _keys(objects, NOTES[kind])
217+
for kind, objects in _roots(export_dirs).items()
218+
}
219+
220+
drifted = []
221+
for kind, keys in collected.items():
222+
notes = NOTES[kind]
223+
drifted += [f"{kind}: no note for {path}" for path in keys if path not in notes]
224+
drifted += [
225+
f"{kind}: note for {path}, which no export carries"
226+
for path in notes
227+
if path not in keys
228+
]
229+
if drifted:
230+
raise ValueError("; ".join(drifted))
231+
232+
out_dir.mkdir(parents=True, exist_ok=True)
233+
for kind, keys in collected.items():
234+
(out_dir / f"{kind}.md").write_text(_table(keys, NOTES[kind]))

0 commit comments

Comments
 (0)