Skip to content

Commit dd2dbe2

Browse files
Quote a source range into an existing field (t50)
Quote a source range into an existing field
2 parents 1806275 + 5bf9367 commit dd2dbe2

10 files changed

Lines changed: 205 additions & 21 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
- A draft now holds a `log` of every command that changed it and a `fields` map of what those commands wrote, each field recording which layer 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, and `in2lambda draft replay` rebuilds the draft from the frozen markdown and the log, refusing unless what it builds is the draft that is there, byte for byte. A draft written before this has no `log` in it and is refused as one nothing here wrote; `in2lambda source add --start-over` freezes the document again.
1010
- A draft is filled in by `in2lambda draft question add`, `in2lambda draft part add QUESTION` and `in2lambda draft question solution QUESTION`. 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`, or `--literal TEXT` where the source does not say it in a form the field can take, which records the field as edited and written by layer 4 rather than 3. Question and part numbers are worked out from the fields already written rather than given, 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, or quoting lines another field was taken from, is refused: the first naming the field, the second naming both.
1111
- `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.
12+
- `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. The field is written again at layer 3, with the range of the lines quoted and `edited` false, as `in2lambda draft question add --text` writes one; the range it named before is dropped, and `in2lambda validate` reports those lines as in no field. There is no `--literal`: `in2lambda draft field replace` is the command for text no line of the source says.
1213
- `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.
1314
- A spec's `question`, `part`, `solution` and `ignore` now each take a list of selectors as well as one selector, written as a YAML list under the key, and a block has that role where any one of them matches it. A run that would write a field twice - a document of nothing but solutions has more solutions than the layout has questions to answer - now leaves the second block in no field and reports it, naming the field and the block that holds it, instead of refusing the whole run. In the Python API, `in2lambda.spec.Spec` holds a list of selectors per role rather than one or None, and `in2lambda.spec.fields` returns a third list, of `in2lambda.spec.Doubled`, beside its fields and its ignored blocks.
1415
- A field quoted out of a list item is now dedented as commonmark reads the item: the marker comes off the first line and as much of the same width off every line under it. So a question written `1. ` no longer carries its number, a continuation line no longer arrives indented far enough to be rendered as a code block, and a spec's `strip` is left with what pandoc does not read as a marker. Values written by `in2lambda spec run`, `in2lambda draft question add`, `in2lambda draft part add` and `in2lambda draft question solution` change accordingly; the ranges behind them still name the same source lines.

‎docs/source/drafts.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,15 @@ The layer and the ranges are left as they were, so the field still names the lin
361361
from, and `edited` says the field no longer holds what those lines say. The wording replaced has
362362
to occur in the field exactly once, and `--regex` reads it as a regular expression.
363363

364+
Where the source does say the wording and the field was quoted from the wrong lines - a spec
365+
matching the label line `Q4` alone writes an empty `q4.text`, which `in2lambda validate` reports -
366+
`in2lambda draft field set q4.text --text b3` quotes block `b3` into the field instead. The field
367+
is written again at layer 3, with the range of the lines quoted and `edited` false, as
368+
`in2lambda draft question add --text` writes one. The lines the field named before are dropped,
369+
and `in2lambda validate` reports them as in no field until `in2lambda draft mark ignore` says they
370+
are nothing to take a question from. `in2lambda draft field set` takes no `--literal`:
371+
`in2lambda draft field replace` is the command for text no line of the source says.
372+
364373
## Replay and check again
365374

