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 @@ -6,4 +6,5 @@
- `in2lambda FILE FILTER` exits with an error naming the command to run instead, rather than printing its usage and exiting successfully.
- 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.
- `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.
- 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.
- The Python API is unchanged: `in2lambda.main.runner` and everything under `in2lambda.api` take the same arguments and return the same objects.
240 changes: 240 additions & 0 deletions in2lambda/draft/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
"""Builds up a draft by commands, and rebuilds it from the ones it recorded.

A draft is written by a sequence of commands, some of them chosen by a model. Every
command that changes one is recorded in the draft's ``log`` as it is applied, and every
field a command writes carries where it came from, so that :func:`replay` can build the
same draft again out of the frozen markdown and the log alone, with no model in the loop.
That is what makes a run reproducible, and a saved run a test.

Commands reach a draft only through :func:`apply`, which is what keeps the log complete:
a handler registered with :func:`command` is never called by anything else.
"""

from collections.abc import Callable # Rather than typing's, which beartype warns on.
from pathlib import Path
from typing import Any

from in2lambda.source import (
DRAFT,
SourceError,
_require_conversion_tools,
blocks,
frozen,
save,
serialise,
)

Command = dict[str, Any]
"""One entry of the log: ``{"command": name, "args": {...}, "by": who}``."""

Handler = Callable[[dict[str, Any], str, dict[str, Any], str], None]
"""What a command does: `handler(draft, markdown, args, by)`, changing the draft.

The frozen markdown is passed in rather than read, so that a handler quoting the source
by line range quotes the same text on a replay as it did when it first ran.
"""

_HANDLERS: dict[str, Handler] = {}
"""Every command there is, by the name a log entry names it with."""


class MalformedCommand(SourceError):
"""A log holds something that is not a command, so nothing can be made of it."""


class UnknownCommand(SourceError):
"""A log names a command that nothing registered, so the draft cannot be rebuilt."""


class NoSuchBlock(SourceError):
"""A command names a block the frozen source has not got."""


class ReplayDiffers(SourceError):
"""Replaying a draft's log does not reproduce the draft."""


def command(name: str) -> Callable[[Handler], Handler]:
"""Registers a handler as the command of that name.

Args:
name: What a log entry calls it, as it is typed: ``"mark ignore"``.

Returns:
The decorator, which returns the handler unchanged.
"""

def register(handler: Handler) -> Handler:
_HANDLERS[name] = handler
return handler

return register


def record(
draft: dict[str, Any],
key: str,
value: Any,
*,
layer: int,
ranges: list[list[int]],
by: str,
) -> None:
"""Writes one field of a draft, with where it came from.

Args:
draft: The draft to write into.
key: What the field is called, unique within the draft.
value: What it is.
layer: What wrote it: 1 a spec, 2 a predicate, 3 a range taken from the source,
4 a literal someone typed. A reader deciding whether to trust a field wants
to know which of those it was.
ranges: The line ranges of the frozen source the value was copied from, as
``[[start, end], ...]``, and empty where it was not copied from any.
by: Who ran the command, as a name or a model.
"""
draft["fields"][key] = {
"value": value,
"layer": layer,
"ranges": ranges,
# A field is edited when something replaces the value a command wrote, which is
# not something a command can do to its own field on the way in.
"edited": False,
"by": by,
}


def _fault(entry: Any) -> str:
"""What is wrong with the shape of a log entry, or "" if nothing is."""
if not isinstance(entry, dict):
return "is not an object"
if missing := sorted({"command", "args", "by"} - entry.keys()):
return f"has no {' or '.join(missing)}"
if not isinstance(entry["command"], str):
return f"gives {entry['command']!r} as its command, which is not a name"
if not isinstance(entry["args"], dict):
return f"gives {entry['args']!r} as its args, which is not an object"
return ""


def _argument(args: dict[str, Any], name: str, command: str) -> Any:
"""One argument of a command, given that the log entry gave it.

Handlers take their arguments through this rather than indexing, so that a log
entry missing one says which one rather than raising a KeyError at whoever ran it.

Raises:
MalformedCommand: the entry has no argument of that name.
"""
if name not in args:
raise MalformedCommand(
f"{args!r} in the log is not a command {command} can run: it has no "
f'"{name}" argument.'
)
return args[name]


def apply(draft: dict[str, Any], markdown: str, entry: Any) -> None:
"""Runs one command against a draft and records it in the draft's log.

Args:
draft: The draft to change, in place.
markdown: The frozen markdown the draft was written from.
entry: The command, as it is written in the log. Anything at all, rather than a
`Command`, because a log is read from a file anyone can edit: what shape it
has is something to tell the reader about, not something to assume.

Raises:
MalformedCommand: the entry is not a command.
UnknownCommand: nothing is registered under that name.
"""
if fault := _fault(entry):
raise MalformedCommand(
f"{entry!r} in the log is not a command: it {fault}. A command is an "
'object with a "command" naming it, its "args", and who it was run "by".'
)
if (handler := _HANDLERS.get(entry["command"])) is None:
raise UnknownCommand(
f"{entry['command']} is not a command this version of in2lambda has, so "
"the draft cannot be built from its log. It was written by a newer one."
)
handler(draft, markdown, entry["args"], entry["by"])
# After the handler, so a command that was refused is not recorded as having run.
draft["log"].append(entry)


def execute(entry: Command, directory: str = ".") -> None:
"""Runs one command against the draft in a directory and writes it back.

Args:
entry: The command, as it is written in the log.
directory: Where the ``draft.json`` to change is.

Raises:
SourceError: the draft is missing, is not one of ours, or was written from
markdown that has changed since; or the command is unknown or refused.
"""
draft, markdown = frozen(directory)
apply(draft, markdown, entry)
save(Path(directory) / DRAFT, draft)


def replay(directory: str = ".") -> None:
"""Rebuilds the draft in a directory from its source and its log, and checks it.

Nothing is written: the point is to find out whether what is on disk is what its
commands say it should be, and a replay that wrote the answer could not tell anyone
it was different.

Args:
directory: Where the ``draft.json`` to replay is.

Raises:
DraftExists: the markdown has changed since the draft was written from it, so
the commands would be replayed against lines they were not run against.
MalformedCommand: the log holds something that is not a command.
UnknownCommand: the log names a command nothing here registered.
ReplayDiffers: the rebuilt draft is not the one on disk, byte for byte.
"""
_require_conversion_tools()
draft, markdown = frozen(directory)
# From the markdown rather than from the draft: the blocks are as much a product of
# the source as the fields are, and copying them across would not check them.
rebuilt: dict[str, Any] = {
"source": draft["source"],
"hash": draft["hash"],
"blocks": [block.to_dict() for block in blocks(markdown)],
"log": [],
"fields": {},
}
for entry in draft["log"]:
apply(rebuilt, markdown, entry)

path = Path(directory) / DRAFT
if serialise(rebuilt) != path.read_bytes():
raise ReplayDiffers(
f"Replaying the log in {DRAFT} does not reproduce it, so what is in it did "
"not all come from the commands it records - something has changed it since "
"they ran. Run in2lambda source add --start-over to begin again."
)


@command("mark ignore")
def _mark_ignore(
draft: dict[str, Any], markdown: str, args: dict[str, Any], by: str
) -> None:
"""Marks one block of the frozen source as nothing to take a question from."""
block = _argument(args, "block", "mark ignore")
if (found := next((b for b in draft["blocks"] if b["id"] == block), None)) is None:
raise NoSuchBlock(
f"There is no block {block} in {DRAFT}. Run in2lambda source show to see "
"the ids of the blocks there are."
)
record(
draft,
f"{block}.ignore",
True,
layer=3,
ranges=[[found["start"], found["end"]]],
by=by,
)
70 changes: 57 additions & 13 deletions in2lambda/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,23 @@
# import os
# sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))

import getpass
import importlib
import shlex
from collections.abc import Iterator # Rather than typing's, which beartype warns on.
from contextlib import contextmanager
from typing import Optional

import rich_click as click

import in2lambda.draft
import in2lambda.filters
import in2lambda.source
from in2lambda.api.set import Set

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


@contextmanager
def _message_not_traceback() -> Iterator[None]:
"""Turns anything raised for a reader into what to do about it and a non-zero exit.

Every command wraps whatever it calls in this: a missing pandoc, a draft from
somewhere else, a source that has moved on are all things the person running it can
act on, and none of them are worth a traceback.
"""
try:
yield
except SourceError as error:
raise click.ClickException(str(error)) from None


def docx_to_md(docx_file: str) -> str:
"""Converts .docx files to markdown.

Expand Down Expand Up @@ -188,11 +207,8 @@ def convert(
) -> None:
"""Takes in a QUESTION_FILE for a given SUBJECT and produces Lambda Feedback compatible json/zip files."""
# main() is made separate from click() so that it can be easily imported as part of a library.
try:
with _message_not_traceback():
runner(question_file, chosen_filter, output_dir, answer_file)
except ConversionToolsMissing as error:
# Exit with the install instructions rather than a traceback.
raise click.ClickException(str(error)) from None


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


@source_group.command("show")
def source_show() -> None:
"""Prints the frozen markdown of the draft in this directory, numbered."""
try:
with _message_not_traceback():
click.echo(in2lambda.source.show())
except SourceError as error:
raise click.ClickException(str(error)) from None


@cli.group("draft")
def draft_group() -> None:
"""Builds up the draft in this directory, recording every command in it."""


@draft_group.group("mark")
def draft_mark() -> None:
"""Says what to make of a block of the frozen source."""


@draft_mark.command("ignore")
@click.argument("block")
@click.option(
"--by",
default=getpass.getuser,
help="Who to record the command as having been run by. [default: your username]",
)
def draft_mark_ignore(block: str, by: str) -> None:
"""Marks BLOCK as nothing to take a question from."""
with _message_not_traceback():
in2lambda.draft.execute(
{"command": "mark ignore", "args": {"block": block}, "by": by}
)


@draft_group.command("replay")
def draft_replay() -> None:
"""Rebuilds the draft in this directory from its log and checks it is the same."""
with _message_not_traceback():
in2lambda.draft.replay()
click.echo("Replays as it stands.")


if __name__ == "__main__":
Expand Down
Loading
Loading