Skip to content

Commit dec2a86

Browse files
committed
Merge commit 'a76603dc3d21847c687caf558255941aa966a5fa' into wb/t51
2 parents 64e69bb + a76603d commit dec2a86

10 files changed

Lines changed: 304 additions & 1 deletion

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
- `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.
1010
- 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.
1111
- `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.
12+
- `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.
1213
- `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.
1314
- `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.
1415
- `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.

‎docs/source/drafts.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,12 @@ $ in2lambda draft question solution q2 --text b8b
337337
Wrote q2.solution.
338338
```
339339

340+
This sheet writes one solution per question. A sheet that writes a solution under each part is
341+
quoted part by part instead, by `in2lambda draft part solution PART --text RANGE`, where PART is
342+
written `q1.p2`. It writes `q1.p2.solution`, takes `--text` and `--literal` as
343+
`in2lambda draft question solution` does, and refuses a part that `in2lambda draft part add` has
344+
not written.
345+
340346
## Change what a field says
341347

342348
The sheet writes the second solution with `\half`, which KaTeX does not define, and

‎in2lambda/draft/__init__.py‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@
5151
_RANGE = re.compile(r"s(\d+)(?::(\d+))?")
5252
"""Lines of a frozen source, as ``s16`` for one of them or ``s10:14`` for several."""
5353

54+
_PART = re.compile(r"q\d+\.p\d+")
55+
"""A part of a question, as the draft names one: ``q1.p2``."""
56+
5457
_QUALIFIED = re.compile(r"(\d+)/([^/]*)")
5558
"""A block id or a line range with its source in front: ``2/b3``, ``2/s10:14``.
5659
@@ -80,6 +83,10 @@ class NoSuchQuestion(SourceError):
8083
"""A command adds to a question no command has written."""
8184

8285

86+
class NoSuchPart(SourceError):
87+
"""A command adds to a part nothing has written yet."""
88+
89+
8390
class AlreadyFilled(SourceError):
8491
"""A command would write a written field, or lines another field was taken from."""
8592

@@ -602,6 +609,23 @@ def _require_question(draft: dict[str, Any], question: str, command: str) -> Non
602609
)
603610

604611

612+
def _require_part(draft: dict[str, Any], part: str, command: str) -> None:
613+
"""Checks the draft has the part a command adds to.
614+
615+
The id is checked for its shape as well as for being written, so that a question id
616+
given where a part was asked for is refused rather than writing the field
617+
``question solution`` writes.
618+
619+
Raises:
620+
NoSuchPart: the id is not a part id, or nothing has written that part's text.
621+
"""
622+
if _PART.fullmatch(part) is None or f"{part}.text" not in draft["fields"]:
623+
raise NoSuchPart(
624+
f"There is no part {part} in the draft: {command} adds to a part in2lambda "
625+
"draft part add has already written, named as q1.p2."
626+
)
627+
628+
605629
def _text_field(draft: dict[str, Any], key: str, command: str) -> dict[str, Any]:
606630
"""The field of that name, which a command writing into a field reads first.
607631
@@ -706,6 +730,31 @@ def _question_solution(
706730
)
707731

708732

733+
@command("part solution")
734+
def _part_solution(
735+
draft: dict[str, Any],
736+
sources: list[str],
737+
args: dict[str, Any],
738+
by: str,
739+
directory: str,
740+
) -> str:
741+
"""Gives one part of a question its worked solution, wherever it is written.
742+
743+
A sheet that writes a solution under each part is answered part by part, which is
744+
what a spec's PartPartSolSol and PartSolPartSol layouts do in one run.
745+
"""
746+
part = _argument(args, "part", "part solution")
747+
_require_part(draft, part, "part solution")
748+
return _fill(
749+
draft,
750+
sources,
751+
args,
752+
by,
753+
command="part solution",
754+
key=f"{part}.solution",
755+
)
756+
757+
709758
@command("field replace")
710759
def _field_replace(
711760
draft: dict[str, Any],

‎in2lambda/main.py‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -403,7 +403,7 @@ def draft_question_solution(
403403

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

408408

409409
@draft_part.command("add")
@@ -424,6 +424,27 @@ def draft_part_add(
424424
)
425425

426426

427+
@draft_part.command("solution")
428+
@click.argument("part")
429+
@_text_or_literal
430+
@_by
431+
@_draft
432+
def draft_part_solution(
433+
part: str,
434+
text: Optional[str],
435+
literal: Optional[str],
436+
by: str,
437+
draft: Optional[str],
438+
) -> None:
439+
"""Gives PART - q1.p2 - the worked solution written at --text or --literal."""
440+
_run(
441+
"part solution",
442+
{"part": part, "text": text, "literal": literal},
443+
by,
444+
draft,
445+
)
446+
447+
427448
@draft_group.group("split")
428449
def draft_split() -> None:
429450
"""Cuts a block of the frozen source that holds two things."""

‎tests/fixtures/drafts/README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ them with, the first question's part is typed out because the source writes the
6363
beside it, the two solutions are one block that `split block` cuts in two, and one `field replace`
6464
writes a command KaTeX defines over one it does not. The `spec.yaml` beside it is the spec that
6565
page runs before starting the draft again, and no command here runs it.
66+
`part_solutions` is the sheet that writes a solution under each part, which the spec layouts
67+
`PartPartSolSol` and `PartSolPartSol` read: two `part solution` commands quote the two solutions
68+
onto `q1.p1` and `q1.p2`, a `question solution` quotes the one answering the second question, and
69+
the third part, which the sheet answers nowhere, is the one finding in the report. Each field
70+
quotes the block as the sheet writes it, so a solution keeps the `Solution:` the sheet labels it
71+
with, where the spec fixture of the same document strips the label.
6672
`degrees` writes `^\circ` into the maths of both a question and the one worked solution answering
6773
its two parts, and is the one folder whose report comes from `in2lambda.validation` over the set
6874
the draft describes rather than from the checks over the draft itself: the solution is reported
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
[
2+
{
3+
"args": {
4+
"block": "b1"
5+
},
6+
"by": "tests",
7+
"command": "mark ignore"
8+
},
9+
{
10+
"args": {
11+
"text": "b2"
12+
},
13+
"by": "tests",
14+
"command": "question add"
15+
},
16+
{
17+
"args": {
18+
"question": "q1",
19+
"text": "b3"
20+
},
21+
"by": "tests",
22+
"command": "part add"
23+
},
24+
{
25+
"args": {
26+
"question": "q1",
27+
"text": "b4"
28+
},
29+
"by": "tests",
30+
"command": "part add"
31+
},
32+
{
33+
"args": {
34+
"question": "q1",
35+
"text": "b5"
36+
},
37+
"by": "tests",
38+
"command": "part add"
39+
},
40+
{
41+
"args": {
42+
"part": "q1.p1",
43+
"text": "b6"
44+
},
45+
"by": "tests",
46+
"command": "part solution"
47+
},
48+
{
49+
"args": {
50+
"part": "q1.p2",
51+
"text": "b7"
52+
},
53+
"by": "tests",
54+
"command": "part solution"
55+
},
56+
{
57+
"args": {
58+
"text": "b8"
59+
},
60+
"by": "tests",
61+
"command": "question add"
62+
},
63+
{
64+
"args": {
65+
"question": "q2",
66+
"text": "b9"
67+
},
68+
"by": "tests",
69+
"command": "question solution"
70+
}
71+
]
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
{
2+
"b1.ignore": {
3+
"by": "tests",
4+
"edited": false,
5+
"layer": 3,
6+
"ranges": [
7+
[
8+
1,
9+
1
10+
]
11+
],
12+
"value": true
13+
},
14+
"q1.p1.solution": {
15+
"by": "tests",
16+
"edited": false,
17+
"layer": 3,
18+
"ranges": [
19+
[
20+
11,
21+
11
22+
]
23+
],
24+
"value": "Solution: $W = nRT\\ln(V_1/V_2)$."
25+
},
26+
"q1.p1.text": {
27+
"by": "tests",
28+
"edited": false,
29+
"layer": 3,
30+
"ranges": [
31+
[
32+
5,
33+
5
34+
]
35+
],
36+
"value": "Find the work done on the gas."
37+
},
38+
"q1.p2.solution": {
39+
"by": "tests",
40+
"edited": false,
41+
"layer": 3,
42+
"ranges": [
43+
[
44+
13,
45+
13
46+
]
47+
],
48+
"value": "Solution: $Q = W$, since the internal energy does not change."
49+
},
50+
"q1.p2.text": {
51+
"by": "tests",
52+
"edited": false,
53+
"layer": 3,
54+
"ranges": [
55+
[
56+
7,
57+
7
58+
]
59+
],
60+
"value": "Find the heat rejected."
61+
},
62+
"q1.p3.text": {
63+
"by": "tests",
64+
"edited": false,
65+
"layer": 3,
66+
"ranges": [
67+
[
68+
9,
69+
9
70+
]
71+
],
72+
"value": "Sketch the process on a $p$-$V$ diagram."
73+
},
74+
"q1.text": {
75+
"by": "tests",
76+
"edited": false,
77+
"layer": 3,
78+
"ranges": [
79+
[
80+
3,
81+
3
82+
]
83+
],
84+
"value": "Q1. An ideal gas is compressed isothermally from $V_1$ to $V_2$."
85+
},
86+
"q2.solution": {
87+
"by": "tests",
88+
"edited": false,
89+
"layer": 3,
90+
"ranges": [
91+
[
92+
17,
93+
17
94+
]
95+
],
96+
"value": "Solution: $\\eta = 1 - T_c/T_h = 0.5$."
97+
},
98+
"q2.text": {
99+
"by": "tests",
100+
"edited": false,
101+
"layer": 3,
102+
"ranges": [
103+
[
104+
15,
105+
15
106+
]
107+
],
108+
"value": "Q2. Find the efficiency of a Carnot engine between 300 K and 600 K."
109+
}
110+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
[
2+
{
3+
"check": "no-solution",
4+
"field": "q1.p3",
5+
"level": "warning",
6+
"message": "q1.p3 (lines 9-9) has no solution: neither q1.p3.solution nor q1.solution is written.",
7+
"ranges": [
8+
[
9+
9,
10+
9
11+
]
12+
]
13+
}
14+
]
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Thermodynamics problem sheet
2+
3+
Q1. An ideal gas is compressed isothermally from $V_1$ to $V_2$.
4+
5+
(a) Find the work done on the gas.
6+
7+
(b) Find the heat rejected.
8+
9+
(c) Sketch the process on a $p$-$V$ diagram.
10+
11+
Solution: $W = nRT\ln(V_1/V_2)$.
12+
13+
Solution: $Q = W$, since the internal energy does not change.
14+
15+
Q2. Find the efficiency of a Carnot engine between 300 K and 600 K.
16+
17+
Solution: $\eta = 1 - T_c/T_h = 0.5$.

‎tests/test_draft.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,11 @@ def test_the_refusal_names_the_lines_that_are_in_the_way(
397397
(["draft", "question", "add", "--text", "s8", "--literal", "Words."], "both"),
398398
(["draft", "question", "add"], "neither"),
399399
(["draft", "part", "add", "q9", "--text", "s8"], "q9"),
400+
(["draft", "part", "solution", "q1.p9", "--text", "s8"], "no part q1.p9"),
401+
# A question is not a part of one, and the field it would write is q1.solution,
402+
# which is the other command's.
403+
(["draft", "part", "solution", "q1", "--text", "s8"], "no part q1"),
404+
(["draft", "part", "solution", "q1.p", "--text", "s8"], "no part q1.p"),
400405
(["draft", "split", "block", "b3", "5"], "b3 is lines 5-6"),
401406
(["draft", "split", "block", "b3", "7"], "b3 is lines 5-6"),
402407
# q1.text has a $d$ and a $v$ in it, so a $ names four places and none of them.
@@ -422,6 +427,9 @@ def test_the_refusal_names_the_lines_that_are_in_the_way(
422427
"a text and a literal",
423428
"no text and no literal",
424429
"a question nothing has written",
430+
"a part nothing has written",
431+
"a question where a part was asked for",
432+
"a part id that is not one",
425433
"a split at the line the block starts on",
426434
"a split past the line it ends on",
427435
"wording the field says more than once",

0 commit comments

Comments
 (0)