diff --git a/CHANGELOG.md b/CHANGELOG.md index 059b7df..8d717b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,14 @@ - 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 `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. +- A draft is filled in by `in2lambda draft question add`, `in2lambda draft part add QUESTION` and `in2lambda draft question solution QUESTION`. Each takes `--text` to copy the wording out of the frozen source, as a block id such as `b3` or as lines such as `s10:14`, or `--literal TEXT` where the source does not say it in a form the field can take, which records the field as edited and written by layer 4 rather than 3. Question and part numbers are worked out from the fields already written rather than given, so a replay arrives at the same ids. `in2lambda draft split block BLOCK AT` cuts a block the parser made one of two things into `b3a` and `b3b`, so that each half can be quoted on its own. A command writing a field that is already written, or quoting lines another field was taken from, is refused: the first naming the field, the second naming both. - `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 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. - An export now names its images as they sit in `media/`: every markdown image reference a question holds - in its text, a part's, a worked solution, a final answer or an answer box's wording - is rewritten to the file name the image was carried under, so a document writing `![](figures/train.png)` exports as `![](train.png)` beside `media/train.png` and Lambda Feedback finds the figure where it looks for one. A file two questions use is carried once; where two different files are called the same, the second is named as Lambda Feedback's own exports name an image, `question_001__0001.png`. Reading an export back is unchanged, since an export already names its images this way. +- A draft can freeze more than one document, which is how a sheet written as a question file and a separate solutions file is drafted: `in2lambda source add questions.docx solutions.docx` freezes them as source 1 and source 2 of the one `questions.draft.json`, and `in2lambda source add solutions.docx --draft questions.docx` adds a file to a draft already written as its next source. Every block id and line range of a source after the first carries its number - `2/b3`, `2/s10:14`, with `1/b3` meaning the `b3` it always did - and a field quoted from one records which source it came from, so that the same line number in two documents is two different places. `in2lambda source show` prints each source under its number and its name. `in2lambda spec run` runs the spec over every source: the first is laid out as its `layout` says, and in any source after it the `question` selector picks out the marker written above each question's solutions while everything else the spec picks out is a solution, paired onto the questions and parts of the first the way `in2lambda convert -a` pairs an answers file. A draft now holds `sources`, a list of `{source, hash, blocks}` in the order they were frozen, rather than those three at the top level, so a draft written before this is refused as one nothing here wrote; `in2lambda source add --start-over` freezes the document again. In the Python API, `in2lambda.source.add` takes a list of files and the draft to freeze them into; `in2lambda.source.frozen` and `in2lambda.draft.apply` hand back and take the markdown of every source rather than of one; `in2lambda.spec.fields` takes one `(blocks, markdown)` pair per source in place of its `elements` and `markdown` arguments; and `in2lambda.spec.Field` carries the number of the source its ranges are lines of, which anything constructing one has to say. - 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/docs/source/spec.md b/docs/source/spec.md index 39067fc..a403ef6 100644 --- a/docs/source/spec.md +++ b/docs/source/spec.md @@ -1,11 +1,11 @@ # 📐 Specs A spec is a small YAML file saying which blocks of a document are questions, which are parts and -which are solutions. Running one fills in the draft beside the document, so that the wording of -every question comes out of the source rather than being retyped: +which are solutions. Running one fills in the draft beside the documents it was frozen from, so +that the wording of every question comes out of the source rather than being retyped: ```bash -$ in2lambda source add questions.docx +$ in2lambda source add questions.docx solutions.docx $ in2lambda spec run spec.yaml b6 (lines 12-13) is in no field and not marked ignore. ``` @@ -18,7 +18,7 @@ Running an edited one again is refused; freeze the document afresh and run it, w commands: ```bash -$ in2lambda source add --start-over questions.docx +$ in2lambda source add --start-over questions.docx solutions.docx $ in2lambda spec run spec.yaml ``` @@ -134,6 +134,43 @@ the solutions are, and what each of them answers. | `PartPartSolSol` | The parts come together and their solutions come after, in the same order. | | `PartsSepSol` | Every solution is at the end: the first answers the first part of the first question, and so on. | +## A separate solutions document + +Many sheets come as two files: the questions, and the solutions written separately from them. +Freeze both, in that order, and the draft holds them as source 1 and source 2. A file can be +added to a draft later just as well, which freezes it as the next source: + +```bash +$ in2lambda source add questions.docx +$ in2lambda source add solutions.docx +``` + +`in2lambda source show` then prints each source under its number and its name, and everything +that names a block or a line range says which source it means. `b3` and `s10:14` are the first +source's, as they have always been; `2/b3` and `2/s14:20` are the second's, and `1/b3` is `b3` +the long way round. A field quoted from a source after the first records that source's number +beside the lines it came from, since line 5 of the solutions is not line 5 of the sheet. + +The same spec runs over every source, and each selector matches within the source it is being +run over - `after Header text=Solutions` is about where a block sits in its own document. What +changes is what the selectors mean in a document of solutions, which is what `in2lambda convert +-a` makes of an answers file: + +- A block the **`question`** selector matches is a **marker** - the `Q2.` written above the + solutions to the second question. It answers nothing itself, is marked ignored, and sends what + follows it to that question's first slot. +- A block the **`part`** or the **`solution`** selector matches is a **solution**, and they take + the slots in order: each question's parts, or the question itself where it has none. +- The **`layout`** is the sheet's, and says nothing about the documents after it. Solutions + written separately come in the order the questions do, which is the `PartsSepSol` rule whatever + the sheet itself is laid out as. + +A solution past the last slot is reported as being in no field, like any other block the spec +made nothing of; one landing on a question the solutions before it have answered is refused, +saying that the field - `q2.solution`, say - is already written and that no command here writes +a field twice. `in2lambda draft field replace` changes the wording of one, and `in2lambda source +add --start-over` begins the draft again. + ## What it writes Each question is `q1`, `q2` and so on in the order they appear, and each of its parts `q1.p1`, diff --git a/in2lambda/draft/__init__.py b/in2lambda/draft/__init__.py index 58744c1..87b1f1e 100644 --- a/in2lambda/draft/__init__.py +++ b/in2lambda/draft/__init__.py @@ -21,6 +21,7 @@ SourceError, _digest, _elements, + _numbered, _require_conversion_tools, blocks, dedented, @@ -32,22 +33,31 @@ Command = dict[str, Any] """One entry of the log: ``{"command": name, "args": {...}, "by": who}``.""" -Handler = Callable[[dict[str, Any], str, dict[str, Any], str, str], str] -"""What a command does: `handler(draft, markdown, args, by, directory)`. +Handler = Callable[[dict[str, Any], list[str], dict[str, Any], str, str], str] +"""What a command does: `handler(draft, sources, args, by, directory)`. -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; the -directory is where the draft is, which is what a file a command names is beside. What -comes back is what the command wrote, named - the key of the field, or the block ids a -split made - which is what whoever ran it needs in the command after this one, or what -it left out where a command wrote a draft's worth of fields at once. +The frozen markdown of every source is passed in rather than read, in the order the +draft froze them, so that a handler quoting one by line range quotes the same text on a +replay as it did when it first ran; the directory is where the draft is, which is what a +file a command names is beside. What comes back is what the command wrote, named - the +key of the field, or the block ids a split made - which is what whoever ran it needs in +the command after this one, or what it left out where a command wrote a draft's worth of +fields at once. """ _HANDLERS: dict[str, Handler] = {} """Every command there is, by the name a log entry names it with.""" _RANGE = re.compile(r"s(\d+)(?::(\d+))?") -"""Lines of the frozen source, as ``s16`` for one of them or ``s10:14`` for several.""" +"""Lines of a frozen source, as ``s16`` for one of them or ``s10:14`` for several.""" + +_QUALIFIED = re.compile(r"(\d+)/([^/]*)") +"""A block id or a line range with the source it is in in front: ``2/b3``, ``2/s10:14``. + +One number and one slash: what follows the slash is an id or a range, never another +source in front of one. So ``1/2/b3`` matches nothing here and is refused as the address +it is not, rather than being read as source 1's ``2/b3`` and quoting the wrong document. +""" class MalformedCommand(SourceError): @@ -119,6 +129,7 @@ def record( ranges: list[list[int]], by: str, edited: bool = False, + source: int = 1, ) -> str: """Writes one field of a draft, with where it came from. @@ -135,15 +146,19 @@ def record( edited: Whether the value is something other than what the source says. A literal is the one thing a command writes that arrives edited; otherwise a field is edited when something later replaces what a command wrote. + source: Which of the draft's frozen sources the ranges are lines of, numbered + from 1. Written into the field only where it is not the first, so that a + draft of one document holds the fields it has always held. Returns: The key, so that a handler can hand back the field it wrote. Raises: AlreadyFilled: the field is written already, or the lines it was to be copied - from are where another field came from. Nothing here writes a field twice - - `field replace` changes the wording of one rather than writing it again - - so either is a mistake, and worth naming both halves of. + from are where another field of the same source came from. Nothing here + writes a field twice - `field replace` changes the wording of one rather + than writing it again - so either is a mistake, and worth naming both + halves of. """ if key in draft["fields"]: raise AlreadyFilled( @@ -152,6 +167,10 @@ def record( "in2lambda source add --start-over to begin the draft again." ) for filled, field in draft["fields"].items(): + # Only the fields quoted from the same source: line 12 of the solutions document + # is not line 12 of the sheet, and two fields quoting those quote different text. + if field.get("source", 1) != source: + continue # Each of the field's ranges on its own, so that the refusal names the one in # the way: a field edited by hand can be quoted from several, and the rest of # them may be lines nobody wants. @@ -168,6 +187,7 @@ def record( "ranges": ranges, "edited": edited, "by": by, + **({"source": source} if source != 1 else {}), } return key @@ -230,13 +250,13 @@ def _argument(args: dict[str, Any], name: str, command: str, kind: type = str) - def apply( - draft: dict[str, Any], markdown: str, entry: Any, directory: str = "." + draft: dict[str, Any], sources: list[str], entry: Any, directory: str = "." ) -> str: """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. + sources: The frozen markdown of each of the draft's sources, in its order. 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. @@ -255,7 +275,7 @@ def apply( 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." ) - written = handler(draft, markdown, entry["args"], entry["by"], directory) + written = handler(draft, sources, entry["args"], entry["by"], directory) # After the handler, so a command that was refused is not recorded as having run. draft["log"].append(entry) # A report is about the draft as it was, so the command that changes it takes the @@ -280,10 +300,10 @@ def execute(entry: Command, draft: str | Path) -> str: markdown that has changed since; or the command is unknown or refused. """ path = Path(draft) - found, markdown = frozen(path) + found, sources = 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)) + written = apply(found, sources, entry, str(path.parent)) save(path, found) return written @@ -308,18 +328,25 @@ def replay(draft: str | Path) -> None: """ _require_conversion_tools() path = Path(draft) - found, markdown = frozen(path) + found, sources = 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. + # the sources as the fields are, and copying them across would not check them. rebuilt: dict[str, Any] = { - "source": found["source"], - "hash": found["hash"], - "blocks": [block.to_dict() for block in blocks(markdown)], + "sources": [ + { + "source": source["source"], + "hash": source["hash"], + "blocks": [block.to_dict() for block in blocks(markdown, number)], + } + for number, (source, markdown) in enumerate( + zip(found["sources"], sources), start=1 + ) + ], "log": [], "fields": {}, } for entry in found["log"]: - apply(rebuilt, markdown, entry, str(path.parent)) + apply(rebuilt, sources, 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 @@ -340,54 +367,92 @@ def replay(draft: str | Path) -> None: ) -def _block(draft: dict[str, Any], block: str) -> dict[str, Any]: - """One block of the frozen source, given the draft has one of that id. +def _qualified(where: str) -> tuple[int, str]: + """Which source a block id or a line range is of, and the rest of it. + + ``2/b3`` is block b3 of the draft's second source and ``2/s10:14`` its lines 10 to + 14. Anything with no number in front of it is the first source's, which is how every + command written while a draft held one source still reads; ``1/b3`` says the same + thing the long way round. + + Anything else comes back as the first source's and under the name it was given, so + that whoever looks for it says what was asked for: ``1/2/b3`` names no block of any + source and is refused as ``1/2/b3``. + """ + if (named := _QUALIFIED.fullmatch(where)) is None: + return 1, where + return int(named[1]), named[2] + + +def _block(draft: dict[str, Any], block: str) -> tuple[int, dict[str, Any]]: + """Which source a block is of and the block itself, given a source has that id. Raises: - NoSuchBlock: it has not. + NoSuchBlock: none of them has, whether because no block is numbered that way or + because the draft has not got the source the id names. """ - if (found := next((b for b in draft["blocks"] if b["id"] == block), None)) is None: + source, name = _qualified(block) + wanted = _numbered(source, name) + found = next( + ( + held + for frozen_source in draft["sources"] + for held in frozen_source["blocks"] + if held["id"] == wanted + ), + None, + ) + if found is None: raise NoSuchBlock( f"There is no block {block} in the draft. Run in2lambda source show to see " "the ids of the blocks there are." ) - return found + return source, found def _lines( - draft: dict[str, Any], markdown: str, where: str, command: str -) -> tuple[int, int]: - """The first and last line of the source that a ``text`` argument names. + draft: dict[str, Any], sources: list[str], where: str, command: str +) -> tuple[int, int, int]: + """Which source a ``text`` argument names, and the first and last line of it. A block id says the lines are whatever that block spans, which is what an author reading `show` has in front of them; a range says them outright, for the part of a - block that is not worth splitting in two. + block that is not worth splitting in two. Either names a source after the first by + writing its number in front: ``2/b3``, ``2/s10:14``. Raises: - NoSuchBlock: it is neither a range nor a block the frozen source has. - NoSuchLines: it is a range of lines the source has not got. + NoSuchBlock: it is neither a range nor a block a frozen source has. + NoSuchLines: it is a range of lines the source it names has not got, or of a + source the draft has not got. """ + source, name = _qualified(where) # Block ids are b1, b2, b3a, so anything starting with an s was meant as a range and # is answered as one, rather than as a block of that name nobody was looking for. - if not where.startswith("s"): - found = _block(draft, where) - return found["start"], found["end"] - lines = len(markdown.splitlines()) - if (named := _RANGE.fullmatch(where)) is not None: + if not name.startswith("s"): + in_source, found = _block(draft, where) + return in_source, found["start"], found["end"] + if not 1 <= source <= len(sources): + raise NoSuchLines( + f"{command} was given {where}, and there is no source {source} in the draft: " + f"it holds {len(sources)}. Run in2lambda source add FILE to freeze another " + "beside them." + ) + lines = len(sources[source - 1].splitlines()) + if (named := _RANGE.fullmatch(name)) is not None: start, end = int(named[1]), int(named[2] or named[1]) if 1 <= start <= end <= lines: - return start, end + return source, start, end raise NoSuchLines( - f"{command} was given {where}, which is not lines of the frozen source: it has " + f"{command} was given {where}, which is not lines of source {source}: it has " f"{lines} lines, and they are named as s16, or as s10:14 for a range running " - "from an earlier line to a later. Run in2lambda source show to see them " - "numbered." + "from an earlier line to a later, with the source's number in front - 2/s10:14 " + "- for any but the first. Run in2lambda source show to see them numbered." ) def _fill( draft: dict[str, Any], - markdown: str, + sources: list[str], args: dict[str, Any], by: str, *, @@ -396,15 +461,15 @@ def _fill( ) -> str: """Writes the field a command fills, from its ``text`` or its ``literal``. - A field is copied out of the frozen source by ``text``, which is what freezing it - was for, or typed out as a ``literal`` where the source does not say it in a form - the field can take. A literal is nobody's quotation: it is layer 4, it has no range - behind it, and it arrives edited, because what it holds is not what the source says. + A field is copied out of a frozen source by ``text``, which is what freezing it was + for, or typed out as a ``literal`` where no source says it in a form the field can + take. A literal is nobody's quotation: it is layer 4, it has no range behind it, and + it arrives edited, because what it holds is not what any source says. Raises: MalformedCommand: the command gives both of them, or neither, or gives one of them as something other than text. - NoSuchBlock, NoSuchLines: its ``text`` is not somewhere in the source. + NoSuchBlock, NoSuchLines: its ``text`` is not somewhere in a source. AlreadyFilled: the field, or the lines it names, are taken. """ text, literal = args.get("text"), args.get("literal") @@ -431,19 +496,24 @@ def _fill( by=by, edited=True, ) - start, end = _lines(draft, markdown, _argument(args, "text", command), command) + source, start, end = _lines( + draft, sources, _argument(args, "text", command), command + ) return record( draft, key, - _quoted(draft, markdown, start, end), + _quoted(draft, sources[source - 1], source, start, end), layer=3, ranges=[[start, end]], by=by, + source=source, ) -def _quoted(draft: dict[str, Any], markdown: str, start: int, end: int) -> str: - """Lines of the frozen source as a field takes them. +def _quoted( + draft: dict[str, Any], markdown: str, source: int, start: int, end: int +) -> str: + """Lines of one frozen source as a field takes them. Lines quoted out of a list item are dedented by the item's own indentation, which is the markdown's rather than the author's; the range is still the source lines. @@ -454,7 +524,14 @@ def _quoted(draft: dict[str, Any], markdown: str, start: int, end: int) -> str: # Blocks do not overlap, so the one the first line falls in is the one the lines are # part of - a nested item among them included, since only a top-level item is a # block of its own and a range is how one of those is quoted. - block = next((b for b in draft["blocks"] if b["start"] <= start <= b["end"]), None) + block = next( + ( + held + for held in draft["sources"][source - 1]["blocks"] + if held["start"] <= start <= held["end"] + ), + None, + ) return dedented(text) if block and block["type"] == "list item" else text @@ -486,29 +563,39 @@ def _require_question(draft: dict[str, Any], question: str, command: str) -> Non @command("mark ignore") def _mark_ignore( - draft: dict[str, Any], markdown: str, args: dict[str, Any], by: str, directory: str + draft: dict[str, Any], + sources: list[str], + args: dict[str, Any], + by: str, + directory: str, ) -> str: - """Marks one block of the frozen source as nothing to take a question from.""" - block = _argument(args, "block", "mark ignore") - found = _block(draft, block) + """Marks one block of a frozen source as nothing to take a question from.""" + source, found = _block(draft, _argument(args, "block", "mark ignore")) + # The id as the draft holds it, so that a block named 1/b3 writes the b3.ignore a + # block named b3 does, and a block of a later source the 2/b3.ignore it is. return record( draft, - f"{block}.ignore", + f"{found['id']}.ignore", True, layer=3, ranges=[[found["start"], found["end"]]], by=by, + source=source, ) @command("question add") def _question_add( - draft: dict[str, Any], markdown: str, args: dict[str, Any], by: str, directory: str + draft: dict[str, Any], + sources: list[str], + args: dict[str, Any], + by: str, + directory: str, ) -> str: """Adds a question, taking the first number no question has taken.""" return _fill( draft, - markdown, + sources, args, by, command="question add", @@ -518,14 +605,18 @@ def _question_add( @command("part add") def _part_add( - draft: dict[str, Any], markdown: str, args: dict[str, Any], by: str, directory: str + draft: dict[str, Any], + sources: list[str], + args: dict[str, Any], + by: str, + directory: str, ) -> str: """Adds a part to a question, taking the first number that question has not.""" question = _argument(args, "question", "part add") _require_question(draft, question, "part add") return _fill( draft, - markdown, + sources, args, by, command="part add", @@ -535,14 +626,22 @@ def _part_add( @command("question solution") def _question_solution( - draft: dict[str, Any], markdown: str, args: dict[str, Any], by: str, directory: str + draft: dict[str, Any], + sources: list[str], + args: dict[str, Any], + by: str, + directory: str, ) -> str: - """Gives a question its worked solution, wherever in the source it is written.""" + """Gives a question its worked solution, wherever it is written. + + Which is often a document of its own: ``--text 2/b4`` is the block of the solutions + frozen beside the sheet that answers it. + """ question = _argument(args, "question", "question solution") _require_question(draft, question, "question solution") return _fill( draft, - markdown, + sources, args, by, command="question solution", @@ -552,7 +651,11 @@ def _question_solution( @command("field replace") def _field_replace( - draft: dict[str, Any], markdown: str, args: dict[str, Any], by: str, directory: str + draft: dict[str, Any], + sources: list[str], + args: dict[str, Any], + by: str, + directory: str, ) -> str: """Replaces one piece of wording inside a field that is written already. @@ -613,9 +716,13 @@ def _field_replace( @command("split block") def _split_block( - draft: dict[str, Any], markdown: str, args: dict[str, Any], by: str, directory: str + draft: dict[str, Any], + sources: list[str], + args: dict[str, Any], + by: str, + directory: str, ) -> str: - """Cuts one block of the frozen source in two, so each half can be named. + """Cuts one block of a frozen source in two, so each half can be named. A block is whatever the parser made of the source, which is sometimes two things: a question and the part under it, written with no blank line between them. The source @@ -624,19 +731,20 @@ def _split_block( """ block = _argument(args, "block", "split block") at = _argument(args, "at", "split block", int) - found = _block(draft, block) + source, found = _block(draft, block) if not found["start"] < at <= found["end"]: raise NoSuchLines( f"{block} is lines {found['start']}-{found['end']}, so it cannot be split " f"at line {at}: the line split at is the first line of the second half, and " "each half has to have a line in it." ) - index = draft["blocks"].index(found) - draft["blocks"][index : index + 1] = [ - {**found, "id": f"{block}a", "end": at - 1}, - {**found, "id": f"{block}b", "start": at}, + held = draft["sources"][source - 1]["blocks"] + index = held.index(found) + held[index : index + 1] = [ + {**found, "id": f"{found['id']}a", "end": at - 1}, + {**found, "id": f"{found['id']}b", "start": at}, ] - return f"{block}a and {block}b" + return f"{found['id']}a and {found['id']}b" def _file_as_run(directory: str, name: str, digest: str) -> bytes: @@ -745,9 +853,13 @@ def spec_command(name: str, by: str, draft: str | Path) -> Command: @command("spec run") def _spec_run( - draft: dict[str, Any], markdown: str, args: dict[str, Any], by: str, directory: str + draft: dict[str, Any], + sources: list[str], + args: dict[str, Any], + by: str, + directory: str, ) -> str: - """Fills in a draft's fields from a spec of selectors over the frozen source.""" + """Fills in a draft's fields from a spec of selectors over its frozen sources.""" _require_conversion_tools() # Every file every spec the log says has run was run with, rather than one named the # same way as this one: a spec that has been edited since leaves fields the log can @@ -768,20 +880,42 @@ def _spec_run( functions = in2lambda.spec.predicates( spec, raw[-1], _argument(args, "predicates", "spec run") ) - # The blocks the selectors run over are the ones the parser makes of the source, and + # The blocks the selectors run over are the ones the parser makes of the sources, and # a `split block` since has left the draft holding halves the parser never made. So # an ignored block is named and ranged from here rather than from the draft: the # field then spans the whole of what was ignored, and `uncovered`, which goes by the # lines a field was taken from, counts each half of a split block as covered by it. - elements = _elements(markdown) - fields, ignored = in2lambda.spec.fields(spec, elements, markdown, functions) + documents = [ + (_elements(markdown, number), markdown) + for number, markdown in enumerate(sources, start=1) + ] + fields, ignored = in2lambda.spec.fields(spec, documents, functions) for found in fields: - record(draft, found.key, found.value, layer=1, ranges=found.ranges, by=by) - lines = {block.id: [block.start, block.end] for block, _ in elements} + record( + draft, + found.key, + found.value, + layer=1, + ranges=found.ranges, + by=by, + source=found.source, + ) + lines = { + block.id: (number, [block.start, block.end]) + for number, (elements, _) in enumerate(documents, start=1) + for block, _ in elements + } # The field `mark ignore` writes, so that `uncovered` need not care which said so. for block_id in ignored: + source, span = lines[block_id] record( - draft, f"{block_id}.ignore", True, layer=1, ranges=[lines[block_id]], by=by + draft, + f"{block_id}.ignore", + True, + layer=1, + ranges=[span], + by=by, + source=source, ) # A spec writes a draft's worth of fields, so what it hands back is the other way # round: what it made nothing of, which is what is left for anyone to act on. Said diff --git a/in2lambda/draft/report.py b/in2lambda/draft/report.py index fbbf041..2412797 100644 --- a/in2lambda/draft/report.py +++ b/in2lambda/draft/report.py @@ -105,7 +105,7 @@ def _runs(lines: list[int]) -> list[list[int]]: def uncovered(draft: dict[str, Any]) -> list[Finding]: - """Blocks of the source that no field, and no `mark ignore`, accounts for. + """Blocks of the sources that no field, and no `mark ignore`, accounts for. A block partly quoted is reported for the rest of it: a question taken from the first line of a block leaves the other lines as much unaccounted for as a whole block would. @@ -117,35 +117,39 @@ def uncovered(draft: dict[str, Any]) -> list[Finding]: Returns: One :data:`Finding` per block with lines nothing has made anything of, in - document order. `in2lambda.spec` reports through this as well as the checks do: - what a spec run left out is the same question asked the moment it finishes. + document order and source by source. `in2lambda.spec` reports through this as + well as the checks do: what a spec run left out is the same question asked the + moment it finishes. """ + # By source as well as by line: line 12 of the solutions document is not line 12 of + # the sheet, and a field quoting the one accounts for nothing in the other. claimed = { - line + (field.get("source", 1), line) for field in draft["fields"].values() for start, end in field["ranges"] for line in range(start, end + 1) } found = [] - for block in draft["blocks"]: - free = _runs( - [ - line - for line in range(block["start"], block["end"] + 1) - if line not in claimed - ] - ) - if free: - found.append( - { - "check": "uncovered", - "level": ERROR, - "field": block["id"], - "ranges": free, - "message": f"{block['id']}{_where(free)} is in no field and not " - "marked ignore.", - } + for number, source in enumerate(draft["sources"], start=1): + for block in source["blocks"]: + free = _runs( + [ + line + for line in range(block["start"], block["end"] + 1) + if (number, line) not in claimed + ] ) + if free: + found.append( + { + "check": "uncovered", + "level": ERROR, + "field": block["id"], + "ranges": free, + "message": f"{block['id']}{_where(free)} is in no field and not " + "marked ignore.", + } + ) return found @@ -168,7 +172,10 @@ def _overlaps(draft: dict[str, Any]) -> list[Finding]: } for index, key in enumerate(keys) for other in keys[index + 1 :] - if overlapping(fields[key]["ranges"], fields[other]["ranges"]) + # Of the same source, since the same lines of two documents are not the same + # lines, as `in2lambda.draft.record` compares them. + if fields[key].get("source", 1) == fields[other].get("source", 1) + and overlapping(fields[key]["ranges"], fields[other]["ranges"]) ] @@ -273,7 +280,9 @@ def checks(draft: dict[str, Any]) -> list[Finding]: Examples: >>> from in2lambda.draft.report import checks >>> draft = { - ... "blocks": [{"id": "b1", "type": "paragraph", "start": 1, "end": 2}], + ... "sources": [ + ... {"blocks": [{"id": "b1", "type": "paragraph", "start": 1, "end": 2}]} + ... ], ... "fields": {}, ... } >>> [finding["message"] for finding in checks(draft)] @@ -410,8 +419,8 @@ def validate(draft: str | Path) -> list[Finding]: Raises: 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 + SourceUnreadable: a markdown the draft names has moved, or is not text. + DraftExists: a markdown has changed since the draft was written from it, so the lines the report named would not be the lines it was written about. Warns: diff --git a/in2lambda/main.py b/in2lambda/main.py index a1b4540..3452411 100644 --- a/in2lambda/main.py +++ b/in2lambda/main.py @@ -241,30 +241,50 @@ def convert( @cli.group("source") def source_group() -> None: - """Freezes a source document, so its text can be quoted by line range.""" + """Freezes the source documents of a draft, so their text can be quoted by line range.""" + + +_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("add") -@click.argument("file", type=click.Path(exists=True, dir_okay=False, resolve_path=True)) +@click.argument( + "files", + nargs=-1, + required=True, + type=click.Path(exists=True, dir_okay=False, resolve_path=True), +) @click.option( "--start-over", is_flag=True, - help="Freeze FILE again, discarding the draft already there.", + help="Freeze FILES again, discarding the draft already there.", ) -def source_add(file: str, start_over: bool) -> None: - """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( +@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]", + help="The draft to freeze FILES into, as FILE.draft.json or a source already in " + "it. [default: the draft named after the first FILE]", ) -"""Which draft a command is about, since a folder of sheets holds one draft each.""" +def source_add(files: tuple[str, ...], start_over: bool, draft: Optional[str]) -> None: + """Converts each FILE to markdown and records its blocks in a draft beside them. + + The draft is named after the first file - questions.draft.json - unless --draft + says which one to freeze into. A sheet written as two documents, the questions in + one file and the solutions in another, is frozen as both, in that order: in2lambda + source add questions.docx solutions.docx. A file can be added to a draft already + written as its next source, by naming that draft with --draft. The first source's + blocks and lines are named b3 and s10:14; every source after it carries its number + - 2/b3, 2/s10:14. + """ + with _message_not_traceback(): + written = in2lambda.source.add(list(files), start_over, draft) + click.echo(f"Wrote {written}") @source_group.command("show") @@ -298,8 +318,9 @@ def _text_or_literal(command: Callable[..., None]) -> Callable[..., None]: ), click.option( "--text", - help="Where in the frozen source the text is: a block id such as b3, or " - "lines such as s10:14. Run in2lambda source show to see both.", + help="Where in a frozen source the text is: a block id such as b3, or " + "lines such as s10:14, with the source's number in front - 2/b3, 2/s10:14 " + "- for any but the first. Run in2lambda source show to see both.", ), ): command = option(command) diff --git a/in2lambda/source/__init__.py b/in2lambda/source/__init__.py index 3b267ae..6d17258 100644 --- a/in2lambda/source/__init__.py +++ b/in2lambda/source/__init__.py @@ -1,4 +1,4 @@ -"""Freezes a source document, so that its text can be quoted by line range. +"""Freezes the source documents of a draft, so their text can be quoted by line range. 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 @@ -9,6 +9,12 @@ 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. +A draft freezes several documents where a sheet is written that way - the questions in +one file and the solutions in another. They are numbered in the order they were frozen, +and a block id or a line range of any source after the first carries its number: +``2/b3``, ``2/s10:14``. The first source's are written plain, as they were when a draft +held one. + 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. """ @@ -30,16 +36,21 @@ def _field_fault(field: Any) -> str: """What is wrong with the shape of one field of a draft, or "" if nothing is. - ``ranges`` and ``value`` are what is looked for, because they are the parts of a - field anything here reads: `in2lambda.draft.record` compares the lines a command is - quoting against the lines every field was taken from, and + ``ranges``, ``source`` and ``value`` are what is looked for, because they are the + parts of a field anything here reads: `in2lambda.draft.record` compares the lines a + command is quoting against the lines every field of that source was taken from, and `in2lambda.draft.report.checks` reports a field whose value says nothing. Only whether there is a value is asked, since the checks look at one as a string or not at all. The layer, whether it was edited and by whom are written and read back whole, and an edit to any of them is what a replay catches byte for byte. + + A field quoted from the first source has no ``source`` in it, which is what every + field of a draft frozen from one document looks like. """ if not isinstance(field, dict): return "is not an object" + if not isinstance(field.get("source", 1), int): + return f"has source {field['source']!r} rather than a number" if "ranges" not in field: return "has no ranges" if not isinstance(field["ranges"], list) or not all( @@ -54,9 +65,15 @@ def _field_fault(field: Any) -> str: return "" -_FIELDS = ("source", "hash", "blocks", "log", "fields") +_FIELDS = ("sources", "log", "fields") """What a draft has in it, and so what one has to have for anything here to read it. +``sources`` is one ``{source, hash, blocks}`` per frozen document, in the order they +were frozen. A draft written before there could be more than one holds those three at +the top level instead, and is refused as one nothing here wrote rather than read as a +draft of one source: the ids and ranges in it were written against a shape that has +gone. Freezing the document again is the way through, which is what the refusal says. + 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 @@ -307,6 +324,7 @@ def _draft(path: Path) -> dict[str, Any]: # 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 ( + ("sources", list, "a list"), ("log", list, "a list"), ("fields", dict, "an object"), ): @@ -315,6 +333,26 @@ def _draft(path: Path) -> dict[str, Any]: f"{path} is not a draft anything here wrote: its {field} is " f"{draft[field]!r} rather than {called}. {advice}" ) + if not draft["sources"]: + # Nothing here writes one: `add` freezes a file or refuses. So an empty list is + # a hand-edited draft, and every id and range in it names a document that is no + # longer there - which is what the rest of this package would trip over rather + # than report, since it takes the first source as the one an unqualified id is + # of. + raise DraftUnreadable( + f"{path} is not a draft anything here wrote: its sources is empty, so " + f"there is no frozen document for its fields to have been quoted out of. " + f"{advice}" + ) + for source in draft["sources"]: + if not isinstance(source, dict) or not all( + key in source for key in ("source", "hash", "blocks") + ): + raise DraftUnreadable( + f"{path} is not a draft anything here wrote: its sources holds " + f"{source!r} rather than a frozen document, its hash and its blocks. " + f"{advice}" + ) for key, field in draft["fields"].items(): if fault := _field_fault(field): raise DraftUnreadable( @@ -340,32 +378,38 @@ def save(path: Path, draft: dict[str, Any]) -> None: path.write_bytes(serialise(draft)) -def frozen(draft: str | Path) -> tuple[dict[str, Any], str]: - """A draft and the markdown it was written from, still unmoved. +def frozen(draft: str | Path) -> tuple[dict[str, Any], list[str]]: + """A draft and the markdown of every source it names, still unmoved. Args: draft: The path of the draft to read. Returns: - The draft, and the text of the markdown it names. + The draft, and the text of each markdown it names, in the order it froze them: + the first is source 1, whose blocks and lines are the ones named unqualified. Raises: 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. + SourceUnreadable: a markdown the draft names has moved, or is not text. + DraftExists: a 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. The + refusal names the file that changed, since a draft may hold several. """ path = Path(draft) found = _draft(path) - raw, markdown = _source(path.parent / found["source"]) - if _digest(raw) != found["hash"]: - raise DraftExists( - 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 found, markdown + texts = [] + for source in found["sources"]: + raw, markdown = _source(path.parent / source["source"]) + if _digest(raw) != source["hash"]: + raise DraftExists( + f"{source['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." + ) + texts.append(markdown) + return found, texts @dataclass @@ -386,11 +430,23 @@ def to_dict(self) -> dict[str, str | int]: return {"id": self.id, "type": self.type, "start": self.start, "end": self.end} -def blocks(markdown: str) -> list[Block]: +def _numbered(source: int, name: str) -> str: + """A block id or a line range as the source it names something in writes it. + + The first source writes them plain - ``b3``, ``s10:14`` - which is what everything + wrote when a draft held one source; every source after it puts its number and a + slash in front, so that an id or a range says which document it is of. + """ + return name if source == 1 else f"{source}/{name}" + + +def blocks(markdown: str, source: int = 1) -> list[Block]: r"""Every top-level block of some markdown, in the order it is written. Args: markdown: A document in the dialect :func:`add` freezes to. + source: Which source of a draft the markdown is, which numbers the ids of any + but the first: a draft's second source has ``2/b1``, ``2/b2``. Returns: One :class:`Block` per block, numbered ``b1`` onwards. The blocks do not @@ -402,8 +458,10 @@ def blocks(markdown: str) -> list[Block]: >>> from in2lambda.source import blocks >>> blocks("# Title\n\nSome words.\n") [Block(id='b1', type='heading', start=1, end=1), Block(id='b2', type='paragraph', start=3, end=3)] + >>> [block.id for block in blocks("# Solutions\n", 2)] + ['2/b1'] """ - return [block for block, _ in _elements(markdown)] + return [block for block, _ in _elements(markdown, source)] _MARKER = re.compile(r" *(?:[-+*]|\(?(?:\d+|[ivxlcdm]+|[IVXLCDM]+|[A-Za-z])[.)]) {1,4}") @@ -448,7 +506,7 @@ def dedented(text: str) -> str: ) -def _elements(markdown: str) -> list[tuple[Block, Any]]: +def _elements(markdown: str, source: int = 1) -> list[tuple[Block, Any]]: """Every block of some markdown, each beside the panflute element it was taken from. A selector matches on what the element is - its type, its heading level, the text it @@ -467,7 +525,7 @@ def _elements(markdown: str) -> list[tuple[Block, Any]]: # nor past the end of the document. limits = [start - 1 for _, start, _, _ in found[1:]] + [len(markdown.splitlines())] return [ - (Block(f"b{number}", kind, start, min(end, limit)), element) + (Block(_numbered(source, f"b{number}"), kind, start, min(end, limit)), element) for number, ((kind, start, end, element), limit) in enumerate( zip(found, limits), 1 ) @@ -543,80 +601,106 @@ def _range(element) -> tuple[int, int]: # type: ignore[no-untyped-def] ) -def add(file: str, start_over: bool = False) -> Path: - """Freezes a document and writes the draft of it beside the file. +def add( + files: list[str], start_over: bool = False, into: str | Path | None = None +) -> Path: + """Freezes one or more documents and writes the draft of them beside the files. 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 ``FILE.draft.json``, so that whatever quotes the source by line range can + written to ``FILE.draft.json``, so that whatever quotes a source by line range can tell that the lines it was given still say what they said. + The files are numbered in the order they are given, and a file already frozen into + the draft beside them is checked against the hash it was frozen at rather than + frozen afresh. So a sheet and the solutions written separately from it are frozen + together, or the solutions added later as the next source; either way the questions + keep the ids and the lines the commands so far were run against. + Args: - file: The document to freeze, as .docx, .tex or markdown. - start_over: Freeze the file again, discarding whatever is already there. + files: The documents to freeze, as .docx, .tex or markdown, all in the one + directory, in the order they are to be numbered in. + start_over: Freeze them again, discarding whatever draft is already there. + into: The draft to freeze them into, as its own path or that of a source + already in it, and None for the one named after the first file. A file + frozen into a draft already written is a source of that draft rather than + the first source of one of its own. Returns: 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 beside the file that nothing here wrote, + SourceError: the files are not all in one directory, so there is no one draft + beside them to freeze them into. + SourceUnreadable: a file is markdown, but not UTF-8 text. + DraftUnreadable: there is a draft beside the files 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 + DraftExists: a source has changed since it was frozen, or a markdown would overwrite a file that no draft claims. Neither happens with `start_over`. """ _require_conversion_tools() - source = Path(file) - if file_type(file) == "markdown": - raw, markdown = _source(source) - frozen_path = source - else: - raw = _pandoc(file, _MARKDOWN) - markdown = raw.decode("utf-8") - frozen_path = source.with_suffix(".md") - draft = draft_of(source) - 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] = {} - # And what the checks found about it, which still holds for the same reason: this - # writes the draft back as it was, so a report of it is a report of what is saved. - report: Any = None - # The blocks a draft already here has, which are not always what parsing the - # markdown gives: `split block` cuts one in two, and parsing again would undo that - # while keeping the log entry saying it happened, leaving the ids the fields were - # written against naming nothing. - found: Optional[list[Any]] = None - - if not start_over: - if draft.is_file(): - existing = _draft(draft) - if existing["hash"] != digest: + paths = [Path(file) for file in files] + if len({path.parent for path in paths}) != 1: + raise SourceError( + "A draft sits beside the documents it is of, so the files frozen into one " + f"are all in the same directory: {', '.join(files)}." + ) + draft = draft_of(paths[0] if into is None else into) + + # What a draft already here has been told, which freezing the same files again does + # not undo: the commands were run against these very lines, so they still hold. The + # blocks are kept for the same reason, and are not always what parsing the markdown + # gives: `split block` cuts one in two, and parsing again would undo that while + # keeping the log entry saying it happened, leaving the ids the fields were written + # against naming nothing. --start-over is the way to throw all of it away, and the + # only one. + existing: dict[str, Any] = {"sources": [], "log": [], "fields": {}} + if not start_over and draft.is_file(): + existing = _draft(draft) + sources: list[dict[str, Any]] = list(existing["sources"]) + + # Every file is read and parsed before any is written: a parse that fails half way + # through would otherwise leave a markdown there with no draft claiming it, and the + # next run would refuse to touch a file this one wrote. + converted: list[tuple[Path, bytes]] = [] + for path in paths: + if file_type(str(path)) == "markdown": + raw, markdown = _source(path) + frozen_path = path + else: + raw = _pandoc(str(path), _MARKDOWN) + markdown = raw.decode("utf-8") + frozen_path = path.with_suffix(".md") + converted.append((frozen_path, raw)) + digest = _digest(raw) + if found := next( + (source for source in sources if source["source"] == frozen_path.name), None + ): + if found["hash"] != digest: raise DraftExists( - 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." + f"{path.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(): + continue + if not start_over and frozen_path != path and frozen_path.exists(): raise DraftExists( 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." ) + sources.append( + { + "source": frozen_path.name, + "hash": digest, + "blocks": [ + block.to_dict() for block in blocks(markdown, len(sources) + 1) + ], + } + ) - # Before either file is written: a parse that fails half way through would - # otherwise leave the markdown there with no draft claiming it, and the next run - # would refuse to touch a file this one wrote. - if found is None: - found = [block.to_dict() for block in blocks(markdown)] - - if frozen_path != source: + for frozen_path, raw in converted: # 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_path.write_bytes(raw) @@ -625,12 +709,19 @@ def add(file: str, start_over: bool = False) -> Path: save( draft, { - "source": frozen_path.name, - "hash": digest, - "blocks": found, - "log": log, - "fields": fields, - **({"report": report} if report is not None else {}), + "sources": sources, + "log": existing["log"], + "fields": existing["fields"], + # What the checks found still holds where every file named was frozen + # already, since then this writes the draft back as it was. A source + # appended is a document the checks have never seen, every block of which + # is in no field, so the report is dropped as any change to a draft drops + # it - and `build`, which is gated on one, asks for the checks again. + **( + {"report": existing["report"]} + if existing.get("report") is not None and sources == existing["sources"] + else {} + ), }, ) return draft @@ -643,26 +734,36 @@ def show(draft: str | Path) -> str: 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. + One line per line of each frozen markdown: the id of the block starting there, + where one does, then the line number and the line itself. A draft of more than + one source heads each with its number and its name, since the line numbers + start again at 1 in every one of them. Raises: 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 + SourceUnreadable: a markdown the draft names has moved, or is not text. + DraftExists: a markdown has changed since the draft was written from it, so the ids would be printed against lines they are not the ids of. """ # 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. - found, markdown = frozen(draft) + found, sources = frozen(draft) - 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))) - return "\n".join( - f"{ids.get(number, ''):>{margin}} {number:>{numbers}} {line}".rstrip() - for number, line in enumerate(lines, start=1) - ) + printed = [] + for number, (source, markdown) in enumerate( + zip(found["sources"], sources), start=1 + ): + ids = {block["start"]: block["id"] for block in source["blocks"]} + lines = markdown.splitlines() + margin = max((len(block_id) for block_id in ids.values()), default=0) + numbers = len(str(len(lines))) + body = "\n".join( + f"{ids.get(line_number, ''):>{margin}} {line_number:>{numbers}} {line}".rstrip() + for line_number, line in enumerate(lines, start=1) + ) + printed.append( + f"Source {number}: {source['source']}\n{body}" if len(sources) > 1 else body + ) + return "\n\n".join(printed) diff --git a/in2lambda/spec/__init__.py b/in2lambda/spec/__init__.py index 5e3dfc4..9eff8c4 100644 --- a/in2lambda/spec/__init__.py +++ b/in2lambda/spec/__init__.py @@ -22,6 +22,12 @@ copied from them here. What comes out is one field per question, part and solution, so that a draft written by a spec says the same things as a draft written by hand. +The same spec runs over every source a draft has frozen. The first is the sheet, laid +out as the layout says; a source after it is a document of solutions written separately, +and its solutions are paired onto the questions of the sheet the way ``in2lambda convert +-a`` pairs an answers file - the ``question`` selector picking out the marker above each +question's solutions rather than a question. + Reading a spec needs pyyaml, which only the ``convert`` extra installs; the command that calls this checks for it first, along with pandoc and panflute. """ @@ -156,6 +162,8 @@ class Field(NamedTuple): key: str value: str ranges: list[list[int]] + source: int + """Which of the draft's frozen sources the ranges are lines of, numbered from 1.""" def _attribute(name: str, element: Any, pf: Any) -> Optional[str]: @@ -445,11 +453,12 @@ def _stems(roles: list[Optional[str]]) -> list[Optional[str]]: return stems -def _slots(roles: list[Optional[str]], stems: list[Optional[str]]) -> list[str]: - """What a separate section of solutions answers, one after another. +def _slots(roles: list[Optional[str]], stems: list[Optional[str]]) -> list[list[str]]: + """What a separate section of solutions answers, question by question. - Each question with parts is its parts; each question without is itself. That is the - order the solutions in a PartsSepSol document are written in. + Each question with parts is answered part by part; each question without is answered + itself. That is the order the solutions in a PartsSepSol document are written in, and + the order a document of solutions written beside the sheet is written in. """ questions: list[tuple[str, list[str]]] = [] for index, role in enumerate(roles): @@ -457,13 +466,31 @@ def _slots(roles: list[Optional[str]], stems: list[Optional[str]]) -> list[str]: questions.append((str(stems[index]), [])) elif role == "part" and questions and stems[index]: questions[-1][1].append(str(stems[index])) - return [slot for stem, parts in questions for slot in (parts or [stem])] + return [parts or [stem] for stem, parts in questions] + + +def _keys(layout: str, roles: list[list[Optional[str]]]) -> list[list[Optional[str]]]: + """The field each block's text goes in, source by source. + + The first source is the sheet, and the layout says which solution written in it + answers what. Every source after it is a document of solutions written separately + from the sheet, and is paired onto the sheet's questions rather than laid out. + """ + stems = _stems(roles[0]) + slots = _slots(roles[0], stems) + return [_laid_out(layout, roles[0], stems, slots)] + [ + _answers(later, slots) for later in roles[1:] + ] -def _keys(layout: str, roles: list[Optional[str]]) -> list[Optional[str]]: - """The field each block's text goes in, or None where the layout puts it in none.""" - stems = _stems(roles) - separate = iter(_slots(roles, stems)) +def _laid_out( + layout: str, + roles: list[Optional[str]], + stems: list[Optional[str]], + slots: list[list[str]], +) -> list[Optional[str]]: + """The field each block of the sheet goes in, or None where the layout puts it in none.""" + separate = iter([slot for question in slots for slot in question]) keys: list[Optional[str]] = [None] * len(roles) question: Optional[str] = None parts: list[str] = [] @@ -493,31 +520,54 @@ def _keys(layout: str, roles: list[Optional[str]]) -> list[Optional[str]]: return keys -def fields( - spec: Spec, - elements: list[tuple[Block, Any]], - markdown: str, - functions: Optional[dict[str, Callable[[Any], Any]]] = None, -) -> tuple[list[Field], list[str]]: - """What a spec makes of a document: its fields, and the blocks it says to ignore. - - Args: - spec: The spec to run, as :func:`load` read it. - elements: Every block of the frozen markdown beside the element it is, as - :func:`in2lambda.source._elements` gives them. - markdown: The frozen markdown itself, which the values are quoted out of. - functions: The predicates its selectors call, as :func:`predicates` bound them, - and None for a spec that calls none. +def _answers(roles: list[Optional[str]], slots: list[list[str]]) -> list[Optional[str]]: + """The field each block of a separate document of solutions goes in. - Returns: - One :class:`Field` per question, part and solution the spec found, and the ids - of the blocks its ``ignore`` selector matched. A block that is neither is in - neither, which is what a coverage report is about. + The pairing `in2lambda convert -a` does, in the words of a spec. A block the + ``question`` selector matches is a marker - the ``Q2.`` written above the solutions + to the second question - which answers nothing itself and sends what follows it to + that question's first slot. Everything else the spec picks out, whether its ``part`` + selector matched or its ``solution`` one, is a solution, and they take the slots in + order: each question's parts, or the question itself where it has none. A solution + past the last slot is in no field, and one landing on a question the solutions + before it have answered is refused by `in2lambda.draft.record`, naming both. """ - import panflute as pf + ordered = [ + (number, slot) for number, question in enumerate(slots) for slot in question + ] + keys: list[Optional[str]] = [None] * len(roles) + at, markers = 0, 0 + for index, role in enumerate(roles): + if role == "question": + markers += 1 + at = next( + ( + position + for position, (number, _) in enumerate(ordered) + if number == markers - 1 + ), + len(ordered), + ) + elif role in ("part", "solution") and at < len(ordered): + keys[index] = f"{ordered[at][1]}.solution" + at += 1 + return keys + +def _roles( + spec: Spec, + elements: list[tuple[Block, Any]], + pf: Any, + functions: Optional[dict[str, Callable[[Any], Any]]], +) -> list[Optional[str]]: + """What the spec says each block of one source is, or None where it says nothing. + + A selector matches within the source it is run over - ``after Header text=Solutions`` + is about where a block sits in its own document - so each source is decided about on + its own, whatever the sources before it hold. + """ found = [element for _, element in elements] - roles = [ + return [ next( ( role @@ -529,15 +579,55 @@ def fields( ) for index in range(len(found)) ] - lines = markdown.splitlines() - return ( - [ - Field(key, _stripped(spec, lines, block), [[block.start, block.end]]) - for (block, _), key in zip(elements, _keys(spec.layout, roles)) + + +def fields( + spec: Spec, + documents: list[tuple[list[tuple[Block, Any]], str]], + functions: Optional[dict[str, Callable[[Any], Any]]] = None, +) -> tuple[list[Field], list[str]]: + """What a spec makes of a draft's sources: its fields, and the blocks to ignore. + + Args: + spec: The spec to run, as :func:`load` read it. + documents: Every source of the draft, in the order it froze them: each as the + blocks of its frozen markdown beside the element each is, as + :func:`in2lambda.source._elements` gives them, and the markdown itself, + which the values are quoted out of. + functions: The predicates its selectors call, as :func:`predicates` bound them, + and None for a spec that calls none. + + Returns: + One :class:`Field` per question, part and solution the spec found, each saying + which source it came from, and the ids of the blocks to mark as ignored: what + the ``ignore`` selector matched, and the markers of a separate document of + solutions, which say which question the solutions under them answer and nothing + else. A block in neither is in neither, which is what a coverage report is about. + """ + import panflute as pf + + roles = [_roles(spec, elements, pf, functions) for elements, _ in documents] + written = [] + for number, ((elements, markdown), keys) in enumerate( + zip(documents, _keys(spec.layout, roles)), start=1 + ): + lines = markdown.splitlines() + written += [ + Field( + key, _stripped(spec, lines, block), [[block.start, block.end]], number + ) + for (block, _), key in zip(elements, keys) if key is not None - ], - [block.id for (block, _), role in zip(elements, roles) if role == "ignore"], - ) + ] + ignored = [ + block.id + for number, ((elements, _), found) in enumerate(zip(documents, roles), start=1) + for (block, _), role in zip(elements, found) + # A marker is ignored rather than quoted: what it says is which question the + # solutions under it answer, and that question's text came from the sheet. + if role == "ignore" or (number > 1 and role == "question") + ] + return written, ignored def _stripped(spec: Spec, lines: list[str], block: Block) -> str: diff --git a/tests/conftest.py b/tests/conftest.py index 0ae8848..a882ab0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -47,6 +47,16 @@ """Every spec folder, so that covering another kind of document is a folder and no code.""" +def frozen_sources(folder: Path) -> list[str]: + """The documents a draft or spec folder freezes, in the order they are its sources. + + A folder holding a ``solutions.md`` beside its ``source.md`` is a sheet written as + two documents - the questions, and the worked solutions separately - and freezes as + two sources, so covering that is a second file in a folder rather than a test. + """ + return [name for name in ("source.md", "solutions.md") if (folder / name).is_file()] + + @pytest.fixture def without_node(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: """Runs the test as if Node.js, which KaTeX is rendered with, were not installed. diff --git a/tests/fixtures/drafts/README.md b/tests/fixtures/drafts/README.md index 9271bb5..ab491b2 100644 --- a/tests/fixtures/drafts/README.md +++ b/tests/fixtures/drafts/README.md @@ -5,7 +5,9 @@ of it, the `expected.json` those commands should leave in the draft's `fields`, `report.json` that `in2lambda validate` should then find in it - a folder with no `report.json` is a draft with nothing wrong with it, and one whose findings are all at level `warning` is a draft `in2lambda build` says them over and exports anyway, where a `level` of `error` refuses -it. The test freezes the source, applies each command, checks +it. A folder holding a `solutions.md` beside its `source.md` is a sheet written as two +documents, and the solutions are frozen as the draft's second source, whose blocks and lines +are named `2/b3` and `2/s14`. The test freezes the sources, applies each command, checks the draft over, compares the fields and the report, 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. @@ -43,6 +45,13 @@ no parts is what the export has to write out as an empty part rather than as the `questions_only` is the sheet half the corpus is - a title, two questions, two parts under the first of them and no solution written anywhere - so every one of its findings is a warning, and it is the folder that says a draft with nothing answering it is still exported. +`solutions_in_a_second_source` is the other half of that sheet, and the only folder with two +documents in it: the questions in `source.md` and the worked solutions in `solutions.md`, which +the spec pairs onto them the way `in2lambda convert -a` pairs an answers file, so every solution +field is quoted from source 2 while the questions are quoted from source 1 - lines 5 and 7 of +each, which is why the fields say which source they came from. The note at the end of the +solutions is what the spec makes nothing of, and `mark ignore 2/b7` is a block of the second +source named as one. `degrees` writes `^\circ` into the maths of both a question and the one worked solution answering its two parts, and is the one folder whose report comes from `in2lambda.validation` over the set the draft describes rather than from the checks over the draft itself: the solution is reported diff --git a/tests/fixtures/drafts/solutions_in_a_second_source/commands.json b/tests/fixtures/drafts/solutions_in_a_second_source/commands.json new file mode 100644 index 0000000..0154256 --- /dev/null +++ b/tests/fixtures/drafts/solutions_in_a_second_source/commands.json @@ -0,0 +1,17 @@ +[ + { + "args": { + "hash": "sha256:e6f8f7aff47a4ea67756f0084709ba055209351bcab9607538a50ed440797907", + "spec": "spec.yaml" + }, + "by": "tests", + "command": "spec run" + }, + { + "args": { + "block": "2/b7" + }, + "by": "tests", + "command": "mark ignore" + } +] diff --git a/tests/fixtures/drafts/solutions_in_a_second_source/expected.json b/tests/fixtures/drafts/solutions_in_a_second_source/expected.json new file mode 100644 index 0000000..21c2580 --- /dev/null +++ b/tests/fixtures/drafts/solutions_in_a_second_source/expected.json @@ -0,0 +1,153 @@ +{ + "2/b1.ignore": { + "by": "tests", + "edited": false, + "layer": 1, + "ranges": [ + [ + 1, + 1 + ] + ], + "source": 2, + "value": true + }, + "2/b2.ignore": { + "by": "tests", + "edited": false, + "layer": 1, + "ranges": [ + [ + 3, + 3 + ] + ], + "source": 2, + "value": true + }, + "2/b5.ignore": { + "by": "tests", + "edited": false, + "layer": 1, + "ranges": [ + [ + 9, + 9 + ] + ], + "source": 2, + "value": true + }, + "2/b7.ignore": { + "by": "tests", + "edited": false, + "layer": 3, + "ranges": [ + [ + 13, + 13 + ] + ], + "source": 2, + "value": true + }, + "b1.ignore": { + "by": "tests", + "edited": false, + "layer": 1, + "ranges": [ + [ + 1, + 1 + ] + ], + "value": true + }, + "q1.p1.solution": { + "by": "tests", + "edited": false, + "layer": 1, + "ranges": [ + [ + 5, + 5 + ] + ], + "source": 2, + "value": "The load is $F = pA$." + }, + "q1.p1.text": { + "by": "tests", + "edited": false, + "layer": 1, + "ranges": [ + [ + 5, + 5 + ] + ], + "value": "Find the load the large piston carries." + }, + "q1.p2.solution": { + "by": "tests", + "edited": false, + "layer": 1, + "ranges": [ + [ + 7, + 7 + ] + ], + "source": 2, + "value": "The pressure is $p = F/a$." + }, + "q1.p2.text": { + "by": "tests", + "edited": false, + "layer": 1, + "ranges": [ + [ + 7, + 7 + ] + ], + "value": "Find the pressure in the oil." + }, + "q1.text": { + "by": "tests", + "edited": false, + "layer": 1, + "ranges": [ + [ + 3, + 3 + ] + ], + "value": "A hydraulic scale has two pistons joined by oil." + }, + "q2.solution": { + "by": "tests", + "edited": false, + "layer": 1, + "ranges": [ + [ + 11, + 11 + ] + ], + "source": 2, + "value": "The drag is $\\tau A$." + }, + "q2.text": { + "by": "tests", + "edited": false, + "layer": 1, + "ranges": [ + [ + 9, + 9 + ] + ], + "value": "A flat plate is towed through water at $u$." + } +} diff --git a/tests/fixtures/drafts/solutions_in_a_second_source/solutions.md b/tests/fixtures/drafts/solutions_in_a_second_source/solutions.md new file mode 100644 index 0000000..12fc006 --- /dev/null +++ b/tests/fixtures/drafts/solutions_in_a_second_source/solutions.md @@ -0,0 +1,13 @@ +# Solutions + +Q1. Hydraulic scale. + +(a) The load is $F = pA$. + +(b) The pressure is $p = F/a$. + +Q2. Flat plate. + +Solution: The drag is $\tau A$. + +Marked out of 20, with method marks throughout. diff --git a/tests/fixtures/drafts/solutions_in_a_second_source/source.md b/tests/fixtures/drafts/solutions_in_a_second_source/source.md new file mode 100644 index 0000000..b2925e3 --- /dev/null +++ b/tests/fixtures/drafts/solutions_in_a_second_source/source.md @@ -0,0 +1,9 @@ +# Fluid mechanics problem sheet + +Q1. A hydraulic scale has two pistons joined by oil. + +(a) Find the load the large piston carries. + +(b) Find the pressure in the oil. + +Q2. A flat plate is towed through water at $u$. diff --git a/tests/fixtures/drafts/solutions_in_a_second_source/spec.yaml b/tests/fixtures/drafts/solutions_in_a_second_source/spec.yaml new file mode 100644 index 0000000..4d7ac64 --- /dev/null +++ b/tests/fixtures/drafts/solutions_in_a_second_source/spec.yaml @@ -0,0 +1,6 @@ +question: Para text~'^Q\d+\.' +part: ListItem +solution: Para text~'^Solution:' +strip: ['^Q\d+\. ', '^Solution: '] +ignore: Header +layout: PartsSepSol diff --git a/tests/test_draft.py b/tests/test_draft.py index 66a5729..8e357c6 100644 --- a/tests/test_draft.py +++ b/tests/test_draft.py @@ -20,7 +20,7 @@ import pytest from click.testing import CliRunner -from conftest import DRAFTS, DRAFTS_DIR, needs_compiler +from conftest import DRAFTS, DRAFTS_DIR, frozen_sources, needs_compiler import in2lambda.draft import in2lambda.draft.report @@ -44,6 +44,9 @@ FIGURE = DRAFTS_DIR / "figure_in_a_question" """The one whose fields refer to an image file, which the export has to carry.""" +SOLUTIONS = DRAFTS_DIR / "solutions_in_a_second_source" +"""The one written as two documents, for what a command naming the second one does.""" + DEGREES = DRAFTS_DIR / "degrees" """The one whose report comes from the set the draft describes rather than the draft.""" @@ -52,9 +55,12 @@ def _built(folder: Path, tmp_path: Path) -> Path: - """A folder's document, frozen in `tmp_path` with its commands applied and checked.""" + """A folder's documents, 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 + # A folder holding a solutions.md is a sheet written as two documents, and freezes + # the solutions as its second source; everything of it is then named 2/b3, 2/s14. + sources = frozen_sources(tmp_path) + assert CliRunner().invoke(cli, ["source", "add", *sources]).exit_code == 0 draft_path = tmp_path / "source.draft.json" for entry in json.loads((folder / "commands.json").read_text()): in2lambda.draft.execute(entry, draft_path) @@ -384,6 +390,8 @@ def test_the_refusal_names_the_lines_that_are_in_the_way( ("arguments", "named"), [ (["draft", "question", "add", "--text", "s99:100"], "s99:100"), + (["draft", "question", "add", "--text", "2/s1:2"], "2/s1:2"), + (["draft", "question", "add", "--text", "9/b1"], "9/b1"), (["draft", "question", "add", "--text", "s6:5"], "s6:5"), (["draft", "question", "add", "--text", "sixteen"], "sixteen"), (["draft", "question", "add", "--text", "s8", "--literal", "Words."], "both"), @@ -403,6 +411,8 @@ def test_the_refusal_names_the_lines_that_are_in_the_way( ], ids=[ "lines the source has not got", + "lines of a source the draft has not got", + "a block of a source it has not got", "a range that runs backwards", "a text that is no kind of address", "a text and a literal", @@ -474,6 +484,49 @@ def test_a_command_says_what_it_wrote(tmp_path: Path, monkeypatch) -> None: assert "regex" not in draft["log"][-1]["args"] +def test_a_field_is_quoted_from_a_later_source_by_its_number( + tmp_path: Path, monkeypatch +) -> None: + """A block or a range of the solutions is named as the sheet's is, with its number.""" + monkeypatch.setenv("COLUMNS", "200") + monkeypatch.chdir(tmp_path) + shutil.copytree(SOLUTIONS, tmp_path, dirs_exist_ok=True) + runner = CliRunner() + assert ( + runner.invoke(cli, ["source", "add", "source.md", "solutions.md"]).exit_code + == 0 + ) + + # Each question out of the sheet and each solution out of the document beside it, + # once as the block it is and once as the lines it spans. + for arguments in ( + ["draft", "question", "add", "--text", "b2"], + ["draft", "part", "add", "q1", "--text", "b3"], + ["draft", "question", "solution", "q1", "--text", "2/b3"], + ["draft", "question", "add", "--text", "s9"], + ["draft", "question", "solution", "q2", "--text", "2/s11"], + ): + result = runner.invoke(cli, arguments) + assert result.exit_code == 0, result.output + + fields = json.loads((tmp_path / "source.draft.json").read_text())["fields"] + assert fields["q1.solution"]["value"] == "The load is $F = pA$." + assert fields["q2.solution"]["value"] == "Solution: The drag is $\\tau A$." + # Line 5 of the sheet is q1's part and line 5 of the solutions is what answers it: + # the same numbers, and no clash, because each field says which source it is of. + assert fields["q1.p1.text"]["ranges"] == fields["q1.solution"]["ranges"] == [[5, 5]] + assert "source" not in fields["q1.p1.text"] + assert fields["q1.solution"]["source"] == 2 + + # A number in front of an address that carries one already is no address at all, and + # is refused as what was given. Read as source 1's own 2/b6 it would find the block + # and then quote line 11 of the sheet, which is nine lines long - a field holding + # nothing, from a document nobody named, that a replay would reproduce faithfully. + result = runner.invoke(cli, ["draft", "question", "add", "--text", "1/2/b6"]) + assert result.exit_code != 0, result.output + assert "1/2/b6" in result.output + + def test_a_draft_edited_into_an_overlap_or_a_gap_is_reported( tmp_path: Path, monkeypatch ) -> None: @@ -620,6 +673,41 @@ def test_a_command_run_after_a_report_leaves_none_behind( assert "report" not in json.loads(draft_path.read_text()) +def test_freezing_another_source_leaves_no_report_behind( + tmp_path: Path, monkeypatch +) -> None: + """A report says nothing about a document frozen after it was written.""" + monkeypatch.chdir(tmp_path) + draft_path = _built(MARK_IGNORE, tmp_path) + assert json.loads(draft_path.read_text())["report"] + (tmp_path / "solutions.md").write_text("# Solutions\n\n1. The load is $F = pA$.\n") + runner = CliRunner() + + assert ( + runner.invoke( + cli, ["source", "add", "solutions.md", "--draft", "source.md"] + ).exit_code + == 0 + ) + + # Every block of the new source is in no field, so a report written before it was + # frozen is not a report of this draft - which a replay, running the checks again + # over what it rebuilt, is what catches. + assert "report" not in json.loads(draft_path.read_text()) + assert runner.invoke(cli, ["draft", "replay"]).exit_code == 0 + + # Naming a file the draft has got already does leave it: nothing has changed, so + # what the checks found still holds. + in2lambda.draft.report.validate(draft_path) + assert ( + runner.invoke( + cli, ["source", "add", "solutions.md", "--draft", "source.md"] + ).exit_code + == 0 + ) + assert json.loads(draft_path.read_text())["report"] + + def test_the_halves_of_a_split_block_are_blocks_like_any_other( tmp_path: Path, monkeypatch ) -> None: diff --git a/tests/test_source.py b/tests/test_source.py index ce19e9f..08202d9 100644 --- a/tests/test_source.py +++ b/tests/test_source.py @@ -36,10 +36,10 @@ 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 / "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()}" + (source,) = json.loads((tmp_path / "source.draft.json").read_text())["sources"] + assert source["blocks"] == json.loads((folder / "expected.json").read_text()) + markdown = (tmp_path / source["source"]).read_bytes() + assert source["hash"] == f"sha256:{hashlib.sha256(markdown).hexdigest()}" def test_freezing_again_is_refused_once_the_source_has_changed( @@ -96,7 +96,7 @@ 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 draft = json.loads((tmp_path / "source.draft.json").read_text()) - assert draft["source"] == "source.md" + assert [source["source"] for source in draft["sources"]] == ["source.md"] def test_source_show_numbers_the_lines_and_names_the_blocks( @@ -118,12 +118,74 @@ 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 / "source.draft.json").read_text())["blocks"] + blocks = json.loads((tmp_path / "source.draft.json").read_text())["sources"][0][ + "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) +def test_a_second_source_is_frozen_beside_the_first( + tmp_path: Path, monkeypatch +) -> None: + """A sheet and the solutions written separately from it are two sources of a draft.""" + shutil.copy(_frozen(MARKDOWN), tmp_path / "source.md") + (tmp_path / "solutions.md").write_text("# Solutions\n\n1. The load is $F = pA$.\n") + monkeypatch.chdir(tmp_path) + runner = CliRunner() + assert runner.invoke(cli, ["source", "add", "source.md"]).exit_code == 0 + + # A file the draft beside it has not got is the next source, not a second freezing. + assert ( + runner.invoke( + cli, ["source", "add", "solutions.md", "--draft", "source.md"] + ).exit_code + == 0 + ) + + draft = json.loads((tmp_path / "source.draft.json").read_text()) + assert [source["source"] for source in draft["sources"]] == [ + "source.md", + "solutions.md", + ] + # Every id of a source after the first says which source it is an id of. + assert [block["id"] for block in draft["sources"][1]["blocks"]] == ["2/b1", "2/b2"] + # And naming both files freezes neither again, as naming one already frozen does not. + written = (tmp_path / "source.draft.json").read_bytes() + result = runner.invoke(cli, ["source", "add", "source.md", "solutions.md"]) + assert result.exit_code == 0, result.output + assert (tmp_path / "source.draft.json").read_bytes() == written + + result = runner.invoke(cli, ["source", "show"]) + + assert result.exit_code == 0, result.output + # Each source under its number and its name, since both start their lines at 1. + assert "Source 1: source.md" in result.output + assert "Source 2: solutions.md" in result.output + assert "2/b1 1 # Solutions" in result.output + + +def test_freezing_files_from_two_directories_is_refused( + tmp_path: Path, monkeypatch +) -> None: + """A draft sits beside its sources, so there is no one draft for files apart.""" + monkeypatch.setenv("COLUMNS", "200") + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + shutil.copy(_frozen(MARKDOWN), tmp_path / "source.md") + (elsewhere / "solutions.md").write_text("# Solutions\n") + + result = CliRunner().invoke( + cli, + ["source", "add", str(tmp_path / "source.md"), str(elsewhere / "solutions.md")], + ) + + assert result.exit_code != 0 + assert "same directory" in result.output + assert not (tmp_path / "source.draft.json").exists() + + def test_source_show_without_a_draft_says_so(tmp_path: Path, monkeypatch) -> None: """Running it in the wrong directory is a message, not a traceback.""" monkeypatch.chdir(tmp_path) @@ -155,7 +217,9 @@ def test_source_show_refuses_once_the_source_has_changed( @pytest.mark.parametrize( - "content", ["{ not json at all", '{"blocks": []}'], ids=["not-json", "foreign"] + "content", + ["{ not json at all", '{"blocks": []}', '{"sources": [], "log": [], "fields": {}}'], + ids=["not-json", "foreign", "no-sources"], ) @pytest.mark.parametrize( "arguments", diff --git a/tests/test_spec.py b/tests/test_spec.py index 88fe515..31c388a 100644 --- a/tests/test_spec.py +++ b/tests/test_spec.py @@ -16,7 +16,7 @@ import pytest from click.testing import CliRunner -from conftest import SPECS, SPECS_DIR +from conftest import SPECS, SPECS_DIR, frozen_sources import in2lambda.draft from in2lambda.main import cli @@ -33,10 +33,12 @@ def _frozen(folder: Path, tmp_path: Path) -> CliRunner: - """A folder's document and its spec, copied into `tmp_path` with the source frozen.""" + """A folder's documents and its spec, copied into `tmp_path` with the sources frozen.""" shutil.copytree(folder, tmp_path, dirs_exist_ok=True) runner = CliRunner() - assert runner.invoke(cli, ["source", "add", "source.md"]).exit_code == 0 + assert ( + runner.invoke(cli, ["source", "add", *frozen_sources(tmp_path)]).exit_code == 0 + ) return runner