diff --git a/CHANGELOG.md b/CHANGELOG.md index f95b615..e401a7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/in2lambda/draft/__init__.py b/in2lambda/draft/__init__.py new file mode 100644 index 0000000..f4f39d1 --- /dev/null +++ b/in2lambda/draft/__init__.py @@ -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, + ) diff --git a/in2lambda/main.py b/in2lambda/main.py index 9ed757c..8ee7fe0 100644 --- a/in2lambda/main.py +++ b/in2lambda/main.py @@ -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, @@ -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. @@ -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") @@ -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__": diff --git a/in2lambda/source/__init__.py b/in2lambda/source/__init__.py index 33aef4f..e732074 100644 --- a/in2lambda/source/__init__.py +++ b/in2lambda/source/__init__.py @@ -23,8 +23,14 @@ DRAFT = "draft.json" """What a frozen source is written to, beside the source itself.""" -_FIELDS = ("source", "hash", "blocks") -"""What a draft has in it, and so what one has to have for anything here to read it.""" +_FIELDS = ("source", "hash", "blocks", "log", "fields") +"""What a draft has in it, and so what one has to have for anything here to read it. + +A draft written before ``log`` and ``fields`` existed has neither, and is refused as one +nothing here wrote: there is no command log to replay it from, and inventing an empty one +would claim the fields in it came from nowhere. Freezing the source again is the way +through, which is what the refusal says. +""" _MARKDOWN = "commonmark_x" """The dialect the frozen markdown is written in, and read back as. @@ -199,9 +205,64 @@ def _draft(path: Path) -> dict[str, Any]: f"{path} is not a draft anything here wrote: it has no " f"{' or '.join(missing)} in it. {advice}" ) + # The one gate everything reading a draft passes through, so a hand-edited log or + # fields is refused here rather than as a TypeError from whatever iterated it. + for field, shape, called in ( + ("log", list, "a list"), + ("fields", dict, "an object"), + ): + if not isinstance(draft[field], shape): + raise DraftUnreadable( + f"{path} is not a draft anything here wrote: its {field} is " + f"{draft[field]!r} rather than {called}. {advice}" + ) return draft +def serialise(draft: dict[str, Any]) -> bytes: + """The bytes a draft is written as, which is the only form it is ever written in. + + Sorted, and bytes rather than text, so that the same draft is the same file: + replaying a command log has to reproduce ``draft.json`` exactly, which it cannot do + if the key order depends on what order something happened to write the keys in, or + if the newlines depend on which machine wrote them. + """ + return (json.dumps(draft, indent=2, sort_keys=True) + "\n").encode("utf-8") + + +def save(path: Path, draft: dict[str, Any]) -> None: + """Writes a draft to the given path.""" + path.write_bytes(serialise(draft)) + + +def frozen(directory: str = ".") -> tuple[dict[str, Any], str]: + """The draft in a directory and the markdown it was written from, still unmoved. + + Args: + directory: Where the ``draft.json`` is. + + Returns: + The draft, and the text of the markdown it names. + + Raises: + DraftMissing: there is no draft in that directory. + DraftUnreadable: what is there is not a draft anything here wrote. + SourceUnreadable: the markdown the draft names has moved, or is not text. + DraftExists: the markdown has changed since the draft was written from it, so + the line ranges in the draft no longer name the lines they were taken from. + """ + path = Path(directory) / DRAFT + draft = _draft(path) + raw, markdown = _source(path.parent / draft["source"]) + if _digest(raw) != draft["hash"]: + raise DraftExists( + f"{draft['source']} has changed since {DRAFT} was written from it, so its " + "block ids no longer name the lines they were written against. Run " + "in2lambda source add --start-over to freeze the file as it now is." + ) + return draft, markdown + + @dataclass class Block: """One top-level block of a frozen source, and the lines it spans. @@ -348,26 +409,34 @@ def add(file: str, start_over: bool = False) -> Path: source = Path(file) if file_type(file) == "markdown": raw, markdown = _source(source) - frozen = source + frozen_path = source else: raw = _pandoc(file, _MARKDOWN) markdown = raw.decode("utf-8") - frozen = source.with_suffix(".md") + frozen_path = source.with_suffix(".md") draft = source.parent / DRAFT digest = _digest(raw) + # What a draft already here has been told, which freezing the same file again does + # not undo: the commands were run against these very lines, so they still hold. + # --start-over is the way to throw them away, and the only one. + log: list[Any] = [] + fields: dict[str, Any] = {} + if not start_over: if draft.is_file(): - if _draft(draft)["hash"] != digest: + existing = _draft(draft) + if existing["hash"] != digest: raise DraftExists( f"{source.name} has changed since {DRAFT} was written from it. " "Run in2lambda source add --start-over to freeze it again, which " "invalidates every line range taken from the old draft." ) - elif frozen != source and frozen.exists(): + log, fields = existing["log"], existing["fields"] + elif frozen_path != source and frozen_path.exists(): raise DraftExists( - f"{frozen.name} is already there and no {DRAFT} claims it, so it is " - "not ours to overwrite. Move it aside, or run in2lambda source add " + f"{frozen_path.name} is already there and no {DRAFT} claims it, so it " + "is not ours to overwrite. Move it aside, or run in2lambda source add " "--start-over." ) @@ -376,17 +445,21 @@ def add(file: str, start_over: bool = False) -> Path: # would refuse to touch a file this one wrote. found = [block.to_dict() for block in blocks(markdown)] - if frozen != source: + if frozen_path != source: # The bytes pandoc wrote, so that the file on disk is what `digest` is of; # writing text would rewrite the line endings on Windows and it would not be. - frozen.write_bytes(raw) - draft.write_text( - json.dumps( - {"source": frozen.name, "hash": digest, "blocks": found}, - indent=2, - ) - + "\n", - encoding="utf-8", + frozen_path.write_bytes(raw) + # Freezing is where a draft starts, not something it records: a replay is the log + # applied to this, so `add` is the only thing that writes a draft it did not run. + save( + draft, + { + "source": frozen_path.name, + "hash": digest, + "blocks": found, + "log": log, + "fields": fields, + }, ) return draft @@ -408,18 +481,10 @@ def show(directory: str = ".") -> str: DraftExists: the markdown has changed since the draft was written from it, so the ids would be printed against lines they are not the ids of. """ - draft_path = Path(directory) / DRAFT - draft = _draft(draft_path) - raw, markdown = _source(draft_path.parent / draft["source"]) # A line range is only an address while the lines have not moved: printing ids # against markdown the draft was not written from would be worse than printing # nothing, because it would look right. - if _digest(raw) != draft["hash"]: - raise DraftExists( - f"{draft['source']} has changed since {DRAFT} was written from it, so its " - "block ids no longer name the lines they were written against. Run " - "in2lambda source add --start-over to freeze the file as it now is." - ) + draft, markdown = frozen(directory) ids = {block["start"]: block["id"] for block in draft["blocks"]} lines = markdown.splitlines() diff --git a/tests/conftest.py b/tests/conftest.py index 189112d..c48847a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,6 +25,12 @@ SOURCES = sorted(path for path in SOURCES_DIR.iterdir() if path.is_dir()) """Every source folder, so that covering another construct is a folder and no code.""" +DRAFTS_DIR = Path(__file__).parent / "fixtures" / "drafts" +"""One document per folder, beside the commands to run against its draft and the result.""" + +DRAFTS = sorted(path for path in DRAFTS_DIR.iterdir() if path.is_dir()) +"""Every draft folder, so that covering another command is a folder and no code.""" + @pytest.fixture(scope="session") def filters_dir() -> str: diff --git a/tests/fixtures/drafts/README.md b/tests/fixtures/drafts/README.md new file mode 100644 index 0000000..45494ce --- /dev/null +++ b/tests/fixtures/drafts/README.md @@ -0,0 +1,10 @@ +# Drafts built by commands + +Each folder here is one run: a `source.md` to freeze, the `commands.json` to apply to the draft +of it, and the `expected.json` those commands should leave in the draft's `fields`. The test +freezes the source, applies each command, compares the fields, and then replays the draft from +its log and checks the file is unchanged byte for byte - so a folder covers both what a command +writes and that it can be rebuilt from what it recorded. + +To cover another command, add a folder. `mark_ignore` is the `sources/markdown` document with two +of its blocks marked as nothing to take a question from. diff --git a/tests/fixtures/drafts/mark_ignore/commands.json b/tests/fixtures/drafts/mark_ignore/commands.json new file mode 100644 index 0000000..2124027 --- /dev/null +++ b/tests/fixtures/drafts/mark_ignore/commands.json @@ -0,0 +1,16 @@ +[ + { + "args": { + "block": "b1" + }, + "by": "tests", + "command": "mark ignore" + }, + { + "args": { + "block": "b7" + }, + "by": "tests", + "command": "mark ignore" + } +] diff --git a/tests/fixtures/drafts/mark_ignore/expected.json b/tests/fixtures/drafts/mark_ignore/expected.json new file mode 100644 index 0000000..a1b621f --- /dev/null +++ b/tests/fixtures/drafts/mark_ignore/expected.json @@ -0,0 +1,26 @@ +{ + "b1.ignore": { + "by": "tests", + "edited": false, + "layer": 3, + "ranges": [ + [ + 1, + 1 + ] + ], + "value": true + }, + "b7.ignore": { + "by": "tests", + "edited": false, + "layer": 3, + "ranges": [ + [ + 14, + 16 + ] + ], + "value": true + } +} diff --git a/tests/fixtures/drafts/mark_ignore/source.md b/tests/fixtures/drafts/mark_ignore/source.md new file mode 100644 index 0000000..c5c9769 --- /dev/null +++ b/tests/fixtures/drafts/mark_ignore/source.md @@ -0,0 +1,16 @@ +# Hydraulic scale + +A hydraulic scale has two pistons joined by oil. Find the load the large one carries. + +- The small piston has area $a$. +- The large piston has area $A$. + +$$ +F = p A +$$ + +![The two pistons](figure.png) + +| Quantity | Symbol | +|----------|--------| +| Area | $A$ | diff --git a/tests/test_cli.py b/tests/test_cli.py index 047f2a1..56515d0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -85,4 +85,8 @@ def test_completing_the_old_form_offers_the_subcommand() -> None: cli, {}, "in2lambda", "_IN2LAMBDA_COMPLETE" ).get_completions(["./questions.tex"], "") - assert [candidate.value for candidate in completions] == ["convert", "source"] + assert [candidate.value for candidate in completions] == [ + "convert", + "draft", + "source", + ] diff --git a/tests/test_draft.py b/tests/test_draft.py new file mode 100644 index 0000000..dceddbc --- /dev/null +++ b/tests/test_draft.py @@ -0,0 +1,211 @@ +"""Building a draft by commands, and rebuilding it from the ones it recorded. + +Each folder in ``fixtures/drafts`` is a document, the commands to run against its draft +and the fields they should write, so covering another command means adding a folder +rather than a test. The rest is what the command line does when a replay cannot be +trusted - a source that has moved on, a log naming a command nothing has, a draft edited +by hand - which is not something a fixture can say. +""" + +import json +import shutil +from pathlib import Path +from typing import Any + +import pytest +from click.testing import CliRunner +from conftest import DRAFTS, DRAFTS_DIR + +import in2lambda.draft +from in2lambda.main import cli + +MARK_IGNORE = DRAFTS_DIR / "mark_ignore" +"""The case the tests below happen to use; what they check holds for any of them.""" + + +def _built(folder: Path, tmp_path: Path) -> Path: + """A folder's document, frozen in `tmp_path` with its commands applied to it.""" + shutil.copytree(folder, tmp_path, dirs_exist_ok=True) + assert CliRunner().invoke(cli, ["source", "add", "source.md"]).exit_code == 0 + for entry in json.loads((folder / "commands.json").read_text()): + in2lambda.draft.execute(entry) + return tmp_path / "draft.json" + + +@pytest.mark.parametrize("folder", DRAFTS, ids=lambda path: path.name) +def test_a_draft_built_by_commands_replays_identically( + folder: Path, tmp_path: Path, monkeypatch +) -> None: + """A run is only reproducible if the log rebuilds the same draft, byte for byte.""" + monkeypatch.chdir(tmp_path) + draft_path = _built(folder, tmp_path) + + draft = json.loads(draft_path.read_text()) + assert draft["fields"] == json.loads((folder / "expected.json").read_text()) + assert draft["log"] == json.loads((folder / "commands.json").read_text()) + + written = draft_path.read_bytes() + result = CliRunner().invoke(cli, ["draft", "replay"]) + + assert result.exit_code == 0, result.output + assert draft_path.read_bytes() == written + + +def test_freezing_an_unchanged_source_again_keeps_what_the_commands_wrote( + tmp_path: Path, monkeypatch +) -> None: + """The lines have not moved, so the commands run against them still hold.""" + monkeypatch.chdir(tmp_path) + draft_path = _built(MARK_IGNORE, tmp_path) + built = draft_path.read_bytes() + runner = CliRunner() + + assert runner.invoke(cli, ["source", "add", "source.md"]).exit_code == 0 + assert draft_path.read_bytes() == built + + # Which leaves --start-over as the way to be rid of them. + assert ( + runner.invoke(cli, ["source", "add", "source.md", "--start-over"]).exit_code + == 0 + ) + draft = json.loads(draft_path.read_text()) + assert (draft["log"], draft["fields"]) == ([], {}) + + +def test_a_command_and_a_replay_are_refused_once_the_source_has_changed( + tmp_path: Path, monkeypatch +) -> None: + """Commands were run against lines that have moved, so neither is an address now.""" + monkeypatch.setenv("COLUMNS", "200") # So the message is not wrapped mid-sentence. + monkeypatch.chdir(tmp_path) + draft_path = _built(MARK_IGNORE, tmp_path) + built = draft_path.read_bytes() + source = tmp_path / "source.md" + source.write_text(f"An afterthought.\n\n{source.read_text()}") + runner = CliRunner() + + for arguments in (["draft", "replay"], ["draft", "mark", "ignore", "b2"]): + result = runner.invoke(cli, arguments) + + assert result.exit_code != 0, arguments + assert "--start-over" in result.output + assert draft_path.read_bytes() == built + + +def test_a_log_naming_a_command_nothing_has_is_refused( + tmp_path: Path, monkeypatch +) -> None: + """A draft from a newer in2lambda cannot be rebuilt here, and says which command.""" + monkeypatch.setenv("COLUMNS", "200") + monkeypatch.chdir(tmp_path) + draft_path = _built(MARK_IGNORE, tmp_path) + draft = json.loads(draft_path.read_text()) + draft["log"].append({"command": "frobnicate", "args": {}, "by": "tests"}) + draft_path.write_text(json.dumps(draft)) + + result = CliRunner().invoke(cli, ["draft", "replay"]) + + assert result.exit_code != 0 + assert "frobnicate" in result.output + + +@pytest.mark.parametrize( + ("entry", "named"), + [ + ("mark ignore b2", "mark ignore b2"), + ({"command": "mark ignore", "args": {"block": "b2"}}, "by"), + ({"command": 7, "args": {}, "by": "tests"}, "7"), + ({"command": "mark ignore", "args": "b2", "by": "tests"}, "b2"), + ({"command": "mark ignore", "args": None, "by": "tests"}, "None"), + ({"command": "mark ignore", "args": [], "by": "tests"}, "[]"), + ({"command": "mark ignore", "args": {}, "by": "tests"}, "block"), + ], + ids=[ + "not an object", + "no by", + "command is not a name", + "args is not an object", + "args is null", + "args is a list", + "no block argument", + ], +) +def test_a_log_entry_that_is_not_a_command_is_refused( + entry: Any, named: str, tmp_path: Path, monkeypatch +) -> None: + """A log anyone can edit is not a shape to assume, and a bad one is not a crash.""" + monkeypatch.setenv("COLUMNS", "200") + monkeypatch.chdir(tmp_path) + draft_path = _built(MARK_IGNORE, tmp_path) + draft = json.loads(draft_path.read_text()) + draft["log"].append(entry) + draft_path.write_text(json.dumps(draft)) + + result = CliRunner().invoke(cli, ["draft", "replay"]) + + assert result.exit_code != 0 + assert "is not a command" in result.output + # Which of the things wrong with it, rather than leaving the reader to guess. + assert named in result.output + assert result.exception is None or isinstance(result.exception, SystemExit) + + +@pytest.mark.parametrize( + ("field", "value"), [("log", 5), ("fields", [])], ids=["log", "fields"] +) +@pytest.mark.parametrize( + "arguments", + [["draft", "replay"], ["draft", "mark", "ignore", "b2"]], + ids=["replay", "mark"], +) +def test_a_draft_whose_log_or_fields_is_the_wrong_shape_is_refused( + field: str, value: Any, arguments: list[str], tmp_path: Path, monkeypatch +) -> None: + """A draft is a file anyone can edit, so nothing reading one assumes its shape.""" + monkeypatch.setenv("COLUMNS", "200") + monkeypatch.chdir(tmp_path) + draft_path = _built(MARK_IGNORE, tmp_path) + draft = json.loads(draft_path.read_text()) + draft[field] = value + edited = json.dumps(draft, indent=2, sort_keys=True) + "\n" + draft_path.write_text(edited) + + result = CliRunner().invoke(cli, arguments) + + assert result.exit_code != 0 + assert field in result.output + assert result.exception is None or isinstance(result.exception, SystemExit) + assert draft_path.read_text() == edited + + +def test_a_field_changed_by_hand_is_refused(tmp_path: Path, monkeypatch) -> None: + """What is in a draft has to have come from the commands it records, or it is lost.""" + monkeypatch.setenv("COLUMNS", "200") + monkeypatch.chdir(tmp_path) + draft_path = _built(MARK_IGNORE, tmp_path) + draft = json.loads(draft_path.read_text()) + draft["fields"]["b1.ignore"]["value"] = False + edited = json.dumps(draft, indent=2, sort_keys=True) + "\n" + draft_path.write_text(edited) + + result = CliRunner().invoke(cli, ["draft", "replay"]) + + assert result.exit_code != 0 + assert "does not reproduce" in result.output + # A replay says what it found; writing the answer is how the edit would be lost. + assert draft_path.read_text() == edited + + +def test_marking_a_block_that_is_not_there_says_so(tmp_path: Path, monkeypatch) -> None: + """The ids come from `source show`, and a typo in one is a message, not a field.""" + monkeypatch.setenv("COLUMNS", "200") + monkeypatch.chdir(tmp_path) + draft_path = _built(MARK_IGNORE, tmp_path) + built = draft_path.read_bytes() + + result = CliRunner().invoke(cli, ["draft", "mark", "ignore", "b99"]) + + assert result.exit_code != 0 + assert "b99" in result.output + assert "in2lambda source show" in result.output + assert draft_path.read_bytes() == built diff --git a/tests/test_source.py b/tests/test_source.py index 14fe82a..7e17b7e 100644 --- a/tests/test_source.py +++ b/tests/test_source.py @@ -158,8 +158,13 @@ def test_source_show_refuses_once_the_source_has_changed( ) @pytest.mark.parametrize( "arguments", - [["source", "add", "source.md"], ["source", "show"]], - ids=["add", "show"], + [ + ["source", "add", "source.md"], + ["source", "show"], + ["draft", "mark", "ignore", "b1"], + ["draft", "replay"], + ], + ids=["add", "show", "mark", "replay"], ) def test_a_draft_from_somewhere_else_is_refused( content: str, arguments: list[str], tmp_path: Path, monkeypatch