Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@
- `in2lambda draft field replace FIELD OLD NEW` changes the wording inside a field that is already written, for the faults only an edit can fix - a brace the OCR dropped out of some maths, which no range of the source says correctly. OLD has to occur in the field exactly once, or the command is refused saying how many times it occurs; `--regex` reads it as a regular expression and NEW as what to replace it with. The field is left quoting the lines it was taken from, at the layer that wrote it, but recorded as edited and by whoever replaced the wording, so the change can be shown against the source.
- `in2lambda spec run SPEC` runs a YAML file of selectors over the frozen source: it says which blocks are questions, parts and solutions, which to ignore, what to strip off the front of each one, and which of the four filters lays the solutions out. It fills in the draft's fields with the markdown of the lines each was taken from, records the spec's name and hash in the log so a replay runs the same file, and reports every block it made nothing of. Running an edited spec over a draft it has already filled in is refused, as freezing a document that has changed is: `in2lambda source add --start-over` begins the draft again. Reading a spec needs pyyaml, which the `convert` extra now installs alongside panflute. See [the spec page](https://lambda-feedback.github.io/in2lambda/spec.html) for the selectors and layouts.
- `in2lambda validate` checks a draft over as a whole and writes what it finds into it as a `report`: source blocks in no field and not marked ignore, two fields taken from the same lines, gaps in the numbering of the questions or their parts, parts nothing answers, and fields holding nothing. Each finding names the field and the lines it is about, so it can be acted on without reading the draft. Finding something is not a failure and the command still exits 0; the report is replaced by the next run of the checks and dropped by the next command that changes the draft, since it describes the draft as it stood.
- `in2lambda build` writes the draft in this directory out as a Lambda Feedback set: one question per `qN.text` field, holding the parts written for it and the worked solutions, with the images those fields refer to under `media/`, as `in2lambda convert` writes a set - a field naming an image that is not beside the draft is refused saying which file is missing, since the checks read the draft and not the folder it is in, and a question's own solution written beside a solution for every part it has becomes a part of its own holding just that solution, as `convert` pairs them up. It is refused unless `in2lambda validate` has been run since the draft last changed - every command that changes one drops its report - and found nothing, and the refusal prints what the report says so it can be acted on without opening the draft. `in2lambda render` writes each question as a PDF instead, compiled as Lambda Feedback's own PDF generator compiles it, which needs pandoc and xelatex; it is gated on nothing, since looking at a draft is how what the checks found gets fixed. Both take `-o/--out`, as `convert` does.
- The Python API is unchanged: `in2lambda.main.runner` and everything under `in2lambda.api` take the same arguments and return the same objects.
248 changes: 248 additions & 0 deletions in2lambda/draft/export.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
"""Turns a finished draft into the set it describes, to upload or to look at.

A draft is a map of fields - ``q1.text``, ``q1.p2.text``, ``q1.solution`` - and an
export is a :class:`~in2lambda.api.set.Set` of questions holding parts. :func:`as_set`
is the one place that reads the one as the other, so both what is written out and what
is rendered for review come from the same reading of the draft.

:func:`build` refuses a draft the checks have not looked at, or have something to say
about. There is no timestamp in that: every command that changes a draft takes its
report with it, so a draft holding one has been checked since it last changed, and
`in2lambda.source.frozen` refuses one whose source has moved on underneath it.
:func:`render` is gated on nothing, since looking at a draft is how what the checks
found gets fixed.
"""

import re
import warnings
from pathlib import Path
from typing import Any

from in2lambda.api.part import Part
from in2lambda.api.question import Question
from in2lambda.api.set import Set
from in2lambda.json_convert.json_convert import _question_stem, _question_title
from in2lambda.source import DRAFT, ConversionToolsMissing, SourceError, frozen
from in2lambda.validation import _IMAGE, pdf

_QUESTION = re.compile(r"q(\d+)\.text")
"""A question's text, and the number that orders it."""

_PART = re.compile(r"q(\d+)\.p(\d+)\.text")
"""A part's text, and the question and part numbers that order it."""


class NotValidated(SourceError):
"""A draft is being exported that the checks have not passed, or not seen at all."""


class MissingImage(SourceError):
"""A field refers to an image file that is not beside the draft.

The checks do not look at files, so such a draft validates clean; it is refused
here rather than exported, since what would be uploaded is a question with a broken
figure in it.
"""


def as_set(draft: dict[str, Any], directory: str = ".") -> Set:
r"""The set a draft's fields describe, in question and part order.

Args:
draft: A draft, as `in2lambda.source.frozen` reads one.
directory: Where the draft is, and so what the images it names are beside.

Returns:
One question per ``qN.text``, holding one part per ``qN.pM.text`` with the
worked solution written for it. A question's own ``qN.solution`` answers every
part that has none of its own; where every part has one already, or the
question was written without parts, it becomes a part of its own holding
nothing but that solution. That is the rule
:meth:`~in2lambda.api.question.Question.add_solution` applies, so a draft
exports as the same sheet converted by `in2lambda convert` does. A question
written with neither parts nor a solution holds one part with nothing in it,
which is the question as the draft has it. A block marked ignore is in no
question: it is the source's, not the set's.

Examples:
>>> from in2lambda.draft.export import as_set
>>> fields = {
... "q1.text": {"value": "Water flows through a pipe."},
... "q1.p1.text": {"value": "State the continuity equation."},
... "q1.solution": {"value": "$Q = \\pi d^2 v / 4$."},
... "b1.ignore": {"value": True},
... }
>>> as_set({"fields": fields}).questions
[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.')]
"""
fields = draft["fields"]
question_set = Set()
for number in sorted(
int(found[1]) for key in fields if (found := _QUESTION.fullmatch(key))
):
question_set.add_question(main_text=fields[f"q{number}.text"]["value"])
question = question_set.questions[-1]
for part in sorted(
int(found[2])
for key in fields
if (found := _PART.fullmatch(key)) and int(found[1]) == number
):
question.add_part_text(fields[f"q{number}.p{part}.text"]["value"])
if (written := f"q{number}.p{part}.solution") in fields:
question.parts[-1].worked_solution = fields[written]["value"]
if (written := f"q{number}.solution") in fields:
# A sheet often writes one worked solution for a whole question, which
# answers each part it does not answer separately. Where nothing is left for
# it to answer it is a part of its own, as `add_solution` makes it one: a
# solution written beside a solution for every part is still the author's
# wording, and dropping it would export less than the draft holds.
if all(part_of.worked_solution for part_of in question.parts):
question.parts.append(Part(worked_solution=fields[written]["value"]))
else:
for part_of in question.parts:
if not part_of.worked_solution:
part_of.worked_solution = fields[written]["value"]
if not question.parts:
# A question whose parts are yet to be written is still exported, and an
# empty part is what it holds: `json_convert` leaves a question with no
# parts at all carrying the template's own placeholder wording, which is
# wording no field of the draft holds.
question.parts.append(Part())
# As the export refers to them: beside the draft, since that is where a command
# naming a file names one. Whether the file is there is `build`'s question, not
# asked here, so that a draft can be rendered while its figures are being found.
for _, markdown in _fields(question, number):
question.images += [
str(Path(directory) / reference)
for reference in _IMAGE.findall(markdown)
]
return question_set


def build(directory: str = ".", output_dir: str = "out") -> Path:
"""Writes the draft in a directory out as a Lambda Feedback set, if it is clean.

Args:
directory: Where the ``draft.json`` to export is.
output_dir: Where to write the set's folder and its zip.

Returns:
The zip that was written, which is what Lambda Feedback imports.

Raises:
NotValidated: the draft has not been checked since it last changed, or the
checks found something. Either way what would be uploaded is not what
anybody has looked at.
MissingImage: a field refers to an image file that is not beside the draft.
SourceError: the draft is missing, is not one of ours, or was written from
markdown that has changed since.
"""
draft, _ = frozen(directory)
if "report" not in draft:
raise NotValidated(
f"{DRAFT} has not been validated since it last changed, so what it would "
"export is what nothing has checked. Run in2lambda validate."
)
if draft["report"]:
raise NotValidated(
"\n".join(finding["message"] for finding in draft["report"])
+ f"\n{DRAFT} is not exported while its report says this. Fix what it "
"names, or mark the blocks it is about as ignored, and run in2lambda "
"validate again."
)
exported = as_set(draft, directory)
# The export carries every image a field refers to into media/, which is the only
# place Lambda Feedback looks for one, so a file that is not there is not something
# to write the set without: `json_convert` would raise a bare FileNotFoundError over
# it. The checks read the draft and not the folder it is in, so a draft they found
# nothing in can still say this.
for number, question in enumerate(exported.questions, start=1):
for image in question.images:
if not Path(image).is_file():
raise MissingImage(
f"Question {number} refers to an image, and there is no file at "
f"{image}. Put the image there, or take the reference out of the "
"field with in2lambda draft field replace."
)
exported.to_json(output_dir)
return Path(output_dir) / "set.zip"


def render(directory: str = ".", output_dir: str = "out") -> list[Path]:
"""Writes each question of the draft in a directory as a PDF, for review.

The questions are compiled as Lambda Feedback's own PDF generator compiles them,
under a heading naming each, so what comes out is what a student would be shown.
The checks are not run first: looking at a draft is how what they found gets fixed.
Nor does a figure that is not beside the draft stop a question being looked at -
the compiler drops the reference and typesets the rest of it - or a question the
compiler gives up on altogether stop the rest of the draft being written out.

Args:
directory: Where the ``draft.json`` to render is.
output_dir: Where to write the PDFs, named as the export names its questions.

Returns:
The PDF written for each question that was rendered, in question order.

Raises:
ConversionToolsMissing: pandoc or xelatex is not installed.
CompileFailed: no question could be rendered at all, so there is nothing to
look at.
SourceError: the draft is missing, is not one of ours, or was written from
markdown that has changed since.

Warns:
UserWarning: once per LaTeX error in a question that was rendered anyway, and
once for a question the compiler gave up on while others rendered.
"""
if missing := pdf.missing_tools():
raise ConversionToolsMissing(
f"Rendering questions needs {' and '.join(missing)}."
)
draft, _ = frozen(directory)

written = []
refused = []
for index, question in enumerate(as_set(draft, directory).questions):
stem = _question_stem(index, _question_title(question, index))
output = Path(output_dir) / f"{stem}.pdf"
# Headed with the question's number, so that a stack of these can be read
# through, and so that a question with nothing written in it is still a page.
heading = f"Question {index + 1}"
fields = [(heading, f"# {heading}")] + _fields(question, index + 1)
try:
problems = pdf.render(fields, question.images, output)
except pdf.CompileFailed as failed:
refused.append(str(failed))
continue
for problem in problems:
warnings.warn(str(problem), stacklevel=2)
written.append(output)
if refused and not written:
# Nothing at all to look at, which is a failed run rather than a fault in one
# question of it, so it is said the way a draft that cannot be read is.
raise pdf.CompileFailed("; ".join(refused))
for failure in refused:
# One question TeX cannot finish is a fault in that question like any other, and
# the ones that do compile are still what the draft is being rendered for.
warnings.warn(failure, stacklevel=2)
return written


def _fields(question: Question, number: int) -> list[tuple[str, str]]:
"""Every markdown field of a question, each with where to report an error in it.

Named as `in2lambda.validation` names them, save for the title, since a draft
writes none: the renderer marks the document with these, so what it reports back
reads the same as what the validator reports.
"""
where = f"Question {number}"
fields = [(f"{where}, main text", question.main_text)]
for index, part in enumerate(question.parts):
part_where = f"{where}, part ({chr(ord('a') + index)})"
fields += [
(f"{part_where}, text", part.text),
(f"{part_where}, worked solution", part.worked_solution),
]
return fields
47 changes: 47 additions & 0 deletions in2lambda/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import rich_click as click

import in2lambda.draft
import in2lambda.draft.export
import in2lambda.draft.report
import in2lambda.filters
import in2lambda.source
Expand Down Expand Up @@ -434,5 +435,51 @@ def validate() -> None:
click.echo("Nothing to report.")


_out = click.option(
"--out",
"-o",
"output_dir",
default="./out",
show_default=True,
help="Directory to write the files to.",
type=click.Path(resolve_path=True),
)
"""Where what a command makes is written, as `convert` has always taken it."""


@cli.command("build")
@_out
def build(output_dir: str) -> None:
"""Writes the draft in this directory out as a Lambda Feedback set.

Refused unless in2lambda validate has been run since the draft last changed and
found nothing, so that what is uploaded is what the checks have been over.
"""
with _message_not_traceback():
written = in2lambda.draft.export.build(output_dir=output_dir)
click.echo(f"Wrote {written}")


@cli.command("render")
@_out
def render(output_dir: str) -> None:
"""Writes each question of the draft in this directory as a PDF, for review.

The questions are compiled as Lambda Feedback's PDF generator compiles them, which
needs pandoc and xelatex. What the checks have to say about the draft is not asked:
a draft is rendered to look at, including one there is something to fix in.
"""
with _message_not_traceback():
# As `runner` does: a question xelatex complains about is still written out, and
# what it refused is a line to read rather than a traceback.
with warnings.catch_warnings(record=True) as refused:
warnings.simplefilter("always")
written = in2lambda.draft.export.render(output_dir=output_dir)
for warning in refused:
click.echo(f"Warning: {warning.message}")
for pdf in written:
click.echo(f"Wrote {pdf}")


if __name__ == "__main__":
cli()
Loading
Loading