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 @@ -9,6 +9,7 @@
- `in2lambda source add` writes the markdown of a converted .docx or .tex unwrapped, so that a paragraph is one line however long the paragraph is, and an inline `$ ... $` is never broken across two lines. `in2lambda source add` also moves every `$$ ... $$` that pandoc wrote on one line onto lines of its own, in a paragraph and in a list item, indented to the item's width where the maths is written in a list item. A `$$` that opens or closes in a table cell, in a block quote or in a code block is left unchanged, as is the maths that an unpaired `$$` elsewhere in the document, such as one in inline code, pairs with. `in2lambda validate` reports the maths left unchanged. pandoc's writer produces both forms, and Lambda Feedback renders neither, so `in2lambda validate` reported them against every converted sheet. The markdown beside a document frozen before this release differs, and so do its line ranges. `in2lambda source add --start-over` freezes the document again.
- A draft now holds a `log` of every command that changed it, and a `fields` map of the fields those commands wrote. Each field records the layer that wrote it (1 a spec, 2 a predicate, 3 a line range, 4 a literal), the source ranges it was copied from, whether it has been edited, and by whom. `in2lambda draft mark ignore BLOCK` is the first such command. `in2lambda draft replay` rebuilds the draft from the frozen markdown and the log, and refuses unless the draft it builds matches the draft on disk byte for byte. A draft written before this release holds no `log` and is refused as a draft in2lambda did not write; `in2lambda source add --start-over` freezes the document again.
- `in2lambda draft question add`, `in2lambda draft part add QUESTION` and `in2lambda draft question solution QUESTION` fill a draft in. Each takes `--text` to copy the wording out of the frozen source, as a block id such as `b3` or as lines such as `s10:14`. Each takes `--literal TEXT` for wording the source does not hold in a form the field can take, which records the field as edited and written by layer 4 in place of layer 3. in2lambda works the question and part numbers out from the fields already written, so a replay arrives at the same ids. `in2lambda draft split block BLOCK AT` cuts a block the parser made one of two things into `b3a` and `b3b`, so that each half can be quoted on its own. A command writing a field that is already written is refused, naming the field. A command quoting lines another field was taken from is refused, naming both fields.
- `in2lambda draft part solution PART --text RANGE` gives one part of a question its worked solution, for a sheet that writes a solution under each part rather than one solution answering the whole question. PART is written `q1.p2`. The command writes `q1.p2.solution` from the lines named, or from `--literal TEXT`, as `in2lambda draft question solution` writes a question's solution. A PART naming a question, or naming a part the draft has not written, is refused, naming the part. A sheet laid out this way can now be answered part by part by commands, which only a spec's `PartPartSolSol` and `PartSolPartSol` layouts could do before, so the warning `in2lambda validate` prints about a part nothing answers can be acted on.
- `in2lambda draft field replace FIELD OLD NEW` changes the wording inside a written field, for the faults only an edit can fix, such as a brace the OCR dropped out of some maths that no range of the source states correctly. OLD must occur in the field once, or the command is refused, reporting how many times OLD occurs. `--regex` reads OLD as a regular expression and NEW as the replacement. The field keeps the lines it was taken from and the layer that wrote it, and records that it was edited and by whom, so the change can be shown against the source.
- `in2lambda draft field set FIELD --text RANGE` quotes lines of a frozen source into a field that is already written, for a field quoted from the wrong lines. A spec matching the label line `Q4` alone writes an empty `q4.text`, which `in2lambda validate` reports as empty. `in2lambda draft field set` writes the field again at layer 3, with the range of the lines quoted and `edited` false, as `in2lambda draft question add --text` writes a field. The command drops the range the field named before, and `in2lambda validate` reports those lines as in no field. `in2lambda draft field set` takes no `--literal`: `in2lambda draft field replace` writes text that no line of the source holds.
- `in2lambda spec run SPEC` runs a YAML file of selectors over the frozen source. The spec says which blocks are questions, parts and solutions, which blocks to ignore, what to strip off the front of each one, and which of the four filters lays the solutions out. `in2lambda spec run` fills the draft's fields in with the markdown of the lines each was taken from, records the spec's name and hash in the log so that a replay runs the same file, and reports every block it matched to no field. 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.
Expand Down
6 changes: 6 additions & 0 deletions docs/source/drafts.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,12 @@ $ in2lambda draft question solution q2 --text b8b
Wrote q2.solution.
```

This sheet writes one solution per question. A sheet that writes a solution under each part is
quoted part by part instead, by `in2lambda draft part solution PART --text RANGE`, where PART is
written `q1.p2`. It writes `q1.p2.solution`, takes `--text` and `--literal` as
`in2lambda draft question solution` does, and refuses a part that `in2lambda draft part add` has
not written.

## Change what a field says

The sheet writes the second solution with `\half`, which KaTeX does not define, and
Expand Down
49 changes: 49 additions & 0 deletions in2lambda/draft/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@
_RANGE = re.compile(r"s(\d+)(?::(\d+))?")
"""Lines of a frozen source, as ``s16`` for one of them or ``s10:14`` for several."""

_PART = re.compile(r"q\d+\.p\d+")
"""A part of a question, as the draft names one: ``q1.p2``."""

_QUALIFIED = re.compile(r"(\d+)/([^/]*)")
"""A block id or a line range with its source in front: ``2/b3``, ``2/s10:14``.

Expand Down Expand Up @@ -79,6 +82,10 @@ class NoSuchQuestion(SourceError):
"""A command adds to a question no command has written."""


class NoSuchPart(SourceError):
"""A command adds to a part nothing has written yet."""


class AlreadyFilled(SourceError):
"""A command would write a written field, or lines another field was taken from."""

Expand Down Expand Up @@ -587,6 +594,23 @@ def _require_question(draft: dict[str, Any], question: str, command: str) -> Non
)


def _require_part(draft: dict[str, Any], part: str, command: str) -> None:
"""Checks the draft has the part a command adds to.

The id is checked for its shape as well as for being written, so that a question id
given where a part was asked for is refused rather than writing the field
``question solution`` writes.

Raises:
NoSuchPart: the id is not a part id, or nothing has written that part's text.
"""
if _PART.fullmatch(part) is None or f"{part}.text" not in draft["fields"]:
raise NoSuchPart(
f"There is no part {part} in the draft: {command} adds to a part in2lambda "
"draft part add has already written, named as q1.p2."
)


def _text_field(draft: dict[str, Any], key: str, command: str) -> dict[str, Any]:
"""The field of that name, which a command writing into a field reads first.

Expand Down Expand Up @@ -691,6 +715,31 @@ def _question_solution(
)


@command("part solution")
def _part_solution(
draft: dict[str, Any],
sources: list[str],
args: dict[str, Any],
by: str,
directory: str,
) -> str:
"""Gives one part of a question its worked solution, wherever it is written.

A sheet that writes a solution under each part is answered part by part, which is
what a spec's PartPartSolSol and PartSolPartSol layouts do in one run.
"""
part = _argument(args, "part", "part solution")
_require_part(draft, part, "part solution")
return _fill(
draft,
sources,
args,
by,
command="part solution",
key=f"{part}.solution",
)


@command("field replace")
def _field_replace(
draft: dict[str, Any],
Expand Down
23 changes: 22 additions & 1 deletion in2lambda/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,7 @@ def draft_question_solution(

@draft_group.group("part")
def draft_part() -> None:
"""Adds a part to a question of the draft."""
"""Adds a part to a question of the draft, or says where its solution is written."""


@draft_part.command("add")
Expand All @@ -424,6 +424,27 @@ def draft_part_add(
)


@draft_part.command("solution")
@click.argument("part")
@_text_or_literal
@_by
@_draft
def draft_part_solution(
part: str,
text: Optional[str],
literal: Optional[str],
by: str,
draft: Optional[str],
) -> None:
"""Gives PART - q1.p2 - the worked solution written at --text or --literal."""
_run(
"part solution",
{"part": part, "text": text, "literal": literal},
by,
draft,
)


@draft_group.group("split")
def draft_split() -> None:
"""Cuts a block of the frozen source that holds two things."""
Expand Down
6 changes: 6 additions & 0 deletions tests/fixtures/drafts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ them with, the first question's part is typed out because the source writes the
beside it, the two solutions are one block that `split block` cuts in two, and one `field replace`
writes a command KaTeX defines over one it does not. The `spec.yaml` beside it is the spec that
page runs before starting the draft again, and no command here runs it.
`part_solutions` is the sheet that writes a solution under each part, which the spec layouts
`PartPartSolSol` and `PartSolPartSol` read: two `part solution` commands quote the two solutions
onto `q1.p1` and `q1.p2`, a `question solution` quotes the one answering the second question, and
the third part, which the sheet answers nowhere, is the one finding in the report. Each field
quotes the block as the sheet writes it, so a solution keeps the `Solution:` the sheet labels it
with, where the spec fixture of the same document strips the label.
`degrees` writes `^\circ` into the maths of both a question and the one worked solution answering
its two parts, and is the one folder whose report comes from `in2lambda.validation` over the set
the draft describes rather than from the checks over the draft itself: the solution is reported
Expand Down
71 changes: 71 additions & 0 deletions tests/fixtures/drafts/part_solutions/commands.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
[
{
"args": {
"block": "b1"
},
"by": "tests",
"command": "mark ignore"
},
{
"args": {
"text": "b2"
},
"by": "tests",
"command": "question add"
},
{
"args": {
"question": "q1",
"text": "b3"
},
"by": "tests",
"command": "part add"
},
{
"args": {
"question": "q1",
"text": "b4"
},
"by": "tests",
"command": "part add"
},
{
"args": {
"question": "q1",
"text": "b5"
},
"by": "tests",
"command": "part add"
},
{
"args": {
"part": "q1.p1",
"text": "b6"
},
"by": "tests",
"command": "part solution"
},
{
"args": {
"part": "q1.p2",
"text": "b7"
},
"by": "tests",
"command": "part solution"
},
{
"args": {
"text": "b8"
},
"by": "tests",
"command": "question add"
},
{
"args": {
"question": "q2",
"text": "b9"
},
"by": "tests",
"command": "question solution"
}
]
110 changes: 110 additions & 0 deletions tests/fixtures/drafts/part_solutions/expected.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
{
"b1.ignore": {
"by": "tests",
"edited": false,
"layer": 3,
"ranges": [
[
1,
1
]
],
"value": true
},
"q1.p1.solution": {
"by": "tests",
"edited": false,
"layer": 3,
"ranges": [
[
11,
11
]
],
"value": "Solution: $W = nRT\\ln(V_1/V_2)$."
},
"q1.p1.text": {
"by": "tests",
"edited": false,
"layer": 3,
"ranges": [
[
5,
5
]
],
"value": "Find the work done on the gas."
},
"q1.p2.solution": {
"by": "tests",
"edited": false,
"layer": 3,
"ranges": [
[
13,
13
]
],
"value": "Solution: $Q = W$, since the internal energy does not change."
},
"q1.p2.text": {
"by": "tests",
"edited": false,
"layer": 3,
"ranges": [
[
7,
7
]
],
"value": "Find the heat rejected."
},
"q1.p3.text": {
"by": "tests",
"edited": false,
"layer": 3,
"ranges": [
[
9,
9
]
],
"value": "Sketch the process on a $p$-$V$ diagram."
},
"q1.text": {
"by": "tests",
"edited": false,
"layer": 3,
"ranges": [
[
3,
3
]
],
"value": "Q1. An ideal gas is compressed isothermally from $V_1$ to $V_2$."
},
"q2.solution": {
"by": "tests",
"edited": false,
"layer": 3,
"ranges": [
[
17,
17
]
],
"value": "Solution: $\\eta = 1 - T_c/T_h = 0.5$."
},
"q2.text": {
"by": "tests",
"edited": false,
"layer": 3,
"ranges": [
[
15,
15
]
],
"value": "Q2. Find the efficiency of a Carnot engine between 300 K and 600 K."
}
}
14 changes: 14 additions & 0 deletions tests/fixtures/drafts/part_solutions/report.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[
{
"check": "no-solution",
"field": "q1.p3",
"level": "warning",
"message": "q1.p3 (lines 9-9) has no solution: neither q1.p3.solution nor q1.solution is written.",
"ranges": [
[
9,
9
]
]
}
]
17 changes: 17 additions & 0 deletions tests/fixtures/drafts/part_solutions/source.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Thermodynamics problem sheet

Q1. An ideal gas is compressed isothermally from $V_1$ to $V_2$.

(a) Find the work done on the gas.

(b) Find the heat rejected.

(c) Sketch the process on a $p$-$V$ diagram.

Solution: $W = nRT\ln(V_1/V_2)$.

Solution: $Q = W$, since the internal energy does not change.

Q2. Find the efficiency of a Carnot engine between 300 K and 600 K.

Solution: $\eta = 1 - T_c/T_h = 0.5$.
8 changes: 8 additions & 0 deletions tests/test_draft.py
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,11 @@ def test_the_refusal_names_the_lines_that_are_in_the_way(
(["draft", "question", "add", "--text", "s8", "--literal", "Words."], "both"),
(["draft", "question", "add"], "neither"),
(["draft", "part", "add", "q9", "--text", "s8"], "q9"),
(["draft", "part", "solution", "q1.p9", "--text", "s8"], "no part q1.p9"),
# A question is not a part of one, and the field it would write is q1.solution,
# which is the other command's.
(["draft", "part", "solution", "q1", "--text", "s8"], "no part q1"),
(["draft", "part", "solution", "q1.p", "--text", "s8"], "no part q1.p"),
(["draft", "split", "block", "b3", "5"], "b3 is lines 5-6"),
(["draft", "split", "block", "b3", "7"], "b3 is lines 5-6"),
# q1.text has a $d$ and a $v$ in it, so a $ names four places and none of them.
Expand All @@ -422,6 +427,9 @@ def test_the_refusal_names_the_lines_that_are_in_the_way(
"a text and a literal",
"no text and no literal",
"a question nothing has written",
"a part nothing has written",
"a question where a part was asked for",
"a part id that is not one",
"a split at the line the block starts on",
"a split past the line it ends on",
"wording the field says more than once",
Expand Down
Loading