From 999574cd18c78311f34d8d8fcf61e453fa9f07dd Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Sun, 20 Sep 2026 17:27:43 +0100 Subject: [PATCH] implement: Keep one draft per source, not per directory (t42) --- CHANGELOG.md | 7 +- in2lambda/draft/__init__.py | 69 ++++++++-------- in2lambda/draft/export.py | 36 +++++---- in2lambda/draft/report.py | 21 ++--- in2lambda/main.py | 117 +++++++++++++++++++-------- in2lambda/source/__init__.py | 134 ++++++++++++++++++++++--------- tests/fixtures/sources/README.md | 3 +- tests/test_draft.py | 75 ++++++++++++++--- tests/test_source.py | 25 +++--- tests/test_spec.py | 32 ++++---- 10 files changed, 347 insertions(+), 172 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cb18f0..5b5fe1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +5,14 @@ - Converting a document is now `in2lambda convert FILE FILTER`, with the same options as before (`-o/--out`, `-a/--answers`). Scripts and Docker invocations that run `in2lambda FILE FILTER` need the extra word. - `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. +- `in2lambda source add FILE` freezes a document: it converts .docx and .tex to markdown beside the file, and writes a `FILE.draft.json` beside it, 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. The draft is named after the source, so a folder holding a term's worth of sheets holds a draft for each. `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 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. - 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 naming both fields. - `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. - `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. - 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. - `in2lambda validate` checks a draft over as a whole and writes what it finds into it as a `report`: source blocks in no field and not marked ignore, two fields taken from the same lines, gaps in the numbering of the questions or their parts, and fields holding nothing, each at level `error`; a part, or a question written without parts, that nothing in the draft answers is reported at level `warning` instead. Each finding names the level, the field and the lines it is about, so it can be acted on without reading the draft. Finding something is not a failure and the command still exits 0; the report is replaced by the next run of the checks and dropped by the next command that changes the draft, since it describes the draft as it stood. It also checks over the set the draft describes, as a converted document is checked at export - maths delimiters, what KaTeX will not render, images the export would not carry, and the compile Lambda Feedback's PDF generator does where pandoc and xelatex are installed, with a warning saying what to install where they are not - and reports each of those against the draft field the text is written in, at level `error`, so that `in2lambda build` refuses them as it refuses anything else at that level. -- `in2lambda build` writes the draft in this directory out as a Lambda Feedback set: one question per `qN.text` field, holding the parts written for it and the worked solutions, with the images those fields refer to under `media/`, as `in2lambda convert` writes a set - a field naming an image that is not beside the draft is refused saying which file is missing, since the checks read the draft and not the folder it is in, and a question's own solution written beside a solution for every part it has becomes a part of its own holding just that solution, as `convert` pairs them up. It is refused unless `in2lambda validate` has been run since the draft last changed - every command that changes one drops its report - and found nothing at level `error`, and the refusal prints those findings so they can be acted on without opening the draft. A finding at level `warning` - a part or question nothing in the draft answers - does not stop it: half the sheets there are keep their solutions in another file or have none at all, so the warning is printed and the set written all the same, rather than a solution having to be invented to quiet it. `in2lambda render` writes each question as a PDF instead, compiled as Lambda Feedback's own PDF generator compiles it, which needs pandoc and xelatex; it is gated on nothing, since looking at a draft is how what the checks found gets fixed. Both take `-o/--out`, as `convert` does. +- `in2lambda build` writes a draft out as a Lambda Feedback set: one question per `qN.text` field, holding the parts written for it and the worked solutions, with the images those fields refer to under `media/`, as `in2lambda convert` writes a set - a field naming an image that is not beside the draft is refused saying which file is missing, since the checks read the draft and not the folder it is in, and a question's own solution written beside a solution for every part it has becomes a part of its own holding just that solution, as `convert` pairs them up. It is refused unless `in2lambda validate` has been run since the draft last changed - every command that changes one drops its report - and found nothing at level `error`, and the refusal prints those findings so they can be acted on without opening the draft. A finding at level `warning` - a part or question nothing in the draft answers - does not stop it: half the sheets there are keep their solutions in another file or have none at all, so the warning is printed and the set written all the same, rather than a solution having to be invented to quiet it. `in2lambda render` writes each question as a PDF instead, compiled as Lambda Feedback's own PDF generator compiles it, which needs pandoc and xelatex; it is gated on nothing, since looking at a draft is how what the checks found gets fixed. Both take `-o/--out`, as `convert` does. +- Every command that works on a draft - `in2lambda source show`, each of the `in2lambda draft` commands, `in2lambda spec run`, `in2lambda validate`, `in2lambda build` and `in2lambda render` - takes `--draft`, naming either the draft or the source it was frozen from. Left off, it uses the one draft in the current directory, and where there is more than one it is refused naming them rather than acting on whichever sorts first. `in2lambda spec run` names its SPEC from the draft's directory. The Python functions behind them take the draft's path rather than a directory: `in2lambda.source.frozen`, `in2lambda.source.show`, `in2lambda.draft.execute`, `in2lambda.draft.replay`, `in2lambda.draft.spec_command`, `in2lambda.draft.report.validate`, `in2lambda.draft.export.build` and `in2lambda.draft.export.render`. `in2lambda.source.draft_of` says where a document's draft goes and `in2lambda.source.find` is what the command line resolves `--draft` with. - Importing `in2lambda.katex_convert` no longer writes a file called `log` into the working directory. What it has to say about a converted expression goes to the `in2lambda.katex_convert` logger, which is silent unless the application configures logging. - 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 index 92c6a73..58744c1 100644 --- a/in2lambda/draft/__init__.py +++ b/in2lambda/draft/__init__.py @@ -18,7 +18,6 @@ import in2lambda.spec from in2lambda.draft.report import _order, checks, overlapping, uncovered from in2lambda.source import ( - DRAFT, SourceError, _digest, _elements, @@ -265,12 +264,12 @@ def apply( return written -def execute(entry: Command, directory: str = ".") -> str: - """Runs one command against the draft in a directory and writes it back. +def execute(entry: Command, draft: str | Path) -> str: + """Runs one command against a draft and writes it back. Args: entry: The command, as it is written in the log. - directory: Where the ``draft.json`` to change is. + draft: The path of the draft to change. Returns: What the command wrote, as the handler names it: the key of a field, the block @@ -280,21 +279,24 @@ def execute(entry: Command, directory: str = ".") -> str: 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) - written = apply(draft, markdown, entry, directory) - save(Path(directory) / DRAFT, draft) + path = Path(draft) + found, markdown = frozen(path) + # The handlers are given the folder rather than the draft: what they read beside it + # - a spec, a file of predicates - is named from there, whichever draft is theirs. + written = apply(found, markdown, entry, str(path.parent)) + save(path, found) return written -def replay(directory: str = ".") -> None: - """Rebuilds the draft in a directory from its source and its log, and checks it. +def replay(draft: str | Path) -> None: + """Rebuilds a draft from its source and its log, and checks it is the same. 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. + draft: The path of the draft to replay. Raises: DraftExists: the markdown has changed since the draft was written from it, so @@ -305,36 +307,36 @@ def replay(directory: str = ".") -> None: ReplayDiffers: the rebuilt draft is not the one on disk, byte for byte. """ _require_conversion_tools() - draft, markdown = frozen(directory) + path = Path(draft) + found, markdown = frozen(path) # 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"], + "source": found["source"], + "hash": found["hash"], "blocks": [block.to_dict() for block in blocks(markdown)], "log": [], "fields": {}, } - for entry in draft["log"]: - apply(rebuilt, markdown, entry, directory) + for entry in found["log"]: + apply(rebuilt, markdown, entry, str(path.parent)) # The one thing in a draft that no command wrote: the checks did, over the draft the # commands left, so rebuilding it is running them again rather than copying it. What # `in2lambda.validation` found over the set is carried across instead, since it # depends on whether xelatex and Node are installed and the draft does not: rebuilt # here it would come out shorter on a machine whose toolchain is not the one that # validated, and an untouched draft would be accused of having been edited. - if "report" in draft: + if "report" in found: carried = [ - finding for finding in draft["report"] if finding["check"] == "problem" + finding for finding in found["report"] if finding["check"] == "problem" ] rebuilt["report"] = sorted(checks(rebuilt) + carried, key=_order) - 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." + f"Replaying the log in {path.name} 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." ) @@ -346,7 +348,7 @@ def _block(draft: dict[str, Any], block: str) -> dict[str, Any]: """ 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 " + f"There is no block {block} in the draft. Run in2lambda source show to see " "the ids of the blocks there are." ) return found @@ -477,8 +479,8 @@ def _require_question(draft: dict[str, Any], question: str, command: str) -> Non """ if f"{question}.text" not in draft["fields"]: raise NoSuchQuestion( - f"There is no question {question} in {DRAFT}: {command} adds to a question " - "that in2lambda draft question add has already written." + f"There is no question {question} in the draft: {command} adds to a " + "question that in2lambda draft question add has already written." ) @@ -577,7 +579,7 @@ def _field_replace( field = draft["fields"].get(key) if field is None or not isinstance(field.get("value"), str): raise NoSuchField( - f"There is no field {key} holding text in {DRAFT}: field replace changes " + f"There is no field {key} holding text in the draft: field replace changes " "the wording of a field one of the commands before it has written." ) value = field["value"] @@ -658,13 +660,13 @@ def _file_as_run(directory: str, name: str, digest: str) -> bytes: raw = (Path(directory) / name).read_bytes() except FileNotFoundError: raise SpecChanged( - f"There is no {name} beside {DRAFT}, and the log says the draft was filled " - "in with it. Put it back, or start the draft again with in2lambda source " - "add --start-over." + f"There is no {name} beside the draft, and the log says the draft was " + "filled in with it. Put it back, or start the draft again with in2lambda " + "source add --start-over." ) from None if _digest(raw) != digest: raise SpecChanged( - f"{name} has changed since it was run against {DRAFT}, so the fields the " + f"{name} has changed since it was run against the draft, so the fields the " "spec wrote are not the ones it would write now. Put it back, or start the " "draft again with in2lambda source add --start-over." ) @@ -696,7 +698,7 @@ def _files(args: dict[str, Any]) -> list[tuple[str, str]]: return files -def spec_command(name: str, by: str, directory: str = ".") -> Command: +def spec_command(name: str, by: str, draft: str | Path) -> Command: """The `spec run` entry for a spec, with everything it depends on hashed into it. The hashes go in the log beside the names, so that a replay can tell whether it is @@ -705,7 +707,7 @@ def spec_command(name: str, by: str, directory: str = ".") -> Command: Args: name: The spec to run, as it is to be named in the log: beside the draft. by: Who is running it, as a name or a model. - directory: Where the draft is, and so what the spec is beside. + draft: The path of the draft the spec is to fill in, which the spec is beside. Returns: The command, for :func:`execute` to run. @@ -715,8 +717,9 @@ def spec_command(name: str, by: str, directory: str = ".") -> Command: it names a file of predicates that is not beside it. """ _require_conversion_tools() + directory = Path(draft).parent try: - raw = (Path(directory) / name).read_bytes() + raw = (directory / name).read_bytes() except OSError: raise in2lambda.spec.BadSpec( f"There is no {name} to read a spec from. A spec is the file of selectors " @@ -730,7 +733,7 @@ def spec_command(name: str, by: str, directory: str = ".") -> Command: # what the log names things from. beside = (Path(name).parent / spec.predicates).as_posix() try: - code = (Path(directory) / beside).read_bytes() + code = (directory / beside).read_bytes() except OSError: raise in2lambda.spec.BadSpec( f"There is no {beside} to read the spec's predicates from. The " diff --git a/in2lambda/draft/export.py b/in2lambda/draft/export.py index f077576..614cf4b 100644 --- a/in2lambda/draft/export.py +++ b/in2lambda/draft/export.py @@ -24,7 +24,7 @@ from in2lambda.api.question import Question from in2lambda.api.set import Set from in2lambda.json_convert.json_convert import _question_stem, _question_title -from in2lambda.source import DRAFT, ConversionToolsMissing, SourceError, frozen +from in2lambda.source import ConversionToolsMissing, SourceError, frozen from in2lambda.validation import _IMAGE, _location, pdf _QUESTION = re.compile(r"q(\d+)\.text") @@ -181,11 +181,11 @@ def located(draft: dict[str, Any]) -> dict[str, str]: return where -def build(directory: str = ".", output_dir: str = "out") -> Path: - """Writes the draft in a directory out as a Lambda Feedback set, if it is clean. +def build(draft: str | Path, output_dir: str = "out") -> Path: + """Writes a draft out as a Lambda Feedback set, if it is clean. Args: - directory: Where the ``draft.json`` to export is. + draft: The path of the draft to export. output_dir: Where to write the set's folder and its zip. Returns: @@ -207,25 +207,26 @@ def build(directory: str = ".", output_dir: str = "out") -> Path: # it imports this, and only what reads a report - this one function - needs it back. from in2lambda.draft.report import errors - draft, _ = frozen(directory) - if "report" not in draft: + path = Path(draft) + found, _ = frozen(path) + if "report" not in found: raise NotValidated( - f"{DRAFT} has not been validated since it last changed, so what it would " - "export is what nothing has checked. Run in2lambda validate." + f"{path.name} has not been validated since it last changed, so what it " + "would export is what nothing has checked. Run in2lambda validate." ) - if refusing := errors(draft["report"]): + if refusing := errors(found["report"]): raise NotValidated( "\n".join(finding["message"] for finding in refusing) - + f"\n{DRAFT} is not exported while its report says this. Fix what it " + + f"\n{path.name} is not exported while its report says this. Fix what it " "names, or mark the blocks it is about as ignored, and run in2lambda " "validate again." ) - for finding in draft["report"]: + for finding in found["report"]: # Said rather than refused: a sheet whose solutions are elsewhere or absent is # one to export as it stands, and writing one in to quiet this would put wording # into the set that no source of it says. warnings.warn(finding["message"], stacklevel=2) - exported = as_set(draft, directory) + exported = as_set(found, str(path.parent)) # The export carries every image a field refers to into media/, which is the only # place Lambda Feedback looks for one, so a file that is not there is not something # to write the set without: `json_convert` would raise a bare FileNotFoundError over @@ -243,8 +244,8 @@ def build(directory: str = ".", output_dir: str = "out") -> Path: return Path(output_dir) / "set.zip" -def render(directory: str = ".", output_dir: str = "out") -> list[Path]: - """Writes each question of the draft in a directory as a PDF, for review. +def render(draft: str | Path, output_dir: str = "out") -> list[Path]: + """Writes each question of a draft as a PDF, for review. The questions are compiled as Lambda Feedback's own PDF generator compiles them, under a heading naming each, so what comes out is what a student would be shown. @@ -254,7 +255,7 @@ def render(directory: str = ".", output_dir: str = "out") -> list[Path]: compiler gives up on altogether stop the rest of the draft being written out. Args: - directory: Where the ``draft.json`` to render is. + draft: The path of the draft to render. output_dir: Where to write the PDFs, named as the export names its questions. Returns: @@ -275,11 +276,12 @@ def render(directory: str = ".", output_dir: str = "out") -> list[Path]: raise ConversionToolsMissing( f"Rendering questions needs {' and '.join(missing)}." ) - draft, _ = frozen(directory) + path = Path(draft) + found, _ = frozen(path) written = [] refused = [] - for index, question in enumerate(as_set(draft, directory).questions): + for index, question in enumerate(as_set(found, str(path.parent)).questions): stem = _question_stem(index, _question_title(question, index)) output = Path(output_dir) / f"{stem}.pdf" # Headed with the question's number, so that a stack of these can be read diff --git a/in2lambda/draft/report.py b/in2lambda/draft/report.py index b31f806..fbbf041 100644 --- a/in2lambda/draft/report.py +++ b/in2lambda/draft/report.py @@ -30,7 +30,7 @@ from typing import Any from in2lambda.draft.export import as_set, located -from in2lambda.source import DRAFT, frozen, save +from in2lambda.source import frozen, save from in2lambda.validation import pdf Finding = dict[str, Any] @@ -393,22 +393,22 @@ def problems(draft: dict[str, Any], directory: str = ".") -> list[Finding]: return found -def validate(directory: str = ".") -> list[Finding]: - """Checks the draft in a directory over and writes the report into it. +def validate(draft: str | Path) -> list[Finding]: + """Checks a draft over and writes the report into it. The report replaces whatever one is there, and is dropped again by the next command that changes the draft: it describes the draft as it stood, and a report saying something else is worse than none at all. Args: - directory: Where the ``draft.json`` to check is. + draft: The path of the draft to check. Returns: What the checks and `in2lambda.validation` found, as it was written into the draft. Raises: - DraftMissing: there is no draft in that directory. + DraftMissing: there is no draft at that path. 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 @@ -417,7 +417,10 @@ def validate(directory: str = ".") -> list[Finding]: Warns: UserWarning: a check could not be run here - see :func:`problems`. """ - draft, _ = frozen(directory) - draft["report"] = sorted(checks(draft) + problems(draft, directory), key=_order) - save(Path(directory) / DRAFT, draft) - return draft["report"] + path = Path(draft) + found, _ = frozen(path) + found["report"] = sorted( + checks(found) + problems(found, str(path.parent)), key=_order + ) + save(path, found) + return found["report"] diff --git a/in2lambda/main.py b/in2lambda/main.py index 853aaab..951ff60 100644 --- a/in2lambda/main.py +++ b/in2lambda/main.py @@ -254,22 +254,32 @@ def source_group() -> None: help="Freeze FILE again, discarding the draft already there.", ) def source_add(file: str, start_over: bool) -> None: - """Converts FILE to markdown and records its blocks in draft.json beside it.""" + """Converts FILE to markdown and records its blocks in FILE.draft.json beside it.""" with _message_not_traceback(): draft = in2lambda.source.add(file, start_over) click.echo(f"Wrote {draft}") +_draft = click.option( + "--draft", + type=click.Path(exists=True, dir_okay=False), + help="The draft to work on, as FILE.draft.json or the source it was frozen from. " + " [default: the one draft in this directory]", +) +"""Which draft a command is about, since a folder of sheets holds one draft each.""" + + @source_group.command("show") -def source_show() -> None: - """Prints the frozen markdown of the draft in this directory, numbered.""" +@_draft +def source_show(draft: Optional[str]) -> None: + """Prints the frozen markdown of a draft, numbered.""" with _message_not_traceback(): - click.echo(in2lambda.source.show()) + click.echo(in2lambda.source.show(in2lambda.source.find(draft))) @cli.group("draft") def draft_group() -> None: - """Builds up the draft in this directory, recording every command in it.""" + """Builds up a draft, recording every command in it.""" _by = click.option( @@ -298,8 +308,8 @@ def _text_or_literal(command: Callable[..., None]) -> Callable[..., None]: return command -def _run(command: str, args: dict[str, Any], by: str) -> None: - """Runs one draft command against the draft here and says what it wrote. +def _run(command: str, args: dict[str, Any], by: str, draft: Optional[str]) -> None: + """Runs one draft command against the draft asked for and says what it wrote. Arguments nobody gave are left out rather than recorded as nulls: the log is what a replay runs, and an option that was not passed is not an argument of the command. @@ -312,7 +322,8 @@ def _run(command: str, args: dict[str, Any], by: str) -> None: name: given for name, given in args.items() if given is not None }, "by": by, - } + }, + in2lambda.source.find(draft), ) click.echo(f"Wrote {written}.") @@ -325,9 +336,10 @@ def draft_mark() -> None: @draft_mark.command("ignore") @click.argument("block") @_by -def draft_mark_ignore(block: str, by: str) -> None: +@_draft +def draft_mark_ignore(block: str, by: str, draft: Optional[str]) -> None: """Marks BLOCK as nothing to take a question from.""" - _run("mark ignore", {"block": block}, by) + _run("mark ignore", {"block": block}, by, draft) @draft_group.group("question") @@ -338,23 +350,32 @@ def draft_question() -> None: @draft_question.command("add") @_text_or_literal @_by -def draft_question_add(text: Optional[str], literal: Optional[str], by: str) -> None: +@_draft +def draft_question_add( + text: Optional[str], literal: Optional[str], by: str, draft: Optional[str] +) -> None: """Adds a question, numbered after the ones already there.""" - _run("question add", {"text": text, "literal": literal}, by) + _run("question add", {"text": text, "literal": literal}, by, draft) @draft_question.command("solution") @click.argument("question") @_text_or_literal @_by +@_draft def draft_question_solution( - question: str, text: Optional[str], literal: Optional[str], by: str + question: str, + text: Optional[str], + literal: Optional[str], + by: str, + draft: Optional[str], ) -> None: """Gives QUESTION the worked solution written at --text or --literal.""" _run( "question solution", {"question": question, "text": text, "literal": literal}, by, + draft, ) @@ -367,11 +388,18 @@ def draft_part() -> None: @click.argument("question") @_text_or_literal @_by +@_draft def draft_part_add( - question: str, text: Optional[str], literal: Optional[str], by: str + question: str, + text: Optional[str], + literal: Optional[str], + by: str, + draft: Optional[str], ) -> None: """Adds a part of QUESTION, numbered after the parts it already has.""" - _run("part add", {"question": question, "text": text, "literal": literal}, by) + _run( + "part add", {"question": question, "text": text, "literal": literal}, by, draft + ) @draft_group.group("split") @@ -383,9 +411,10 @@ def draft_split() -> None: @click.argument("block") @click.argument("at", type=int) @_by -def draft_split_block(block: str, at: int, by: str) -> None: +@_draft +def draft_split_block(block: str, at: int, by: str, draft: Optional[str]) -> None: """Splits BLOCK in two, the second half starting at line AT.""" - _run("split block", {"block": block, "at": at}, by) + _run("split block", {"block": block, "at": at}, by, draft) @draft_group.group("field") @@ -403,41 +432,53 @@ def draft_field() -> None: help="Read OLD as a regular expression, and NEW as what to replace it with.", ) @_by -def draft_field_replace(field: str, old: str, new: str, regex: bool, by: str) -> None: +@_draft +def draft_field_replace( + field: str, old: str, new: str, regex: bool, by: str, draft: Optional[str] +) -> None: """Replaces OLD with NEW in FIELD, which OLD has to occur exactly once in.""" _run( "field replace", {"field": field, "old": old, "new": new, "regex": True if regex else None}, by, + draft, ) @draft_group.command("replay") -def draft_replay() -> None: - """Rebuilds the draft in this directory from its log and checks it is the same.""" +@_draft +def draft_replay(draft: Optional[str]) -> None: + """Rebuilds a draft from its log and checks it is the same.""" with _message_not_traceback(): - in2lambda.draft.replay() + in2lambda.draft.replay(in2lambda.source.find(draft)) click.echo("Replays as it stands.") @cli.group("spec") def spec_group() -> None: - """Runs a YAML spec of selectors over the frozen source in this directory.""" + """Runs a YAML spec of selectors over a draft's frozen source.""" @spec_group.command("run") -@click.argument("spec", type=click.Path(exists=True, dir_okay=False)) +# Named from the draft's directory rather than from here, which is where `spec_command` +# looks for it and how the log records it, so click is not the one to check it is there. +@click.argument("spec") @_by -def spec_run(spec: str, by: str) -> None: +@_draft +def spec_run(spec: str, by: str, draft: Optional[str]) -> None: """Fills the draft's fields in from SPEC, and says which blocks it left out.""" with _message_not_traceback(): - report = in2lambda.draft.execute(in2lambda.draft.spec_command(spec, by)) + path = in2lambda.source.find(draft) + report = in2lambda.draft.execute( + in2lambda.draft.spec_command(spec, by, path), path + ) click.echo(report) @cli.command("validate") -def validate() -> None: - """Checks the draft in this directory over and writes the report into it. +@_draft +def validate(draft: Optional[str]) -> None: + """Checks a draft over and writes the report into it. Reports source blocks in no field and not marked ignore, two fields taken from the same lines, gaps in the numbering of the questions or their parts, and fields holding @@ -446,11 +487,11 @@ def validate() -> None: Feedback's PDF generator does where pandoc and xelatex are installed - each against the field it is written in. All of those in2lambda build refuses; a question or part nothing answers is reported as a warning, which it builds over. Finding something is - not a failure: the report is written into draft.json either way, and replaced by the + not a failure: the report is written into the draft either way, and replaced by the next one. """ with _message_not_traceback(): - report = in2lambda.draft.report.validate() + report = in2lambda.draft.report.validate(in2lambda.source.find(draft)) for finding in report: # Marked as such, since the two are acted on differently and the report is often # read off the terminal rather than out of the draft. @@ -476,8 +517,9 @@ def validate() -> None: @cli.command("build") @_out -def build(output_dir: str) -> None: - """Writes the draft in this directory out as a Lambda Feedback set. +@_draft +def build(output_dir: str, draft: Optional[str]) -> None: + """Writes a draft out as a Lambda Feedback set. Refused unless in2lambda validate has been run since the draft last changed and found no error, so that what is uploaded is what the checks have been over. What it @@ -485,14 +527,17 @@ def build(output_dir: str) -> None: said, and the set written all the same. """ with _message_not_traceback(), _warnings_said(): - written = in2lambda.draft.export.build(output_dir=output_dir) + written = in2lambda.draft.export.build( + in2lambda.source.find(draft), output_dir=output_dir + ) click.echo(f"Wrote {written}") @cli.command("render") @_out -def render(output_dir: str) -> None: - """Writes each question of the draft in this directory as a PDF, for review. +@_draft +def render(output_dir: str, draft: Optional[str]) -> None: + """Writes each question of a draft as a PDF, for review. The questions are compiled as Lambda Feedback's PDF generator compiles them, which needs pandoc and xelatex. What the checks have to say about the draft is not asked: @@ -501,7 +546,9 @@ def render(output_dir: str) -> None: # A question xelatex complains about is still written out, and what it refused is a # line to read rather than a traceback. with _message_not_traceback(), _warnings_said(): - written = in2lambda.draft.export.render(output_dir=output_dir) + written = in2lambda.draft.export.render( + in2lambda.source.find(draft), output_dir=output_dir + ) for pdf in written: click.echo(f"Wrote {pdf}") diff --git a/in2lambda/source/__init__.py b/in2lambda/source/__init__.py index a65471c..3b267ae 100644 --- a/in2lambda/source/__init__.py +++ b/in2lambda/source/__init__.py @@ -3,8 +3,11 @@ Anything that writes questions from a document - the in2lambda agent, say - needs to take the wording out of the source rather than retype it, and a line range is only an address if the text it points into cannot move underneath it. So the document is frozen -once: converted to markdown, hashed, and written down beside a ``draft.json`` listing -every top-level block with the lines it spans. +once: converted to markdown, hashed, and written down beside a ``FILE.draft.json`` +listing every top-level block with the lines it spans. + +The draft is named after the source it was frozen from, so a folder holding a term's +worth of sheets holds a draft for each rather than one they take turns overwriting. Everything here needs pandoc, and the parsing needs panflute, which only the ``convert`` extra installs; :func:`add` says so rather than failing on the import. @@ -20,8 +23,8 @@ from pathlib import Path from typing import Any, Optional -DRAFT = "draft.json" -"""What a frozen source is written to, beside the source itself.""" +DRAFT_SUFFIX = ".draft.json" +"""What a frozen source is written to, beside the source itself and named after it.""" def _field_fault(field: Any) -> str: @@ -96,7 +99,11 @@ class DraftExists(SourceError): class DraftMissing(SourceError): - """There is no draft to show in the directory asked about.""" + """There is no draft where one was looked for.""" + + +class ManyDrafts(SourceError): + """A directory holds more than one draft, so which was meant has to be said.""" class DraftUnreadable(SourceError): @@ -107,6 +114,62 @@ class SourceUnreadable(SourceError): """The markdown to read has moved, or is not text.""" +def draft_of(source: str | Path) -> Path: + """Where the draft of a document goes, which is beside it and named after it. + + Args: + source: The document that was or would be frozen, in any format :func:`add` + takes. A draft's own path is given back as it is, so that anything taking + one from a reader can take either. + + Returns: + The path of that document's draft. + + Examples: + >>> from in2lambda.source import draft_of + >>> draft_of("sheets/week1.tex").name + 'week1.draft.json' + >>> draft_of("sheets/week1.draft.json").name + 'week1.draft.json' + """ + path = Path(source) + if path.name.endswith(DRAFT_SUFFIX): + return path + return path.with_name(f"{path.stem}{DRAFT_SUFFIX}") + + +def find(given: Optional[str] = None, directory: str = ".") -> Path: + """Which draft a command was asked to work on, or the one draft there is. + + Args: + given: What a reader named, as the draft or as the source it was frozen from, + or nothing to go by what is in `directory`. + directory: Where to look when nothing was named. + + Returns: + The path of the draft to read. + + Raises: + DraftMissing: nothing was named and there is no draft to fall back on. + ManyDrafts: nothing was named and there is more than one, so a folder of + sheets does not silently act on whichever sorts first. + """ + if given is not None: + return draft_of(given) + found = sorted(Path(directory).glob(f"*{DRAFT_SUFFIX}")) + if len(found) == 1: + return found[0] + where = Path(directory).resolve() + if not found: + raise DraftMissing( + f"There is no draft in {where}. Run in2lambda source add FILE first." + ) + raise ManyDrafts( + f"There is more than one draft in {where}: " + f"{', '.join(path.name for path in found)}. Say which with --draft." + ) + + def _require_conversion_tools() -> None: missing = [] if shutil.which("pandoc") is None: @@ -222,8 +285,7 @@ def _draft(path: Path) -> dict[str, Any]: """ if not path.is_file(): raise DraftMissing( - f"There is no {DRAFT} in {path.parent.resolve()}. " - "Run in2lambda source add FILE first." + f"There is no {path.resolve()}. Run in2lambda source add FILE first." ) advice = ( "Move it aside and run in2lambda source add FILE, or pass --start-over to " @@ -266,7 +328,7 @@ 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 + replaying a command log has to reproduce the draft 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. """ @@ -278,32 +340,32 @@ def save(path: Path, draft: dict[str, Any]) -> None: 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. +def frozen(draft: str | Path) -> tuple[dict[str, Any], str]: + """A draft and the markdown it was written from, still unmoved. Args: - directory: Where the ``draft.json`` is. + draft: The path of the draft to read. Returns: The draft, and the text of the markdown it names. Raises: - DraftMissing: there is no draft in that directory. + DraftMissing: there is no draft at that path. 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"]: + path = Path(draft) + found = _draft(path) + raw, markdown = _source(path.parent / found["source"]) + if _digest(raw) != found["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 " + f"{found['source']} has changed since {path.name} 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 + return found, markdown @dataclass @@ -320,7 +382,7 @@ class Block: end: int def to_dict(self) -> dict[str, str | int]: - """The block as it is written into ``draft.json``.""" + """The block as it is written into the draft.""" return {"id": self.id, "type": self.type, "start": self.start, "end": self.end} @@ -486,20 +548,20 @@ def add(file: str, start_over: bool = False) -> Path: A .docx or .tex file is converted to markdown next to it; a markdown file is taken as it is and nothing is copied. Either way the markdown is hashed and its blocks - written to ``draft.json``, so that whatever quotes the source by line range can tell - that the lines it was given still say what they said. + written to ``FILE.draft.json``, so that whatever quotes the source by line range can + tell that the lines it was given still say what they said. Args: file: The document to freeze, as .docx, .tex or markdown. start_over: Freeze the file again, discarding whatever is already there. Returns: - The path of the ``draft.json`` that was written. + The path of the draft that was written. Raises: ConversionToolsMissing: pandoc or panflute is not installed. SourceUnreadable: the file is markdown, but not UTF-8 text. - DraftUnreadable: there is a draft.json beside the file that nothing here wrote, + DraftUnreadable: there is a draft beside the file that nothing here wrote, so it is not ours to read a hash out of or to write over. DraftExists: the source has changed since it was frozen, or the markdown would overwrite a file that no draft claims. Neither happens with `start_over`. @@ -513,7 +575,7 @@ def add(file: str, start_over: bool = False) -> Path: raw = _pandoc(file, _MARKDOWN) markdown = raw.decode("utf-8") frozen_path = source.with_suffix(".md") - draft = source.parent / DRAFT + draft = draft_of(source) digest = _digest(raw) # What a draft already here has been told, which freezing the same file again does @@ -535,17 +597,17 @@ def add(file: str, start_over: bool = False) -> Path: 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." + f"{source.name} has changed since {draft.name} was written from " + "it. Run in2lambda source add --start-over to freeze it again, " + "which invalidates every line range taken from the old draft." ) found, log, fields = existing["blocks"], existing["log"], existing["fields"] report = existing.get("report") elif frozen_path != source and frozen_path.exists(): raise DraftExists( - 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." + f"{frozen_path.name} is already there and no {draft.name} claims it, " + "so it is not ours to overwrite. Move it aside, or run in2lambda " + "source add --start-over." ) # Before either file is written: a parse that fails half way through would @@ -574,18 +636,18 @@ def add(file: str, start_over: bool = False) -> Path: return draft -def show(directory: str = ".") -> str: +def show(draft: str | Path) -> str: """The frozen markdown of a draft, numbered, with block ids in the margin. Args: - directory: Where the ``draft.json`` to print is. + draft: The path of the draft to print. Returns: One line per line of the frozen markdown: the id of the block starting there, where one does, then the line number and the line itself. Raises: - DraftMissing: there is no draft in that directory. + DraftMissing: there is no draft at that path. 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 @@ -594,9 +656,9 @@ def show(directory: str = ".") -> str: # 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. - draft, markdown = frozen(directory) + found, markdown = frozen(draft) - ids = {block["start"]: block["id"] for block in draft["blocks"]} + ids = {block["start"]: block["id"] for block in found["blocks"]} lines = markdown.splitlines() margin = max((len(block_id) for block_id in ids.values()), default=0) numbers = len(str(len(lines))) diff --git a/tests/fixtures/sources/README.md b/tests/fixtures/sources/README.md index 0d17e9a..eb98fc2 100644 --- a/tests/fixtures/sources/README.md +++ b/tests/fixtures/sources/README.md @@ -1,7 +1,8 @@ # Sources to freeze Each folder here is one document for `in2lambda source add` to freeze, beside the block list -it should write into `draft.json`. `markdown`, `tex` and `docx` say the same thing in the three +it should write into `source.draft.json`, the draft being named after the source it was frozen +from. `markdown`, `tex` and `docx` say the same thing in the three formats the command takes, so that what a heading or a list item comes out as does not depend on which format an author brought it in; `empty_list_item` is a bullet with nothing in it, which has no position of its own and so no block; `unseparated_list` is a list with no blank line before it, diff --git a/tests/test_draft.py b/tests/test_draft.py index 88fe43f..a382216 100644 --- a/tests/test_draft.py +++ b/tests/test_draft.py @@ -46,17 +46,21 @@ DEGREES = DRAFTS_DIR / "degrees" """The one whose report comes from the set the draft describes rather than the draft.""" +QUESTIONS_ONLY = DRAFTS_DIR / "questions_only" +"""A second sheet to put beside another, for the folder holding more than one.""" + def _built(folder: Path, tmp_path: Path) -> Path: """A folder's document, frozen in `tmp_path` with its commands applied and checked.""" shutil.copytree(folder, tmp_path, dirs_exist_ok=True) assert CliRunner().invoke(cli, ["source", "add", "source.md"]).exit_code == 0 + draft_path = tmp_path / "source.draft.json" for entry in json.loads((folder / "commands.json").read_text()): - in2lambda.draft.execute(entry) + in2lambda.draft.execute(entry, draft_path) # Checked as well as built, so that what a folder's commands leave for the checks to # find is fixture data like the fields they write are. - in2lambda.draft.report.validate() - return tmp_path / "draft.json" + in2lambda.draft.report.validate(draft_path) + return draft_path def _expected_parts(fields: dict[str, Any], number: int) -> int: @@ -509,7 +513,7 @@ def test_validate_reports_what_the_pdf_generator_cannot_compile( and nothing there would tell a run with the toolchain installed from one without. """ monkeypatch.chdir(tmp_path) - _built(TWO_QUESTIONS, tmp_path) + draft_path = _built(TWO_QUESTIONS, tmp_path) replaced = CliRunner().invoke( cli, [ @@ -523,7 +527,7 @@ def test_validate_reports_what_the_pdf_generator_cannot_compile( ) assert replaced.exit_code == 0, replaced.output - report = in2lambda.draft.report.validate() + report = in2lambda.draft.report.validate(draft_path) refused = [ finding @@ -547,7 +551,7 @@ def test_a_replay_without_the_toolchain_keeps_what_validate_found_with_it( again - and the draft, untouched, would be called hand-edited. """ monkeypatch.chdir(tmp_path) - _built(TWO_QUESTIONS, tmp_path) + draft_path = _built(TWO_QUESTIONS, tmp_path) replaced = CliRunner().invoke( cli, [ @@ -562,7 +566,7 @@ def test_a_replay_without_the_toolchain_keeps_what_validate_found_with_it( assert replaced.exit_code == 0, replaced.output assert any( "the PDF generator cannot compile this" in finding["message"] - for finding in in2lambda.draft.report.validate() + for finding in in2lambda.draft.report.validate(draft_path) ) monkeypatch.setattr(pdf, "missing_tools", lambda: ["xelatex (how to install it)"]) @@ -576,11 +580,11 @@ def test_validate_says_what_to_install_rather_than_reporting_the_compile( ) -> None: """The toolchain is optional here as it is everywhere else: the rest still runs.""" monkeypatch.chdir(tmp_path) - _built(DEGREES, tmp_path) + draft_path = _built(DEGREES, tmp_path) monkeypatch.setattr(pdf, "missing_tools", lambda: ["pandoc (how to install it)"]) with pytest.warns(UserWarning, match="pandoc"): - report = in2lambda.draft.report.validate() + report = in2lambda.draft.report.validate(draft_path) # What reading the markdown found, and nothing about the compile that was not run: # the set is not compiled at all, so it has nothing to say about it either way. @@ -626,7 +630,7 @@ def test_build_refuses_a_draft_that_has_not_been_validated( shutil.copytree(TWO_QUESTIONS, tmp_path, dirs_exist_ok=True) assert CliRunner().invoke(cli, ["source", "add", "source.md"]).exit_code == 0 for entry in json.loads((TWO_QUESTIONS / "commands.json").read_text()): - in2lambda.draft.execute(entry) + in2lambda.draft.execute(entry, tmp_path / "source.draft.json") result = CliRunner().invoke(cli, ["build"]) @@ -701,6 +705,57 @@ def test_build_follows_the_report(folder: Path, tmp_path: Path, monkeypatch) -> } +def test_two_sources_in_one_folder_each_have_a_draft_of_their_own( + tmp_path: Path, monkeypatch +) -> None: + """A folder is a course's worth of sheets, and each is worked on without the others. + + Neither a fixture folder nor `_built` can say this: both are one source per folder, + and what is being checked is that the second sheet does not land on the first. + """ + monkeypatch.setenv("COLUMNS", "200") + monkeypatch.chdir(tmp_path) + runner = CliRunner() + # Named by its source for one sheet and by its draft for the other, since --draft + # takes either and both are what a folder of sheets gets worked on with. + sheets = {"a": (QUESTIONS_ONLY, "a.md"), "b": (TWO_QUESTIONS, "b.draft.json")} + for name, (folder, _) in sheets.items(): + shutil.copy(folder / "source.md", tmp_path / f"{name}.md") + assert runner.invoke(cli, ["source", "add", f"{name}.md"]).exit_code == 0 + + drafts = {name: tmp_path / f"{name}.draft.json" for name in sheets} + assert all(path.is_file() for path in drafts.values()) + + for name, (folder, named) in sheets.items(): + untouched = { + path: path.read_bytes() for other, path in drafts.items() if other != name + } + for entry in json.loads((folder / "commands.json").read_text()): + in2lambda.draft.execute(entry, drafts[name]) + validated = runner.invoke(cli, ["validate", "--draft", named]) + assert validated.exit_code == 0, validated.output + built = runner.invoke(cli, ["build", "--draft", named, "--out", name]) + assert built.exit_code == 0, built.output + + # Every other draft in the folder is the file it was, byte for byte, and the + # set written is the one this draft describes rather than whichever was last. + assert {path: path.read_bytes() for path in untouched} == untouched + fields = json.loads((folder / "expected.json").read_text()) + questions = Set.from_json(str(tmp_path / name / "set.zip")).questions + assert len(questions) == len([key for key in fields if QUESTION.fullmatch(key)]) + for number, question in enumerate(questions, start=1): + assert question.main_text == fields[f"q{number}.text"]["value"] + + # And with two of them there, a command that was not told which is refused rather + # than acting on whichever sorts first. + result = runner.invoke(cli, ["validate"]) + + assert result.exit_code != 0 + assert "a.draft.json" in result.output + assert "b.draft.json" in result.output + assert "--draft" in result.output + + def test_build_refuses_a_field_naming_an_image_that_is_not_there( tmp_path: Path, monkeypatch ) -> None: diff --git a/tests/test_source.py b/tests/test_source.py index 7e17b7e..ce19e9f 100644 --- a/tests/test_source.py +++ b/tests/test_source.py @@ -36,7 +36,7 @@ def test_source_add_finds_the_expected_blocks(folder: Path, tmp_path: Path) -> N result = CliRunner().invoke(cli, ["source", "add", str(_frozen(tmp_path))]) assert result.exit_code == 0, result.output - draft = json.loads((tmp_path / "draft.json").read_text()) + draft = json.loads((tmp_path / "source.draft.json").read_text()) assert draft["blocks"] == json.loads((folder / "expected.json").read_text()) markdown = (tmp_path / draft["source"]).read_bytes() assert draft["hash"] == f"sha256:{hashlib.sha256(markdown).hexdigest()}" @@ -51,24 +51,24 @@ def test_freezing_again_is_refused_once_the_source_has_changed( shutil.copy(_frozen(MARKDOWN), source) runner = CliRunner() assert runner.invoke(cli, ["source", "add", str(source)]).exit_code == 0 - frozen = (tmp_path / "draft.json").read_text() + frozen = (tmp_path / "source.draft.json").read_text() # Freezing the same file again changes nothing, so it is allowed. assert runner.invoke(cli, ["source", "add", str(source)]).exit_code == 0 - assert (tmp_path / "draft.json").read_text() == frozen + assert (tmp_path / "source.draft.json").read_text() == frozen source.write_text(f"{source.read_text()}\nAn afterthought.\n") result = runner.invoke(cli, ["source", "add", str(source)]) assert result.exit_code != 0 assert "--start-over" in result.output - assert (tmp_path / "draft.json").read_text() == frozen + assert (tmp_path / "source.draft.json").read_text() == frozen assert ( runner.invoke(cli, ["source", "add", str(source), "--start-over"]).exit_code == 0 ) - assert (tmp_path / "draft.json").read_text() != frozen + assert (tmp_path / "source.draft.json").read_text() != frozen def test_a_markdown_file_no_draft_claims_is_not_overwritten( @@ -86,7 +86,7 @@ def test_a_markdown_file_no_draft_claims_is_not_overwritten( assert result.exit_code != 0 assert "--start-over" in result.output assert (tmp_path / "source.md").read_text() == theirs - assert not (tmp_path / "draft.json").exists() + assert not (tmp_path / "source.draft.json").exists() # Saying to start over is saying to overwrite it. result = runner.invoke( @@ -95,7 +95,8 @@ def test_a_markdown_file_no_draft_claims_is_not_overwritten( assert result.exit_code == 0, result.output assert (tmp_path / "source.md").read_text() != theirs - assert json.loads((tmp_path / "draft.json").read_text())["source"] == "source.md" + draft = json.loads((tmp_path / "source.draft.json").read_text()) + assert draft["source"] == "source.md" def test_source_show_numbers_the_lines_and_names_the_blocks( @@ -117,7 +118,7 @@ def test_source_show_numbers_the_lines_and_names_the_blocks( assert len(lines) == len(markdown) # Every block's id sits on the line it starts at, and nothing else carries one. - blocks = json.loads((tmp_path / "draft.json").read_text())["blocks"] + blocks = json.loads((tmp_path / "source.draft.json").read_text())["blocks"] for block in blocks: assert lines[block["start"] - 1].split()[0] == block["id"] assert sum(bool(re.match(r" *b\d+ ", line)) for line in lines) == len(blocks) @@ -169,10 +170,10 @@ def test_source_show_refuses_once_the_source_has_changed( def test_a_draft_from_somewhere_else_is_refused( content: str, arguments: list[str], tmp_path: Path, monkeypatch ) -> None: - """A draft.json nothing here wrote is neither read from nor written over.""" + """A draft nothing here wrote is neither read from nor written over.""" monkeypatch.setenv("COLUMNS", "200") shutil.copy(_frozen(MARKDOWN), tmp_path / "source.md") - (tmp_path / "draft.json").write_text(content) + (tmp_path / "source.draft.json").write_text(content) monkeypatch.chdir(tmp_path) result = CliRunner().invoke(cli, arguments) @@ -180,7 +181,7 @@ def test_a_draft_from_somewhere_else_is_refused( assert result.exit_code != 0 assert "--start-over" in result.output assert isinstance(result.exception, SystemExit) - assert (tmp_path / "draft.json").read_text() == content + assert (tmp_path / "source.draft.json").read_text() == content def test_a_frozen_file_that_has_gone_is_a_message(tmp_path: Path, monkeypatch) -> None: @@ -210,4 +211,4 @@ def test_a_source_that_is_not_text_is_a_message(tmp_path: Path, monkeypatch) -> assert result.exit_code != 0 assert "UTF-8" in result.output assert isinstance(result.exception, SystemExit) - assert not (tmp_path / "draft.json").exists() + assert not (tmp_path / "source.draft.json").exists() diff --git a/tests/test_spec.py b/tests/test_spec.py index d1ebae9..88fe515 100644 --- a/tests/test_spec.py +++ b/tests/test_spec.py @@ -56,7 +56,7 @@ def test_a_spec_fills_in_the_fields_beside_it_and_replays( result = runner.invoke(cli, ["spec", "run", "spec.yaml", "--by", "tests"]) assert result.exit_code == 0, result.output - draft_path = tmp_path / "draft.json" + draft_path = tmp_path / "source.draft.json" draft = json.loads(draft_path.read_text()) assert draft["fields"] == json.loads((folder / "expected.json").read_text()) @@ -115,7 +115,7 @@ def test_a_spec_ignoring_a_block_the_draft_has_split_covers_both_halves( result = runner.invoke(cli, ["spec", "run", "spec.yaml", "--by", "tests"]) assert result.exit_code == 0, result.output - fields = json.loads((tmp_path / "draft.json").read_text())["fields"] + fields = json.loads((tmp_path / "source.draft.json").read_text())["fields"] assert fields["b1.ignore"]["ranges"] == [[1, 2]] # Both halves are within the lines the ignore field was written over, so neither is # reported as left out. @@ -130,7 +130,7 @@ def test_the_checks_read_the_fields_a_spec_wrote(tmp_path: Path, monkeypatch) -> assert run.exit_code == 0, run.output # The spec answers the second part where it stands and leaves the first unanswered; # renumbering the second question is the gap, which no command makes. - draft_path = tmp_path / "draft.json" + draft_path = tmp_path / "source.draft.json" draft = json.loads(draft_path.read_text()) for key in ("q2.text", "q2.solution"): draft["fields"][key.replace("q2", "q3")] = draft["fields"].pop(key) @@ -155,7 +155,7 @@ def test_a_replay_is_refused_once_the_spec_has_changed( monkeypatch.chdir(tmp_path) runner = _frozen(PREDICATES, tmp_path) assert runner.invoke(cli, ["spec", "run", "spec.yaml"]).exit_code == 0 - draft_path = tmp_path / "draft.json" + draft_path = tmp_path / "source.draft.json" written = draft_path.read_bytes() _edit(tmp_path / file) @@ -194,7 +194,7 @@ def test_running_an_edited_spec_again_is_refused( monkeypatch.chdir(tmp_path) runner = _frozen(PREDICATES, tmp_path) assert runner.invoke(cli, ["spec", "run", "spec.yaml"]).exit_code == 0 - draft_path = tmp_path / "draft.json" + draft_path = tmp_path / "source.draft.json" written = draft_path.read_bytes() edited = tmp_path / file @@ -227,7 +227,7 @@ def test_a_spec_run_over_a_log_holding_something_that_is_not_a_command_is_refuse monkeypatch.chdir(tmp_path) runner = _frozen(WORKED_EXAMPLE, tmp_path) assert runner.invoke(cli, ["spec", "run", "spec.yaml"]).exit_code == 0 - draft_path = tmp_path / "draft.json" + draft_path = tmp_path / "source.draft.json" draft = json.loads(draft_path.read_text()) draft["log"].append(entry) draft_path.write_text(json.dumps(draft)) @@ -252,7 +252,7 @@ def test_a_replay_is_refused_once_the_spec_has_gone( monkeypatch.chdir(tmp_path) runner = _frozen(PREDICATES, tmp_path) assert runner.invoke(cli, ["spec", "run", "spec.yaml"]).exit_code == 0 - written = (tmp_path / "draft.json").read_bytes() + written = (tmp_path / "source.draft.json").read_bytes() (tmp_path / file).unlink() result = runner.invoke(cli, ["draft", "replay"]) @@ -264,7 +264,7 @@ def test_a_replay_is_refused_once_the_spec_has_gone( assert file in result.output assert again.exit_code != 0 assert file in again.output - assert (tmp_path / "draft.json").read_bytes() == written + assert (tmp_path / "source.draft.json").read_bytes() == written @pytest.mark.parametrize( @@ -309,7 +309,7 @@ def test_a_spec_that_cannot_be_read_says_which_line_to_look_at( monkeypatch.setenv("COLUMNS", "200") monkeypatch.chdir(tmp_path) runner = _frozen(WORKED_EXAMPLE, tmp_path) - written = (tmp_path / "draft.json").read_bytes() + written = (tmp_path / "source.draft.json").read_bytes() (tmp_path / "spec.yaml").write_text(spec) result = runner.invoke(cli, ["spec", "run", "spec.yaml"]) @@ -319,7 +319,7 @@ def test_a_spec_that_cannot_be_read_says_which_line_to_look_at( assert line in result.output assert isinstance(result.exception, SystemExit) # Nothing is half written: the draft is as it was before the spec was run. - assert (tmp_path / "draft.json").read_bytes() == written + assert (tmp_path / "source.draft.json").read_bytes() == written @pytest.mark.parametrize( @@ -343,7 +343,7 @@ def test_a_spec_calling_a_predicate_nothing_holds_is_refused( monkeypatch.setenv("COLUMNS", "200") monkeypatch.chdir(tmp_path) runner = _frozen(PREDICATES, tmp_path) - written = (tmp_path / "draft.json").read_bytes() + written = (tmp_path / "source.draft.json").read_bytes() (tmp_path / "spec.yaml").write_text(spec) result = runner.invoke(cli, ["spec", "run", "spec.yaml"]) @@ -352,7 +352,7 @@ def test_a_spec_calling_a_predicate_nothing_holds_is_refused( for name in named: assert name in result.output assert isinstance(result.exception, SystemExit) - assert (tmp_path / "draft.json").read_bytes() == written + assert (tmp_path / "source.draft.json").read_bytes() == written def test_a_spec_that_is_not_there_is_refused_rather_than_left_to_the_file_system( @@ -361,7 +361,7 @@ def test_a_spec_that_is_not_there_is_refused_rather_than_left_to_the_file_system """The command line checks the path; a script calling this straight does not.""" monkeypatch.chdir(tmp_path) with pytest.raises(SourceError, match="nowhere.yaml"): - in2lambda.draft.spec_command("nowhere.yaml", "tests") + in2lambda.draft.spec_command("nowhere.yaml", "tests", "source.draft.json") def test_a_spec_saved_as_utf_16_is_read_like_any_other( @@ -376,7 +376,7 @@ def test_a_spec_saved_as_utf_16_is_read_like_any_other( result = runner.invoke(cli, ["spec", "run", "spec.yaml", "--by", "tests"]) assert result.exit_code == 0, result.output - fields = json.loads((tmp_path / "draft.json").read_text())["fields"] + fields = json.loads((tmp_path / "source.draft.json").read_text())["fields"] assert fields == json.loads((WORKED_EXAMPLE / "expected.json").read_text()) @@ -387,7 +387,7 @@ def test_a_spec_in_an_encoding_yaml_cannot_read_is_refused( monkeypatch.setenv("COLUMNS", "200") monkeypatch.chdir(tmp_path) runner = _frozen(WORKED_EXAMPLE, tmp_path) - written = (tmp_path / "draft.json").read_bytes() + written = (tmp_path / "source.draft.json").read_bytes() (tmp_path / "spec.yaml").write_bytes( "question: Header\nstrip: ['^Solución ']\nlayout: PartsOneSol\n".encode( "cp1252" @@ -399,7 +399,7 @@ def test_a_spec_in_an_encoding_yaml_cannot_read_is_refused( assert result.exit_code != 0 assert "not YAML" in result.output assert isinstance(result.exception, SystemExit) - assert (tmp_path / "draft.json").read_bytes() == written + assert (tmp_path / "source.draft.json").read_bytes() == written def test_running_a_spec_without_pyyaml_says_what_to_install(