|
| 1 | +"""Turns a finished draft into the set it describes, to upload or to look at. |
| 2 | +
|
| 3 | +A draft is a map of fields - ``q1.text``, ``q1.p2.text``, ``q1.solution`` - and an |
| 4 | +export is a :class:`~in2lambda.api.set.Set` of questions holding parts. :func:`as_set` |
| 5 | +is the one place that reads the one as the other, so both what is written out and what |
| 6 | +is rendered for review come from the same reading of the draft. |
| 7 | +
|
| 8 | +:func:`build` refuses a draft the checks have not looked at, or have something to say |
| 9 | +about. There is no timestamp in that: every command that changes a draft takes its |
| 10 | +report with it, so a draft holding one has been checked since it last changed, and |
| 11 | +`in2lambda.source.frozen` refuses one whose source has moved on underneath it. |
| 12 | +:func:`render` is gated on nothing, since looking at a draft is how what the checks |
| 13 | +found gets fixed. |
| 14 | +""" |
| 15 | + |
| 16 | +import re |
| 17 | +import warnings |
| 18 | +from pathlib import Path |
| 19 | +from typing import Any |
| 20 | + |
| 21 | +from in2lambda.api.part import Part |
| 22 | +from in2lambda.api.question import Question |
| 23 | +from in2lambda.api.set import Set |
| 24 | +from in2lambda.json_convert.json_convert import _question_stem, _question_title |
| 25 | +from in2lambda.source import DRAFT, ConversionToolsMissing, SourceError, frozen |
| 26 | +from in2lambda.validation import _IMAGE, pdf |
| 27 | + |
| 28 | +_QUESTION = re.compile(r"q(\d+)\.text") |
| 29 | +"""A question's text, and the number that orders it.""" |
| 30 | + |
| 31 | +_PART = re.compile(r"q(\d+)\.p(\d+)\.text") |
| 32 | +"""A part's text, and the question and part numbers that order it.""" |
| 33 | + |
| 34 | + |
| 35 | +class NotValidated(SourceError): |
| 36 | + """A draft is being exported that the checks have not passed, or not seen at all.""" |
| 37 | + |
| 38 | + |
| 39 | +class MissingImage(SourceError): |
| 40 | + """A field refers to an image file that is not beside the draft. |
| 41 | +
|
| 42 | + The checks do not look at files, so such a draft validates clean; it is refused |
| 43 | + here rather than exported, since what would be uploaded is a question with a broken |
| 44 | + figure in it. |
| 45 | + """ |
| 46 | + |
| 47 | + |
| 48 | +def as_set(draft: dict[str, Any], directory: str = ".") -> Set: |
| 49 | + r"""The set a draft's fields describe, in question and part order. |
| 50 | +
|
| 51 | + Args: |
| 52 | + draft: A draft, as `in2lambda.source.frozen` reads one. |
| 53 | + directory: Where the draft is, and so what the images it names are beside. |
| 54 | +
|
| 55 | + Returns: |
| 56 | + One question per ``qN.text``, holding one part per ``qN.pM.text`` with the |
| 57 | + worked solution written for it. A question's own ``qN.solution`` answers every |
| 58 | + part that has none of its own; where every part has one already, or the |
| 59 | + question was written without parts, it becomes a part of its own holding |
| 60 | + nothing but that solution. That is the rule |
| 61 | + :meth:`~in2lambda.api.question.Question.add_solution` applies, so a draft |
| 62 | + exports as the same sheet converted by `in2lambda convert` does. A question |
| 63 | + written with neither parts nor a solution holds one part with nothing in it, |
| 64 | + which is the question as the draft has it. A block marked ignore is in no |
| 65 | + question: it is the source's, not the set's. |
| 66 | +
|
| 67 | + Examples: |
| 68 | + >>> from in2lambda.draft.export import as_set |
| 69 | + >>> fields = { |
| 70 | + ... "q1.text": {"value": "Water flows through a pipe."}, |
| 71 | + ... "q1.p1.text": {"value": "State the continuity equation."}, |
| 72 | + ... "q1.solution": {"value": "$Q = \\pi d^2 v / 4$."}, |
| 73 | + ... "b1.ignore": {"value": True}, |
| 74 | + ... } |
| 75 | + >>> as_set({"fields": fields}).questions |
| 76 | + [Question(title='', parts=[Part(text='State the continuity equation.', worked_solution='$Q = \\pi d^2 v / 4$.', answer='', response_areas=[])], images=[], main_text='Water flows through a pipe.')] |
| 77 | + """ |
| 78 | + fields = draft["fields"] |
| 79 | + question_set = Set() |
| 80 | + for number in sorted( |
| 81 | + int(found[1]) for key in fields if (found := _QUESTION.fullmatch(key)) |
| 82 | + ): |
| 83 | + question_set.add_question(main_text=fields[f"q{number}.text"]["value"]) |
| 84 | + question = question_set.questions[-1] |
| 85 | + for part in sorted( |
| 86 | + int(found[2]) |
| 87 | + for key in fields |
| 88 | + if (found := _PART.fullmatch(key)) and int(found[1]) == number |
| 89 | + ): |
| 90 | + question.add_part_text(fields[f"q{number}.p{part}.text"]["value"]) |
| 91 | + if (written := f"q{number}.p{part}.solution") in fields: |
| 92 | + question.parts[-1].worked_solution = fields[written]["value"] |
| 93 | + if (written := f"q{number}.solution") in fields: |
| 94 | + # A sheet often writes one worked solution for a whole question, which |
| 95 | + # answers each part it does not answer separately. Where nothing is left for |
| 96 | + # it to answer it is a part of its own, as `add_solution` makes it one: a |
| 97 | + # solution written beside a solution for every part is still the author's |
| 98 | + # wording, and dropping it would export less than the draft holds. |
| 99 | + if all(part_of.worked_solution for part_of in question.parts): |
| 100 | + question.parts.append(Part(worked_solution=fields[written]["value"])) |
| 101 | + else: |
| 102 | + for part_of in question.parts: |
| 103 | + if not part_of.worked_solution: |
| 104 | + part_of.worked_solution = fields[written]["value"] |
| 105 | + if not question.parts: |
| 106 | + # A question whose parts are yet to be written is still exported, and an |
| 107 | + # empty part is what it holds: `json_convert` leaves a question with no |
| 108 | + # parts at all carrying the template's own placeholder wording, which is |
| 109 | + # wording no field of the draft holds. |
| 110 | + question.parts.append(Part()) |
| 111 | + # As the export refers to them: beside the draft, since that is where a command |
| 112 | + # naming a file names one. Whether the file is there is `build`'s question, not |
| 113 | + # asked here, so that a draft can be rendered while its figures are being found. |
| 114 | + for _, markdown in _fields(question, number): |
| 115 | + question.images += [ |
| 116 | + str(Path(directory) / reference) |
| 117 | + for reference in _IMAGE.findall(markdown) |
| 118 | + ] |
| 119 | + return question_set |
| 120 | + |
| 121 | + |
| 122 | +def build(directory: str = ".", output_dir: str = "out") -> Path: |
| 123 | + """Writes the draft in a directory out as a Lambda Feedback set, if it is clean. |
| 124 | +
|
| 125 | + Args: |
| 126 | + directory: Where the ``draft.json`` to export is. |
| 127 | + output_dir: Where to write the set's folder and its zip. |
| 128 | +
|
| 129 | + Returns: |
| 130 | + The zip that was written, which is what Lambda Feedback imports. |
| 131 | +
|
| 132 | + Raises: |
| 133 | + NotValidated: the draft has not been checked since it last changed, or the |
| 134 | + checks found something. Either way what would be uploaded is not what |
| 135 | + anybody has looked at. |
| 136 | + MissingImage: a field refers to an image file that is not beside the draft. |
| 137 | + SourceError: the draft is missing, is not one of ours, or was written from |
| 138 | + markdown that has changed since. |
| 139 | + """ |
| 140 | + draft, _ = frozen(directory) |
| 141 | + if "report" not in draft: |
| 142 | + raise NotValidated( |
| 143 | + f"{DRAFT} has not been validated since it last changed, so what it would " |
| 144 | + "export is what nothing has checked. Run in2lambda validate." |
| 145 | + ) |
| 146 | + if draft["report"]: |
| 147 | + raise NotValidated( |
| 148 | + "\n".join(finding["message"] for finding in draft["report"]) |
| 149 | + + f"\n{DRAFT} is not exported while its report says this. Fix what it " |
| 150 | + "names, or mark the blocks it is about as ignored, and run in2lambda " |
| 151 | + "validate again." |
| 152 | + ) |
| 153 | + exported = as_set(draft, directory) |
| 154 | + # The export carries every image a field refers to into media/, which is the only |
| 155 | + # place Lambda Feedback looks for one, so a file that is not there is not something |
| 156 | + # to write the set without: `json_convert` would raise a bare FileNotFoundError over |
| 157 | + # it. The checks read the draft and not the folder it is in, so a draft they found |
| 158 | + # nothing in can still say this. |
| 159 | + for number, question in enumerate(exported.questions, start=1): |
| 160 | + for image in question.images: |
| 161 | + if not Path(image).is_file(): |
| 162 | + raise MissingImage( |
| 163 | + f"Question {number} refers to an image, and there is no file at " |
| 164 | + f"{image}. Put the image there, or take the reference out of the " |
| 165 | + "field with in2lambda draft field replace." |
| 166 | + ) |
| 167 | + exported.to_json(output_dir) |
| 168 | + return Path(output_dir) / "set.zip" |
| 169 | + |
| 170 | + |
| 171 | +def render(directory: str = ".", output_dir: str = "out") -> list[Path]: |
| 172 | + """Writes each question of the draft in a directory as a PDF, for review. |
| 173 | +
|
| 174 | + The questions are compiled as Lambda Feedback's own PDF generator compiles them, |
| 175 | + under a heading naming each, so what comes out is what a student would be shown. |
| 176 | + The checks are not run first: looking at a draft is how what they found gets fixed. |
| 177 | + Nor does a figure that is not beside the draft stop a question being looked at - |
| 178 | + the compiler drops the reference and typesets the rest of it - or a question the |
| 179 | + compiler gives up on altogether stop the rest of the draft being written out. |
| 180 | +
|
| 181 | + Args: |
| 182 | + directory: Where the ``draft.json`` to render is. |
| 183 | + output_dir: Where to write the PDFs, named as the export names its questions. |
| 184 | +
|
| 185 | + Returns: |
| 186 | + The PDF written for each question that was rendered, in question order. |
| 187 | +
|
| 188 | + Raises: |
| 189 | + ConversionToolsMissing: pandoc or xelatex is not installed. |
| 190 | + CompileFailed: no question could be rendered at all, so there is nothing to |
| 191 | + look at. |
| 192 | + SourceError: the draft is missing, is not one of ours, or was written from |
| 193 | + markdown that has changed since. |
| 194 | +
|
| 195 | + Warns: |
| 196 | + UserWarning: once per LaTeX error in a question that was rendered anyway, and |
| 197 | + once for a question the compiler gave up on while others rendered. |
| 198 | + """ |
| 199 | + if missing := pdf.missing_tools(): |
| 200 | + raise ConversionToolsMissing( |
| 201 | + f"Rendering questions needs {' and '.join(missing)}." |
| 202 | + ) |
| 203 | + draft, _ = frozen(directory) |
| 204 | + |
| 205 | + written = [] |
| 206 | + refused = [] |
| 207 | + for index, question in enumerate(as_set(draft, directory).questions): |
| 208 | + stem = _question_stem(index, _question_title(question, index)) |
| 209 | + output = Path(output_dir) / f"{stem}.pdf" |
| 210 | + # Headed with the question's number, so that a stack of these can be read |
| 211 | + # through, and so that a question with nothing written in it is still a page. |
| 212 | + heading = f"Question {index + 1}" |
| 213 | + fields = [(heading, f"# {heading}")] + _fields(question, index + 1) |
| 214 | + try: |
| 215 | + problems = pdf.render(fields, question.images, output) |
| 216 | + except pdf.CompileFailed as failed: |
| 217 | + refused.append(str(failed)) |
| 218 | + continue |
| 219 | + for problem in problems: |
| 220 | + warnings.warn(str(problem), stacklevel=2) |
| 221 | + written.append(output) |
| 222 | + if refused and not written: |
| 223 | + # Nothing at all to look at, which is a failed run rather than a fault in one |
| 224 | + # question of it, so it is said the way a draft that cannot be read is. |
| 225 | + raise pdf.CompileFailed("; ".join(refused)) |
| 226 | + for failure in refused: |
| 227 | + # One question TeX cannot finish is a fault in that question like any other, and |
| 228 | + # the ones that do compile are still what the draft is being rendered for. |
| 229 | + warnings.warn(failure, stacklevel=2) |
| 230 | + return written |
| 231 | + |
| 232 | + |
| 233 | +def _fields(question: Question, number: int) -> list[tuple[str, str]]: |
| 234 | + """Every markdown field of a question, each with where to report an error in it. |
| 235 | +
|
| 236 | + Named as `in2lambda.validation` names them, save for the title, since a draft |
| 237 | + writes none: the renderer marks the document with these, so what it reports back |
| 238 | + reads the same as what the validator reports. |
| 239 | + """ |
| 240 | + where = f"Question {number}" |
| 241 | + fields = [(f"{where}, main text", question.main_text)] |
| 242 | + for index, part in enumerate(question.parts): |
| 243 | + part_where = f"{where}, part ({chr(ord('a') + index)})" |
| 244 | + fields += [ |
| 245 | + (f"{part_where}, text", part.text), |
| 246 | + (f"{part_where}, worked solution", part.worked_solution), |
| 247 | + ] |
| 248 | + return fields |
0 commit comments