366375
```bash

‎in2lambda/draft/__init__.py‎

Lines changed: 82 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -155,15 +155,16 @@ def record(
155155
156156
Raises:
157157
AlreadyFilled: the field is written already, or the lines it was to be copied
158-
from are where another field of the same source came from. Nothing here
159-
writes a field twice - `field replace` changes the wording of one rather
160-
than writing it again - so either is a mistake, and worth naming both
161-
halves of.
158+
from are where another field of the same source came from. A command that
159+
fills a field writes it once - `field replace` changes the wording of one
160+
and `field set` quotes other lines into one - so either is a mistake, and
161+
worth naming both halves of.
162162
"""
163163
if key in draft["fields"]:
164164
raise AlreadyFilled(
165-
f"{key} is already written, and no command here writes a field twice. Run "
166-
"in2lambda draft field replace to change the wording it holds, or "
165+
f"{key} is already written, and no command fills a field twice. Run "
166+
"in2lambda draft field replace to change the wording it holds, "
167+
"in2lambda draft field set to quote other lines into it, or "
167168
"in2lambda source add --start-over to begin the draft again."
168169
)
169170
for filled, field in draft["fields"].items():
@@ -496,9 +497,27 @@ def _fill(
496497
by=by,
497498
edited=True,
498499
)
499-
source, start, end = _lines(
500-
draft, sources, _argument(args, "text", command), command
500+
return _quote(
501+
draft, sources, _argument(args, "text", command), by, command=command, key=key
501502
)
503+
504+
505+
def _quote(
506+
draft: dict[str, Any],
507+
sources: list[str],
508+
where: str,
509+
by: str,
510+
*,
511+
command: str,
512+
key: str,
513+
) -> str:
514+
"""Writes a field from the lines of a frozen source that `where` names.
515+
516+
Raises:
517+
NoSuchBlock, NoSuchLines: `where` is not somewhere in a source.
518+
AlreadyFilled: the field, or the lines it names, are taken.
519+
"""
520+
source, start, end = _lines(draft, sources, where, command)
502521
return record(
503522
draft,
504523
key,
@@ -561,6 +580,22 @@ def _require_question(draft: dict[str, Any], question: str, command: str) -> Non
561580
)
562581

563582

583+
def _text_field(draft: dict[str, Any], key: str, command: str) -> dict[str, Any]:
584+
"""The field of that name, which a command writing into one has to find.
585+
586+
Raises:
587+
NoSuchField: the draft has no field of that name, or the one it has holds
588+
something other than text - `b3.ignore` holds true.
589+
"""
590+
field = draft["fields"].get(key)
591+
if field is None or not isinstance(field.get("value"), str):
592+
raise NoSuchField(
593+
f"There is no field {key} holding text in the draft: {command} writes into "
594+
"a field one of the commands before it has written."
595+
)
596+
return field
597+
598+
564599
@command("mark ignore")
565600
def _mark_ignore(
566601
draft: dict[str, Any],
@@ -679,12 +714,7 @@ def _field_replace(
679714
# argument for it, as every other option of a command does.
680715
regex = "regex" in args and _argument(args, "regex", "field replace", bool)
681716

682-
field = draft["fields"].get(key)
683-
if field is None or not isinstance(field.get("value"), str):
684-
raise NoSuchField(
685-
f"There is no field {key} holding text in the draft: field replace changes "
686-
"the wording of a field one of the commands before it has written."
687-
)
717+
field = _text_field(draft, key, "field replace")
688718
value = field["value"]
689719
try:
690720
found = len(re.findall(old, value)) if regex else value.count(old)
@@ -714,6 +744,44 @@ def _field_replace(
714744
return key
715745

716746

747+
@command("field set")
748+
def _field_set(
749+
draft: dict[str, Any],
750+
sources: list[str],
751+
args: dict[str, Any],
752+
by: str,
753+
directory: str,
754+
) -> str:
755+
"""Quotes lines of a frozen source into a field that is written already.
756+
757+
A spec matching the label line `Q4` alone writes an empty `q4.text`, which
758+
`in2lambda validate` reports; the wording the field should hold is somewhere else in
759+
the source. So the field is written again from the lines that do say it, at layer 3
760+
with their ranges and `edited` false, the way `question add --text` writes one.
761+
`field replace` is the command for text nothing in the source says.
762+
763+
The ranges the field named before are dropped, because its value is no longer
764+
copied from those lines. `in2lambda validate` then reports them as in no field, and
765+
`in2lambda draft mark ignore` says a block is nothing to take a question from.
766+
767+
Raises:
768+
MalformedCommand: the command has no ``field`` or no ``text``.
769+
NoSuchField: the draft has no field of that name holding text.
770+
NoSuchBlock, NoSuchLines: its ``text`` is not somewhere in a source.
771+
AlreadyFilled: the lines it names are where another field of the same source
772+
came from.
773+
"""
774+
key = _argument(args, "field", "field set")
775+
where = _argument(args, "text", "field set")
776+
_text_field(draft, key, "field set")
777+
# Out of the fields before it is written again, so that `record` checks the lines
778+
# against the other fields of the source rather than refusing the key it is to
779+
# write. Nothing reaches the file until the command has run, since `execute` saves
780+
# the draft only once `apply` has returned.
781+
del draft["fields"][key]
782+
return _quote(draft, sources, where, by, command="field set", key=key)
783+
784+
717785
@command("split block")
718786
def _split_block(
719787
draft: dict[str, Any],

‎in2lambda/main.py‎

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,14 @@ def draft_group() -> None:
315315
"""Who ran a draft command, which every one of them records."""
316316

317317

318+
_WHERE = (
319+
"Where in a frozen source the text is: a block id such as b3, or lines such as "
320+
"s10:14, with the source's number in front - 2/b3, 2/s10:14 - for any but the "
321+
"first. Run in2lambda source show to see both."
322+
)
323+
"""What --text names, which every command taking one names the same way."""
324+
325+
318326
def _text_or_literal(command: Callable[..., None]) -> Callable[..., None]:
319327
"""The two ways to fill a field: quoted from the frozen source, or typed out."""
320328
for option in (
@@ -323,12 +331,7 @@ def _text_or_literal(command: Callable[..., None]) -> Callable[..., None]:
323331
help="The text itself, where the source does not say it in a form the "
324332
"field can take. Marks the field as edited.",
325333
),
326-
click.option(
327-
"--text",
328-
help="Where in a frozen source the text is: a block id such as b3, or "
329-
"lines such as s10:14, with the source's number in front - 2/b3, 2/s10:14 "
330-
"- for any but the first. Run in2lambda source show to see both.",
331-
),
334+
click.option("--text", help=_WHERE),
332335
):
333336
command = option(command)
334337
return command
@@ -445,7 +448,7 @@ def draft_split_block(block: str, at: int, by: str, draft: Optional[str]) -> Non
445448

446449
@draft_group.group("field")
447450
def draft_field() -> None:
448-
"""Changes the wording of a field the draft has written already."""
451+
"""Writes a field the draft has written already, from the source or by hand."""
449452

450453

451454
@draft_field.command("replace")
@@ -471,6 +474,16 @@ def draft_field_replace(
471474
)
472475

473476

477+
@draft_field.command("set")
478+
@click.argument("field")
479+
@click.option("--text", required=True, help=_WHERE)
480+
@_by
481+
@_draft
482+
def draft_field_set(field: str, text: str, by: str, draft: Optional[str]) -> None:
483+
"""Writes FIELD again, quoting the lines --text names into it."""
484+
_run("field set", {"field": field, "text": text}, by, draft)
485+
486+
474487
@draft_group.command("replay")
475488
@_draft
476489
def draft_replay(draft: Optional[str]) -> None:

‎tests/fixtures/drafts/README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ is why the block it was typed from is marked ignore rather than left unaccounted
2424
brace back and another, with `--regex`, writes a `\tfrac` over the division in the solution, so
2525
both fields end up edited while their ranges still name the lines they were quoted from, and the
2626
backslash in what the second one writes is written rather than read as a replacement template.
27+
`field_set` is the repair for the field `empty_field` leaves empty: `question add --text s2` quotes
28+
a blank line into `q1.text`, which the checks report as empty, and `field set q1.text --text b2`
29+
writes the paragraph into the field instead, at layer 3 with the block's range and `edited` false.
2730
`part_without_solution` and `empty_field` are the smallest drafts the other two checks have
2831
anything to say about; an overlap and a gap in the numbering are not here, because no run of
2932
commands can make one. `figure_in_a_question` is a question whose text runs on into an image, so
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
[
2+
{
3+
"args": {
4+
"block": "b1"
5+
},
6+
"by": "tests",
7+
"command": "mark ignore"
8+
},
9+
{
10+
"args": {
11+
"text": "s2"
12+
},
13+
"by": "tests",
14+
"command": "question add"
15+
},
16+
{
17+
"args": {
18+
"field": "q1.text",
19+
"text": "b2"
20+
},
21+
"by": "tests",
22+
"command": "field set"
23+
}
24+
]
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
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.text": {
15+
"by": "tests",
16+
"edited": false,
17+
"layer": 3,
18+
"ranges": [
19+
[
20+
3,
21+
3
22+
]
23+
],
24+
"value": "Find the pressure at the bottom of the tank."
25+
}
26+
}
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",
5+
"level": "warning",
6+
"message": "q1 (lines 3-3) has no solution: q1.solution is not written, and it has no parts.",
7+
"ranges": [
8+
[
9+
3,
10+
3
11+
]
12+
]
13+
}
14+
]
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Pressure in a tank
2+
3+
Find the pressure at the bottom of the tank.

‎tests/test_draft.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,10 @@ def test_the_refusal_names_the_lines_that_are_in_the_way(
404404
(["draft", "field", "replace", "q1.text", "steam", "water"], "occurs 0 times"),
405405
(["draft", "field", "replace", "q9.text", "a", "b"], "q9.text"),
406406
(["draft", "field", "replace", "b1.ignore", "a", "b"], "b1.ignore"),
407+
(["draft", "field", "set", "q9.text", "--text", "s3"], "q9.text"),
408+
(["draft", "field", "set", "b1.ignore", "--text", "s3"], "b1.ignore"),
409+
# Line 16 is where q1's solution came from, so it is not also q2's text.
410+
(["draft", "field", "set", "q2.text", "--text", "s16"], "q1.solution"),
407411
(
408412
["draft", "field", "replace", "q1.text", "(", "X", "--regex"],
409413
"not a regular expression",
@@ -424,6 +428,9 @@ def test_the_refusal_names_the_lines_that_are_in_the_way(
424428
"wording the field does not say",
425429
"a field nothing has written",
426430
"a field that is not text",
431+
"a field set naming a field nothing has written",
432+
"a field set naming a field that is not text",
433+
"a field set quoting lines another field was taken from",
427434
"a regex that is not one",
428435
],
429436
)
@@ -483,6 +490,22 @@ def test_a_command_says_what_it_wrote(tmp_path: Path, monkeypatch) -> None:
483490
# --regex is an option, so a command nobody passed it to logs no argument for it.
484491
assert "regex" not in draft["log"][-1]["args"]
485492

493+
# And a field set names the field it quoted into, which holds the lines it names and
494+
# nothing of the range it named before: q1.text was lines 5-6 and is now line 5.
495+
result = runner.invoke(
496+
cli, ["draft", "field", "set", "q1.text", "--text", "s5", "--by", "ocr"]
497+
)
498+
499+
assert result.exit_code == 0, result.output
500+
assert result.output == "Wrote q1.text.\n"
501+
assert json.loads(draft_path.read_text())["fields"]["q1.text"] == {
502+
"value": "Water flows through a horizontal pipe of diameter $d$ at speed $v$.",
503+
"layer": 3,
504+
"ranges": [[5, 5]],
505+
"edited": False,
506+
"by": "ocr",
507+
}
508+
486509

487510
def test_a_field_is_quoted_from_a_later_source_by_its_number(
488511
tmp_path: Path, monkeypatch

0 commit comments

Comments
 (0)