Skip to content

Commit d63030d

Browse files
Log every draft command and replay it (t23)
Log every draft command and replay it
2 parents 51defbf + e9fc4bf commit d63030d

12 files changed

Lines changed: 686 additions & 42 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@
66
- `in2lambda FILE FILTER` exits with an error naming the command to run instead, rather than printing its usage and exiting successfully.
77
- beartype is now `^0.22`. At 0.20.0 and below its import hook leaves `cli` a plain function rather than a group, so the new command line either fails to import or runs `convert` whatever the arguments; 0.20.1 is the first version that works.
88
- `in2lambda source add FILE` freezes a document: it converts .docx and .tex to markdown beside the file, and writes a `draft.json` holding the markdown's hash and every block in it with the lines it spans, so that another tool can quote the source by line range. `in2lambda source show` prints that markdown numbered with the block ids. Freezing a file that has changed since is refused unless `--start-over` says to discard the draft, and so is showing one, since its block ids would name lines they are not the ids of. Both need pandoc and the `convert` extra, as `convert` does.
9+
- 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.json` that is there, byte for byte. A `draft.json` 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.
910
- The Python API is unchanged: `in2lambda.main.runner` and everything under `in2lambda.api` take the same arguments and return the same objects.

‎in2lambda/draft/__init__.py‎

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
"""Builds up a draft by commands, and rebuilds it from the ones it recorded.
2+
3+
A draft is written by a sequence of commands, some of them chosen by a model. Every
4+
command that changes one is recorded in the draft's ``log`` as it is applied, and every
5+
field a command writes carries where it came from, so that :func:`replay` can build the
6+
same draft again out of the frozen markdown and the log alone, with no model in the loop.
7+
That is what makes a run reproducible, and a saved run a test.
8+
9+
Commands reach a draft only through :func:`apply`, which is what keeps the log complete:
10+
a handler registered with :func:`command` is never called by anything else.
11+
"""
12+
13+
from collections.abc import Callable # Rather than typing's, which beartype warns on.
14+
from pathlib import Path
15+
from typing import Any
16+
17+
from in2lambda.source import (
18+
DRAFT,
19+
SourceError,
20+
_require_conversion_tools,
21+
blocks,
22+
frozen,
23+
save,
24+
serialise,
25+
)
26+
27+
Command = dict[str, Any]
28+
"""One entry of the log: ``{"command": name, "args": {...}, "by": who}``."""
29+
30+
Handler = Callable[[dict[str, Any], str, dict[str, Any], str], None]
31+
"""What a command does: `handler(draft, markdown, args, by)`, changing the draft.
32+
33+
The frozen markdown is passed in rather than read, so that a handler quoting the source
34+
by line range quotes the same text on a replay as it did when it first ran.
35+
"""
36+
37+
_HANDLERS: dict[str, Handler] = {}
38+
"""Every command there is, by the name a log entry names it with."""
39+
40+
41+
class MalformedCommand(SourceError):
42+
"""A log holds something that is not a command, so nothing can be made of it."""
43+
44+
45+
class UnknownCommand(SourceError):
46+
"""A log names a command that nothing registered, so the draft cannot be rebuilt."""
47+
48+
49+
class NoSuchBlock(SourceError):
50+
"""A command names a block the frozen source has not got."""
51+
52+
53+
class ReplayDiffers(SourceError):
54+
"""Replaying a draft's log does not reproduce the draft."""
55+
56+
57+
def command(name: str) -> Callable[[Handler], Handler]:
58+
"""Registers a handler as the command of that name.
59+
60+
Args:
61+
name: What a log entry calls it, as it is typed: ``"mark ignore"``.
62+
63+
Returns:
64+
The decorator, which returns the handler unchanged.
65+
"""
66+
67+
def register(handler: Handler) -> Handler:
68+
_HANDLERS[name] = handler
69+
return handler
70+
71+
return register
72+
73+
74+
def record(
75+
draft: dict[str, Any],
76+
key: str,
77+
value: Any,
78+
*,
79+
layer: int,
80+
ranges: list[list[int]],
81+
by: str,
82+
) -> None:
83+
"""Writes one field of a draft, with where it came from.
84+
85+
Args:
86+
draft: The draft to write into.
87+
key: What the field is called, unique within the draft.
88+
value: What it is.
89+
layer: What wrote it: 1 a spec, 2 a predicate, 3 a range taken from the source,
90+
4 a literal someone typed. A reader deciding whether to trust a field wants
91+
to know which of those it was.
92+
ranges: The line ranges of the frozen source the value was copied from, as
93+
``[[start, end], ...]``, and empty where it was not copied from any.
94+
by: Who ran the command, as a name or a model.
95+
"""
96+
draft["fields"][key] = {
97+
"value": value,
98+
"layer": layer,
99+
"ranges": ranges,
100+
# A field is edited when something replaces the value a command wrote, which is
101+
# not something a command can do to its own field on the way in.
102+
"edited": False,
103+
"by": by,
104+
}
105+
106+
107+
def _fault(entry: Any) -> str:
108+
"""What is wrong with the shape of a log entry, or "" if nothing is."""
109+
if not isinstance(entry, dict):
110+
return "is not an object"
111+
if missing := sorted({"command", "args", "by"} - entry.keys()):
112+
return f"has no {' or '.join(missing)}"
113+
if not isinstance(entry["command"], str):
114+
return f"gives {entry['command']!r} as its command, which is not a name"
115+
if not isinstance(entry["args"], dict):
116+
return f"gives {entry['args']!r} as its args, which is not an object"
117+
return ""
118+
119+
120+
def _argument(args: dict[str, Any], name: str, command: str) -> Any:
121+
"""One argument of a command, given that the log entry gave it.
122+
123+
Handlers take their arguments through this rather than indexing, so that a log
124+
entry missing one says which one rather than raising a KeyError at whoever ran it.
125+
126+
Raises:
127+
MalformedCommand: the entry has no argument of that name.
128+
"""
129+
if name not in args:
130+
raise MalformedCommand(
131+
f"{args!r} in the log is not a command {command} can run: it has no "
132+
f'"{name}" argument.'
133+
)
134+
return args[name]
135+
136+
137+
def apply(draft: dict[str, Any], markdown: str, entry: Any) -> None:
138+
"""Runs one command against a draft and records it in the draft's log.
139+
140+
Args:
141+
draft: The draft to change, in place.
142+
markdown: The frozen markdown the draft was written from.
143+
entry: The command, as it is written in the log. Anything at all, rather than a
144+
`Command`, because a log is read from a file anyone can edit: what shape it
145+
has is something to tell the reader about, not something to assume.
146+
147+
Raises:
148+
MalformedCommand: the entry is not a command.
149+
UnknownCommand: nothing is registered under that name.
150+
"""
151+
if fault := _fault(entry):
152+
raise MalformedCommand(
153+
f"{entry!r} in the log is not a command: it {fault}. A command is an "
154+
'object with a "command" naming it, its "args", and who it was run "by".'
155+
)
156+
if (handler := _HANDLERS.get(entry["command"])) is None:
157+
raise UnknownCommand(
158+
f"{entry['command']} is not a command this version of in2lambda has, so "
159+
"the draft cannot be built from its log. It was written by a newer one."
160+
)
161+
handler(draft, markdown, entry["args"], entry["by"])
162+
# After the handler, so a command that was refused is not recorded as having run.
163+
draft["log"].append(entry)
164+
165+
166+
def execute(entry: Command, directory: str = ".") -> None:
167+
"""Runs one command against the draft in a directory and writes it back.
168+
169+
Args:
170+
entry: The command, as it is written in the log.
171+
directory: Where the ``draft.json`` to change is.
172+
173+
Raises:
174+
SourceError: the draft is missing, is not one of ours, or was written from
175+
markdown that has changed since; or the command is unknown or refused.
176+
"""
177+
draft, markdown = frozen(directory)
178+
apply(draft, markdown, entry)
179+
save(Path(directory) / DRAFT, draft)
180+
181+
182+
def replay(directory: str = ".") -> None:
183+
"""Rebuilds the draft in a directory from its source and its log, and checks it.
184+
185+
Nothing is written: the point is to find out whether what is on disk is what its
186+
commands say it should be, and a replay that wrote the answer could not tell anyone
187+
it was different.
188+
189+
Args:
190+
directory: Where the ``draft.json`` to replay is.
191+
192+
Raises:
193+
DraftExists: the markdown has changed since the draft was written from it, so
194+
the commands would be replayed against lines they were not run against.
195+
MalformedCommand: the log holds something that is not a command.
196+
UnknownCommand: the log names a command nothing here registered.
197+
ReplayDiffers: the rebuilt draft is not the one on disk, byte for byte.
198+
"""
199+
_require_conversion_tools()
200+
draft, markdown = frozen(directory)
201+
# From the markdown rather than from the draft: the blocks are as much a product of
202+
# the source as the fields are, and copying them across would not check them.
203+
rebuilt: dict[str, Any] = {
204+
"source": draft["source"],
205+
"hash": draft["hash"],
206+
"blocks": [block.to_dict() for block in blocks(markdown)],
207+
"log": [],
208+
"fields": {},
209+
}
210+
for entry in draft["log"]:
211+
apply(rebuilt, markdown, entry)
212+
213+
path = Path(directory) / DRAFT
214+
if serialise(rebuilt) != path.read_bytes():
215+
raise ReplayDiffers(
216+
f"Replaying the log in {DRAFT} does not reproduce it, so what is in it did "
217+
"not all come from the commands it records - something has changed it since "
218+
"they ran. Run in2lambda source add --start-over to begin again."
219+
)
220+
221+
222+
@command("mark ignore")
223+
def _mark_ignore(
224+
draft: dict[str, Any], markdown: str, args: dict[str, Any], by: str
225+
) -> None:
226+
"""Marks one block of the frozen source as nothing to take a question from."""
227+
block = _argument(args, "block", "mark ignore")
228+
if (found := next((b for b in draft["blocks"] if b["id"] == block), None)) is None:
229+
raise NoSuchBlock(
230+
f"There is no block {block} in {DRAFT}. Run in2lambda source show to see "
231+
"the ids of the blocks there are."
232+
)
233+
record(
234+
draft,
235+
f"{block}.ignore",
236+
True,
237+
layer=3,
238+
ranges=[[found["start"], found["end"]]],
239+
by=by,
240+
)

‎in2lambda/main.py‎

Lines changed: 57 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,23 @@
66
# import os
77
# sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
88

9+
import getpass
910
import importlib
1011
import shlex
12+
from collections.abc import Iterator # Rather than typing's, which beartype warns on.
13+
from contextlib import contextmanager
1114
from typing import Optional
1215

1316
import rich_click as click
1417

18+
import in2lambda.draft
1519
import in2lambda.filters
1620
import in2lambda.source
1721
from in2lambda.api.set import Set
1822

19-
# Both were defined here before there was an in2lambda.source, and are in other
20-
# people's scripts as in2lambda.main names.
23+
# All four are in other people's scripts as in2lambda.main names, whether or not they
24+
# are used here: `_pandoc` and `file_type` were defined here before there was an
25+
# in2lambda.source, and `ConversionToolsMissing` is what `runner` documents raising.
2126
from in2lambda.source import (
2227
ConversionToolsMissing,
2328
SourceError,
@@ -27,6 +32,20 @@
2732
)
2833

2934

35+
@contextmanager
36+
def _message_not_traceback() -> Iterator[None]:
37+
"""Turns anything raised for a reader into what to do about it and a non-zero exit.
38+
39+
Every command wraps whatever it calls in this: a missing pandoc, a draft from
40+
somewhere else, a source that has moved on are all things the person running it can
41+
act on, and none of them are worth a traceback.
42+
"""
43+
try:
44+
yield
45+
except SourceError as error:
46+
raise click.ClickException(str(error)) from None
47+
48+
3049
def docx_to_md(docx_file: str) -> str:
3150
"""Converts .docx files to markdown.
3251
@@ -188,11 +207,8 @@ def convert(
188207
) -> None:
189208
"""Takes in a QUESTION_FILE for a given SUBJECT and produces Lambda Feedback compatible json/zip files."""
190209
# main() is made separate from click() so that it can be easily imported as part of a library.
191-
try:
210+
with _message_not_traceback():
192211
runner(question_file, chosen_filter, output_dir, answer_file)
193-
except ConversionToolsMissing as error:
194-
# Exit with the install instructions rather than a traceback.
195-
raise click.ClickException(str(error)) from None
196212

197213

198214
@cli.group("source")
@@ -209,21 +225,49 @@ def source_group() -> None:
209225
)
210226
def source_add(file: str, start_over: bool) -> None:
211227
"""Converts FILE to markdown and records its blocks in draft.json beside it."""
212-
try:
228+
with _message_not_traceback():
213229
draft = in2lambda.source.add(file, start_over)
214-
except SourceError as error:
215-
# Exit with what to do about it rather than a traceback.
216-
raise click.ClickException(str(error)) from None
217230
click.echo(f"Wrote {draft}")
218231

219232

220233
@source_group.command("show")
221234
def source_show() -> None:
222235
"""Prints the frozen markdown of the draft in this directory, numbered."""
223-
try:
236+
with _message_not_traceback():
224237
click.echo(in2lambda.source.show())
225-
except SourceError as error:
226-
raise click.ClickException(str(error)) from None
238+
239+
240+
@cli.group("draft")
241+
def draft_group() -> None:
242+
"""Builds up the draft in this directory, recording every command in it."""
243+
244+
245+
@draft_group.group("mark")
246+
def draft_mark() -> None:
247+
"""Says what to make of a block of the frozen source."""
248+
249+
250+
@draft_mark.command("ignore")
251+
@click.argument("block")
252+
@click.option(
253+
"--by",
254+
default=getpass.getuser,
255+
help="Who to record the command as having been run by. [default: your username]",
256+
)
257+
def draft_mark_ignore(block: str, by: str) -> None:
258+
"""Marks BLOCK as nothing to take a question from."""
259+
with _message_not_traceback():
260+
in2lambda.draft.execute(
261+
{"command": "mark ignore", "args": {"block": block}, "by": by}
262+
)
263+
264+
265+
@draft_group.command("replay")
266+
def draft_replay() -> None:
267+
"""Rebuilds the draft in this directory from its log and checks it is the same."""
268+
with _message_not_traceback():
269+
in2lambda.draft.replay()
270+
click.echo("Replays as it stands.")
227271

228272

229273
if __name__ == "__main__":

0 commit comments

Comments
 (0)