From 4dacc5432b5a45793fff7d03e420234bcde60cd3 Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Sun, 20 Sep 2026 21:38:24 +0100 Subject: [PATCH 1/3] implement: Rewrite the documentation and docstrings to the prose standard (t46) --- CHANGELOG.md | 32 +- docs/source/index.md | 8 +- docs/source/question-format.md | 71 +++-- docs/source/quickstart.md | 40 +-- docs/source/spec.md | 214 ++++++------- in2lambda/draft/__init__.py | 419 ++++++++++++------------- in2lambda/draft/export.py | 188 ++++++----- in2lambda/draft/report.py | 178 +++++------ in2lambda/json_convert/json_convert.py | 115 ++++--- in2lambda/main.py | 184 +++++------ in2lambda/source/__init__.py | 388 +++++++++++------------ in2lambda/spec/__init__.py | 253 ++++++++------- in2lambda/validation/__init__.py | 116 +++---- in2lambda/validation/delimiters.py | 27 +- in2lambda/validation/pdf/__init__.py | 125 ++++---- 15 files changed, 1160 insertions(+), 1198 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d717b7..f6d756c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,19 +2,19 @@ ## 2.0.0 -- Converting a document is now `in2lambda convert FILE FILTER`, with the same options as before (`-o/--out`, `-a/--answers`). Scripts and Docker invocations that run `in2lambda FILE FILTER` need the extra word. -- `in2lambda FILE FILTER` exits with an error naming the command to run instead, rather than printing its usage and exiting successfully. -- beartype is now `^0.22`. At 0.20.0 and below its import hook leaves `cli` a plain function rather than a group, so the new command line either fails to import or runs `convert` whatever the arguments; 0.20.1 is the first version that works. -- `in2lambda source add FILE` freezes a document: it converts .docx and .tex to markdown beside the file, and writes a `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: 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. +- Converting a document is now `in2lambda convert FILE FILTER`. The options are unchanged: `-o/--out` and `-a/--answers`. A script or Docker invocation that runs `in2lambda FILE FILTER` needs the extra word `convert`. +- `in2lambda FILE FILTER` exits with an error naming the command to run. Earlier versions printed the usage message and exited 0. +- beartype is now `^0.22`. At 0.20.0 and below, the beartype import hook leaves `cli` a plain function instead of a group, so the command line either fails to import or runs `convert` whatever the arguments are. 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 `FILE.draft.json` beside it, holding the markdown's hash and every block in the markdown 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 sheets holds one draft per sheet. `in2lambda source show` prints that markdown numbered with the block ids. Freezing a file that has changed since is refused unless `--start-over` discards the draft. Showing a file that has changed is refused as well, because its block ids would name lines they were not taken from. Both commands need pandoc and the `convert` extra, as `in2lambda 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 records the layer that 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. `in2lambda draft replay` rebuilds the draft from the frozen markdown and the log, and refuses unless what it builds matches the draft on disk byte for byte. A draft written before this release holds no `log` and is refused as a draft in2lambda did not write; `in2lambda source add --start-over` freezes the document again. +- `in2lambda draft question add`, `in2lambda draft part add QUESTION` and `in2lambda draft question solution QUESTION` fill a draft in. 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`. Each takes `--literal TEXT` for wording the source does not hold in a form the field can take, which records the field as edited and written by layer 4 in place of layer 3. in2lambda works the question and part numbers out from the fields already written, 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 is refused, naming the field. A command quoting lines another field was taken from is refused, naming both fields. +- `in2lambda draft field replace FIELD OLD NEW` changes the wording inside a written field, for the faults only an edit can fix, such as a brace the OCR dropped out of some maths that no range of the source states correctly. OLD must occur in the field once, or the command is refused, reporting how many times OLD occurs. `--regex` reads OLD as a regular expression and NEW as the replacement. The field keeps the lines it was taken from and the layer that wrote it, and records that it was edited and by whom, so the change can be shown against the source. +- `in2lambda spec run SPEC` runs a YAML file of selectors over the frozen source. The spec says which blocks are questions, parts and solutions, which blocks to ignore, what to strip off the front of each one, and which of the four filters lays the solutions out. `in2lambda spec run` fills the draft's fields in with the markdown of the lines each was taken from, records the spec's name and hash in the log so that a replay runs the same file, and reports every block it matched to no field. 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 the same width of indentation off every line below it. A question written `1. ` no longer carries its number, a continuation line no longer arrives indented far enough to render as a code block, and a spec's `strip` handles only what pandoc does not read as a marker. The values written by `in2lambda spec run`, `in2lambda draft question add`, `in2lambda draft part add` and `in2lambda draft question solution` change accordingly. The ranges behind those values still name the same source lines. +- `in2lambda validate` checks a draft as a whole and writes what it finds into the draft as a `report`. At level `error` it reports source blocks in no field and not marked ignore, two fields taken from the same lines, gaps in the numbering of the questions or their parts, and fields holding nothing. At level `warning` it reports a part, or a question written without parts, that nothing in the draft answers. Each finding names the level, the field and the lines, so that a finding can be acted on without reading the draft. A finding is not a failure and the command exits 0. The next run of the checks replaces the report, and the next command that changes the draft deletes it, because the report describes the draft as it stood. `in2lambda validate` also checks the set the draft describes, as in2lambda checks a converted document at export: maths delimiters, expressions KaTeX will not render, images the export would not carry, and the compile Lambda Feedback's PDF generator performs where pandoc and xelatex are installed, with a warning naming the packages to install where they are not. It reports each of those against the draft field holding the text, at level `error`, so that `in2lambda build` refuses them as it refuses any other finding 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, naming the missing file, because the checks read the draft and not the folder holding it. A question's own solution written beside a solution for every part becomes a part of its own holding that solution, as `in2lambda convert` pairs solutions up. `in2lambda build` is refused unless `in2lambda validate` has run since the draft last changed and found nothing at level `error`; every command that changes a draft deletes its report. The refusal prints those findings, so that they can be acted on without opening the draft. A finding at level `warning`, such as a part or question nothing in the draft answers, does not stop `in2lambda build`: many sheets keep their solutions in another file or have no solutions, so in2lambda prints the warning and writes the set. `in2lambda render` writes each question as a PDF instead, compiled as Lambda Feedback's own PDF generator compiles it, which needs pandoc and xelatex. `in2lambda render` runs whether or not the report holds findings, because reading a question as a PDF is how an author fixes what the checks found. Both commands take `-o/--out`, as `in2lambda convert` does. +- An export now names its images as they sit in `media/`. Every markdown image reference a question holds — in the question's text, a part's text, 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 have the same name, in2lambda names the second as Lambda Feedback's own exports name an image, `question_001_<Title>_0001.png`. Reading an export back is unchanged, because 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 an existing draft as its next source. Every block id and line range of a source after the first carries that source's number — `2/b3`, `2/s10:14` — and `1/b3` names the block `b3` names. A field quoted from a source records which source it came from, so that the same line number in two documents is two places. `in2lambda source show` prints each source under its number and its name. `in2lambda spec run` runs the spec over every source: the first source is laid out as the spec's `layout` says, and in any source after it the `question` selector picks out the marker written above each question's solutions while every other match is a solution, paired onto the questions and parts of the first source as `in2lambda convert -a` pairs an answers file. A draft now holds `sources`, a list of `{source, hash, blocks}` in the order they were frozen, in place of those three keys at the top level, so a draft written before this release is refused as a draft in2lambda did not write; `in2lambda source add --start-over` freezes the document again. Four changes to the Python API break existing scripts: `in2lambda.source.add` takes a list of files and the draft to freeze them into; `in2lambda.source.frozen` returns the markdown of every source and `in2lambda.draft.apply` takes the markdown of every source, in place 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 every caller constructing a `Field` must pass. +- Every command that works on a draft takes `--draft`, naming either the draft or the source it was frozen from: `in2lambda source show`, each `in2lambda draft` command, `in2lambda spec run`, `in2lambda validate`, `in2lambda build` and `in2lambda render`. Left off, each command uses the one draft in the current directory, and where the directory holds more than one draft, the command is refused, naming them. `in2lambda spec run` resolves its SPEC from the draft's directory. The Python functions behind those commands take the draft's path in place of 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` returns where a document's draft goes, and `in2lambda.source.find` resolves `--draft` for the command line. +- Importing `in2lambda.katex_convert` no longer writes a file called `log` into the working directory. What that module reports about a converted expression goes to the `in2lambda.katex_convert` logger, which is silent unless the application configures logging. +- The rest of 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/index.md b/docs/source/index.md index 816852c..cd74e90 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -21,7 +21,7 @@ og:title: in2lambda :::{grid-item} \ \ -Automagically uploads questions to [Lambda Feedback](https://lambda-feedback.github.io/user-documentation/) so you don't have to. +Converts a document of questions into a question set that [Lambda Feedback](https://lambda-feedback.github.io/user-documentation/) imports. ```{button-ref} quickstart :ref-type: doc @@ -39,19 +39,19 @@ Get Started :::{grid-item-card} {octicon}`tools;1.5em` Highly Configurable :link: filters/index :link-type: doc -Can be used to process numerous file formats with a variety of different structures by using [pandoc filters](https://pandoc.org/filters.html). +Reads many file formats, and documents of many structures, through [pandoc filters](https://pandoc.org/filters.html). ::: :::{grid-item-card} {octicon}`terminal;1.5em` Accessible Command Line Tool :link: reference/command-line :link-type: doc -Just provide the question file and select one of the in-built file parsers. +Name the question file and one of the built-in filters. ::: :::{grid-item-card} {octicon}`gear;1.5em` Powerful API :link: reference/library :link-type: doc -A fully type-annotated extensively documented Python library is available for those that need a bit more control. +A type-annotated, documented Python library builds a question set without a source document. ::: :::: diff --git a/docs/source/question-format.md b/docs/source/question-format.md index 97a3d6c..1ba4e44 100644 --- a/docs/source/question-format.md +++ b/docs/source/question-format.md @@ -8,7 +8,7 @@ describes that JSON, and how to build it from Python without a source document. A {class}`~in2lambda.api.set.Set` holds {class}`~in2lambda.api.question.Question` objects, each holding {class}`~in2lambda.api.part.Part` objects, each holding the {class}`~in2lambda.api.response_area.ResponseArea` boxes students type into. -{meth}`~in2lambda.api.set.Set.to_json` writes the lot. +{meth}`~in2lambda.api.set.Set.to_json` writes the set as JSON. ```pycon >>> from in2lambda.api.part import Part @@ -66,8 +66,8 @@ holding {class}`~in2lambda.api.part.Part` objects, each holding the ``` -{meth}`~in2lambda.api.set.Set.to_json` writes a folder named after the set, and a zip of it to -upload: +{meth}`~in2lambda.api.set.Set.to_json` writes a folder named after the set, and a zip of that +folder to upload: ```pycon >>> import json, os, tempfile @@ -87,24 +87,23 @@ upload: ``` -A few things the example shows in passing: +The example also shows the following: -- **Building parts directly beats the incremental helpers.** [Filters](filters/index) - read a document in order, so they call +- **Pass `Part` objects to `Question` where the script holds the whole question.** + [Filters](filters/index) read a document in order, so they call {meth}`~in2lambda.api.question.Question.add_part_text` and {meth}`~in2lambda.api.question.Question.add_solution`, which fill in whichever part comes next. - A script that already knows the whole question should pass `Part` objects to `Question`, as - above; only those give a part a final answer or an answer box. + A `Part` object gives a part a final answer and an answer box, which those two methods do not. - **A line holding only `---` (or `***`) splits a worked solution** into the steps students go through one at a time in the structured tutorial. -- **Unset question settings are left out of the JSON** rather than guessed at, so `skill`, - `guidance` and the two durations only appear when set. `publish` and the four `display_*` - settings always do, defaulting to `True`. -- **Images** go in `Question.images` as paths on disk; they are copied into `media/` under the file - name they already had, and every reference to one in the question's markdown is rewritten to that - name, which is all Lambda Feedback looks an image up by. +- **Unset question settings are left out of the JSON**, so `skill`, `guidance` and the two + durations appear only when set. `publish` and the four `display_*` settings always appear, and + default to `True`. +- **Images** are paths on disk listed in `Question.images`. `to_json` copies each image into + `media/` under its own file name, and rewrites every reference to that image in the question's + markdown to the same name. Lambda Feedback looks an image up by that name alone. - **{meth}`Set.from_json <in2lambda.api.set.Set.from_json>`** reads an existing export, as a folder - or a zip, so an edit to a real set can start from what Lambda Feedback produced. + or a zip, so an edit to a real set starts from the export Lambda Feedback produced. ## The JSON in2lambda writes @@ -116,12 +115,13 @@ A few things the example shows in passing: <set name>.zip # the folder, zipped, to upload ``` -A question's filename is its title with spaces and the characters Windows and path separators -forbid (`/ \ < > : " | ? *`) each replaced by an underscore. An image keeps the file name it -already had, so `images=["figures/rocket-momentum.png"]` gives `media/rocket-momentum.png`, and the -references to it are rewritten to that name. `media/` is one flat folder for the whole set, so a -file two questions use is copied once, and a second file of a name already taken is named as Lambda -Feedback names one, `question_001_<Title>_0001.png`. Files are written on a single line. +A question's filename is its title, with spaces and the characters Windows and path separators +forbid (`/ \ < > : " | ? *`) each replaced by an underscore. An image keeps its own file name, so +`images=["figures/rocket-momentum.png"]` gives `media/rocket-momentum.png`, and every reference to +that image is rewritten to `rocket-momentum.png`. `media/` is one flat folder for the whole set: a +file two questions use is copied once, and a second file whose name is already taken is named as +Lambda Feedback names an image, `question_001_<Title>_0001.png`. Each JSON file is written on a +single line. ### Set @@ -154,33 +154,32 @@ The three types in2lambda writes: | `NUMERIC_UNITS` | `comparePhysicalQuantities` | a number and a unit, e.g. `0.106 kg` | `gradeParams` holds `rtol` (and `strict_syntax`); `config` is null | | `MULTIPLE_CHOICE` | `arrayEqual` | a list of booleans, one per option | `config` holds `single`, `options` and `randomise`; `gradeParams` is null | -The three lists an area carries, each a dataclass in +A response area holds three lists, each of a dataclass in {mod}`in2lambda.api.response_area`: - `inputSymbols` — `{"symbol", "code", "aliases", "isVisible"}` from - {class}`~in2lambda.api.response_area.InputSymbol`. `symbol` is what students see - (e.g. `\(\rho\)`), `code` what the evaluation function reads. + {class}`~in2lambda.api.response_area.InputSymbol`. Lambda Feedback displays `symbol` to students + (e.g. `\(\rho\)`), and the evaluation function reads `code`. - `tests` — `{"id", "payload", "expectedResponse": {"isCorrect"}}` from {class}`~in2lambda.api.response_area.Test`: the author's own checks of the marking. - `cases` — `{"id", "answer", "feedback", "isCorrect", "params"}` from {class}`~in2lambda.api.response_area.Case`: a response matching `answer` is shown `feedback`, and may be marked correct. -An `id` left unset is a fresh UUID, which is what import needs. +An `id` left unset is written as a fresh UUID, which import requires. ### Markdown -Maths is `$...$` inline and `$$` on its own lines for display, rendered by -[KaTeX](https://katex.org/): commands KaTeX lacks do not display — degrees, for example, are -written `^\circ`. An image is written `![pictureTag](rocket-momentum.png)`, naming the file as it -sits in `media/`. A filter passes through whatever path the source document used, so -`\includegraphics{figures/rocket-momentum.png}` becomes `![pictureTag](figures/rocket-momentum.png)` -in the set; writing the set out rewrites it to `![pictureTag](rocket-momentum.png)`, which is the -image as `media/` holds it. A reference naming no image of the question is left as written, and -{func}`~in2lambda.validation.validate` reports it. +[KaTeX](https://katex.org/) renders maths written `$...$` inline and `$$` on its own lines for +display. KaTeX does not display the commands it lacks, so a degree is written `^\circ`. An image +is written `![pictureTag](rocket-momentum.png)`, naming the file as `media/` holds it. A filter +passes the source document's path through, so `\includegraphics{figures/rocket-momentum.png}` +becomes `![pictureTag](figures/rocket-momentum.png)` in the set, and writing the set out rewrites +that reference to `![pictureTag](rocket-momentum.png)`. A reference naming no image of the question +is written as it stands, and {func}`~in2lambda.validation.validate` reports that reference. :::{note} -Lambda Feedback's own exports carry a few keys in2lambda neither reads nor writes, among them -`isSurvey` and `releasedAt` on the set. Diffing a written set against a real export will show -them missing; the platform fills them in on import. +Lambda Feedback's own exports hold a few keys in2lambda neither reads nor writes, among them +`isSurvey` and `releasedAt` on the set. A diff of a written set against a real export shows those +keys missing. Lambda Feedback fills them in on import. ::: diff --git a/docs/source/quickstart.md b/docs/source/quickstart.md index 12cb7e1..6ec0167 100644 --- a/docs/source/quickstart.md +++ b/docs/source/quickstart.md @@ -1,6 +1,6 @@ # 🚀 Quickstart -This page gives a quick overview of how to get started with in2lambda to quickly add documents to Lambda Feedback. +This page describes how to install in2lambda and convert a document into a Lambda Feedback question set. ## 1. Installation @@ -8,13 +8,13 @@ This page gives a quick overview of how to get started with in2lambda to quickly [![GitHub Workflow Status (with event)](https://img.shields.io/github/actions/workflow/status/lambda-feedback/in2lambda/docker-publish.yml?style=flat-square&logo=docker&label=Docker)](https://github.com/lambda-feedback/in2lambda/pkgs/container/in2lambda) -The following creates an interactive container which includes in2lambda and mounts the current working directory into `/files`: +The following command starts an interactive container holding in2lambda, with the current working directory mounted at `/files`: ```bash $ docker run -it --rm -v $(pwd):/files ghcr.io/lambda-feedback/in2lambda sh ``` -Within the container, we can access the files and run in2lambda as normal. +Run in2lambda over those files inside the container. ```bash $ cd files @@ -23,7 +23,7 @@ $ ... $ exit ``` -The container is stopped and deleted after exiting, although the image remains downloaded for future use. +Docker stops and deletes the container on exit. The image stays on disk for the next run. ### PyPi @@ -31,7 +31,7 @@ The container is stopped and deleted after exiting, although the image remains d [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/in2lambda?style=flat-square&logo=python&logoColor=white)](https://pypi.org/project/in2lambda/) -in2lambda can be installed via [pip](https://pip.pypa.io/en/stable/). To author questions in Python: +[pip](https://pip.pypa.io/en/stable/) installs in2lambda. To write questions in Python: ```shell $ pip install in2lambda @@ -44,50 +44,50 @@ $ pip install 'in2lambda[convert]' $ in2lambda --help ``` -This can also be done through [pipx](https://pypa.github.io/pipx/). +[pipx](https://pypa.github.io/pipx/) installs in2lambda as well. ## 2. Choose a Document -`in2lambda convert` takes in two arguments: +`in2lambda convert` takes two arguments: - The path to a document. -- A filter describing how to parse it. +- A filter describing how to parse that document. -A list of available filters can be found [here](filters/index). +The [filters page](filters/index) lists every filter. -For instance, the following takes in `questions.tex` and uses a filter that expects [each part to be directly followed by the solution](filters/_autosummary/PartSolPartSol): +The following command reads `questions.tex` with a filter that expects [each part to be followed by its solution](filters/_autosummary/PartSolPartSol): ```bash $ in2lambda convert questions.tex PartSolPartSol ``` :::{note} -The filter name is case-insensitive. Don't worry about the capital letters. +The filter name is case-insensitive. ::: -Another filter might be used if [the answers are in a separate file](filters/_autosummary/PartsSepSol): +A different filter reads [answers held in a separate file](filters/_autosummary/PartsSepSol): ```bash $ in2lambda convert questions.tex -a solutions.tex PartsSepSol ``` -By default, this generates an `out` directory in the same place that the command was run in. It contains the zipped question files. +`in2lambda convert` writes an `out` directory in the directory the command ran in, holding the zipped question files. -Before writing anything, in2lambda prints the problems it can detect that would stop the set importing or make it render wrongly — an answer that doesn't fit the box marking it, a figure the export won't contain, maths that KaTeX can't display. Each names the question, part and field to go and look at. They are warnings rather than errors: the `out` directory is written either way, since a problem found here may well be deliberate. +Before writing that directory, in2lambda prints the problems that would stop Lambda Feedback importing the set or would render it wrongly: an answer that does not fit the box marking it, a figure the export would not contain, maths KaTeX cannot display. Each problem names the question, the part and the field holding it. Each problem is a warning, and in2lambda writes the `out` directory whatever it finds, because an author may have intended the problem. -The maths is checked by rendering it with KaTeX itself, the way Lambda Feedback will, which needs [Node.js](https://nodejs.org) installed. Without Node.js everything else is still checked and in2lambda says the maths was not. +in2lambda checks the maths by rendering it with KaTeX, as Lambda Feedback renders it, which needs [Node.js](https://nodejs.org). Without Node.js, in2lambda runs the other checks and reports that it did not check the maths. -With [xelatex](https://tug.org/texlive/) installed alongside pandoc, the set is also compiled the way Lambda Feedback makes a PDF of it, and any LaTeX error names the field it is in. Without it, one warning says which packages to install instead. +With [xelatex](https://tug.org/texlive/) installed alongside pandoc, in2lambda also compiles the set as Lambda Feedback compiles a PDF of it, and names the field holding each LaTeX error. Without xelatex, in2lambda prints one warning naming the packages to install. -Check the [command line tool reference](reference/command-line) for more information. +The [command line reference](reference/command-line) describes every command and option. ## 3. Import into Lambda Feedback -Click on a set in teacher mode. The arrow next to the "Add Question" button allows you to import a question from a file. +Open a set in teacher mode. The arrow beside the "Add Question" button imports a question from a file. -Choose the zip file you wish to upload, and the question should appear! 🎉 +Choose the zip file to upload, and Lambda Feedback adds the question to the set. -Imported questions arrive published with every display setting on, and the set's own visibility settings still apply. The Python API can set each of these per question — see the +An imported question arrives published, with every display setting on, and the set's own visibility settings apply to it. The Python API sets each of these per question; see the [question format](question-format). ![Importing Question from file in Teacher Mode](_static/images/import-teacher.png) diff --git a/docs/source/spec.md b/docs/source/spec.md index a403ef6..d336d9f 100644 --- a/docs/source/spec.md +++ b/docs/source/spec.md @@ -1,8 +1,8 @@ # 📐 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 documents it was frozen from, so -that the wording of every question comes out of the source rather than being retyped: +A spec is a YAML file that says which blocks of a document are questions, which are parts and +which are solutions. `in2lambda spec run` fills in the draft beside the documents the spec was +frozen from, copying the wording of every question out of the source: ```bash $ in2lambda source add questions.docx solutions.docx @@ -10,12 +10,12 @@ $ in2lambda spec run spec.yaml b6 (lines 12-13) is in no field and not marked ignore. ``` -The last line is the point of it: a spec run reports every block it made nothing of, naming the -lines it is, so what is left to account for is in front of you rather than quietly missing. +The last line illustrates the purpose of a spec. Blocks not matched to a field are reported, with +their line numbers, so that all content is accounted for. -The fields a draft holds belong to the spec that wrote them, so a spec is run over a draft once. -Running an edited one again is refused; freeze the document afresh and run it, which is two -commands: +The fields of a draft belong to the spec that wrote them, so a spec runs over a draft once. +`in2lambda spec run` refuses an edited spec over a draft it has already filled in. Freeze the +documents again and run the spec, which takes two commands: ```bash $ in2lambda source add --start-over questions.docx solutions.docx @@ -33,62 +33,60 @@ ignore: Header level=1 layout: PartsSepSol ``` -`question` and `layout` have to be there; `part`, `solution`, `strip`, `ignore` and -`predicates` need not be. - -- **`question`, `part`, `solution`** select the blocks that are each of those things. -- **`ignore`** selects the blocks that are none of them - a running header, a page of - instructions - and marks them as `in2lambda draft mark ignore` would, so they are not reported - as left out. -- **`strip`** is a list of patterns taken off the front of every value: the `Q1. ` or - `Solution: ` that labels a block in the document, but not in the question. A list marker is - not one of them, since a value quoted out of a list item arrives dedented. -- **`predicates`** names a Python file beside the spec, for the selectors that cannot say what - they mean in constraints alone. See below. -- **`layout`** is one of the [filters](filters/index), and says which solution answers which - question or part. See below. - -A block is whatever the first of `ignore`, `question`, `part`, `solution` to match it says it is. -That order is fixed, whatever order the keys are written in, so a spec whose selectors overlap -has to tell them apart by what they match rather than by where they are in the file. +A spec must set `question` and `layout`. A spec may set `part`, `solution`, `strip`, `ignore` and +`predicates`. + +- **`question`, `part`, `solution`** select the blocks that are questions, parts and solutions. +- **`ignore`** selects the blocks that are none of those three, such as a running header or a page + of instructions. `in2lambda spec run` marks each one as `in2lambda draft mark ignore` does, so + that the run does not report it. +- **`strip`** lists the patterns removed from the front of every value, such as the `Q1. ` or + `Solution: ` that labels a block in the document but not in the question. A list marker is not + one of those patterns, because a value quoted out of a list item is already dedented. +- **`predicates`** names a Python file beside the spec, for the selectors that constraints alone + cannot express. See [Predicates](#predicates). +- **`layout`** is one of the [filters](filters/index), and assigns each solution to a question or + part. See [Layouts](#layouts). + +`in2lambda spec run` classifies a block as the first of `ignore`, `question`, `part` and +`solution` that matches it. That order is fixed, whatever order the keys are written in. A spec +whose selectors overlap must tell them apart by what they match. ## Selectors -A selector is a block type, then any number of constraints: +A selector is a block type followed by any number of constraints: ``` [after SELECTOR,] [Type] name=value name~'regex' ... ``` -The type is a pandoc element - `Header`, `Para`, `ListItem` - and may be left out to match any -block. A constraint is about one of three things: +The type is a pandoc element: `Header`, `Para`, `ListItem`. A selector that omits the type matches +any block. A constraint names one of three attributes: -| Attribute | What it is | -|-----------|------------| -| `level` | A heading's level: `level=2` is `##`. | -| `text` | The whole block as text, with the markup taken off. | -| `label` | The first word of that text, which is usually what numbers a question. | +| Attribute | Meaning | +|-----------|---------| +| `level` | A heading's level. `level=2` matches `##`. | +| `text` | The whole block as text, with the markup removed. | +| `label` | The first word of that text, which usually numbers a question. | -`=` asks for exactly that; `~` for a regular expression anywhere in it. `after SELECTOR,` says -the block has to come after the first block that selector matches, which is how the solutions at -the end of a problem sheet are told apart from the questions at the front. +`=` matches the whole value. `~` matches a regular expression anywhere in the value. `after +SELECTOR,` requires the block to follow the first block that the named selector matches, which is +how a spec tells the solutions at the end of a problem sheet from the questions at the front. -A regular expression goes in single quotes. YAML reads `\(` inside double quotes as an escape -and complains, and `'^\([a-z]\)'` is the same string without the argument. +Write a regular expression in single quotes. YAML reads `\(` inside double quotes as an escape +sequence and reports an error, and single quotes pass the backslash through. -A selector matches what **pandoc** makes of the document, while a field holds the **markdown** of -the lines it came from. That is worth knowing where a part is written `(a) Find the load.`: it is -a `ListItem`, because pandoc reads `(a)` as a list marker, and the value comes dedented the way -pandoc reads the item - the marker off the first line and as much of the same width off every -line under it - so `strip` is only for what pandoc does not read as a marker, the `Q1. ` and the -`Solution: `. +A selector matches the document as **pandoc** parses it, and a field holds the **markdown** of the +lines the block came from. A part written `(a) Find the load.` is a `ListItem`, because pandoc +reads `(a)` as a list marker. Its value is dedented as pandoc reads the item: the marker comes off +the first line, and the same width of indentation off every line below it. Use `strip` for the +labels pandoc does not read as a marker, such as `Q1. ` and `Solution: `. ## Predicates -Some documents cannot be told apart by their text. If the questions are the paragraphs written -in bold, and a paragraph about marking starts with the word `Question` as surely as they do, -then no `text~` constraint will do it. For those, a spec names a Python file beside it and calls -functions from it: +Some documents cannot be classified by their text. If the questions are the paragraphs written in +bold, and a paragraph about marking also starts with the word `Question`, no `text~` constraint +separates them. A spec then names a Python file beside it and calls functions from that file: ```yaml predicates: predicates.py @@ -97,10 +95,10 @@ solution: Para italic_lead() layout: PartsOneSol ``` -A `name()` anywhere in a selector is a call, and goes with a type, with constraints and with -`after` - `after Header text=Solutions, is_solution()` - all of which have to hold as well. A -predicate is an ordinary function of one argument, the [panflute](https://scorreia.com/software/panflute/) -element the block is, that says whether the block is one of those: +A `name()` anywhere in a selector calls a predicate. A call combines with a type, with constraints +and with `after` — `after Header text=Solutions, is_solution()` — and every one of them must hold. +A predicate is a function of one argument, the [panflute](https://scorreia.com/software/panflute/) +element for the block, that returns whether the selector matches: ```python import panflute as pf @@ -114,73 +112,75 @@ def bold_lead(element: pf.Element) -> bool: return isinstance(first, pf.Strong) ``` -The frozen source is parsed with pandoc's `sourcepos`, so that each block knows which lines it -came from, and that leaves every inline wrapped in a `Span` carrying where it is. A predicate -looking at the markup has to see through them, as the one above does. +`in2lambda source add` parses the frozen source with pandoc's `sourcepos`, so that each block +records the lines it came from. `sourcepos` wraps every inline element in a `Span` holding that +element's position, and a predicate reading the markup must look through those spans, as +`bold_lead` above does. -The file is named in the draft's log with its hash, exactly as the spec is, and it is run from -the bytes that hash was taken of. So a predicate edited after a run is refused the same way an -edited spec is, by `in2lambda draft replay` and by running the spec again. +The draft's log names the predicate file with its hash, as it names the spec, and `in2lambda spec +run` runs the file from the bytes that hash was taken of. `in2lambda draft replay` and a second +`in2lambda spec run` refuse a predicate file edited since the first run, as they refuse an edited +spec. ## Layouts -The layout is the one thing that differs between problem sheets that are otherwise alike: where -the solutions are, and what each of them answers. +The layout says where the solutions sit and which question or part each one answers. Problem +sheets that are otherwise alike differ in their layout. | Layout | Which solution answers what | |--------|-----------------------------| -| `PartsOneSol` | One solution to the whole question, however many parts it has. | -| `PartSolPartSol` | Each solution answers the part just before it, or the question if it has no parts yet. | -| `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. | +| `PartsOneSol` | One solution answers the whole question, however many parts it has. | +| `PartSolPartSol` | Each solution answers the part before it, or the question where no part precedes it. | +| `PartPartSolSol` | The parts come together and their solutions follow, 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: +Many sheets come as two files: the questions, and the solutions written separately. Freeze both, +in that order, and the draft holds them as source 1 and source 2. `in2lambda source add` also adds +a file to an existing draft, which freezes that file 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`, -`q1.p2`. So a spec fills in `q1.text`, `q1.p1.text`, `q1.p1.solution` and, for a question -answered as a whole, `q1.solution`. They are the names the `in2lambda draft` commands give out -as well, so a draft filled in either way is the same draft. Every one of them records the lines it was copied from, and that a -spec wrote it. - -The spec is recorded in the draft's log with its hash, so `in2lambda draft replay` rebuilds the -same draft from the same spec - and refuses if the spec has been edited since, because then it -would be checking the draft against something else. That is why running an edited spec over a -draft it has already filled in is refused too: the draft would be left holding fields no spec on -disk wrote, and no replay could ever check it again. +`in2lambda source show` prints each source under its number and its name, and every block id and +line range names the source it belongs to. `b3` and `s10:14` name the first source, `2/b3` and +`2/s14:20` the second, and `1/b3` names the block `b3` names. A field quoted from a source after +the first records that source's number beside the lines it was copied from, because line 5 of the +solutions is not line 5 of the sheet. + +`in2lambda spec run` runs the same spec over every source, and each selector matches within the +source being run over: `after Header text=Solutions` names a position in one document. The +selectors mean something different in a document of solutions, the document `in2lambda convert +-a` reads as an answers file: + +- A block the **`question`** selector matches is a **marker**, such as the `Q2.` written above the + solutions to the second question. A marker answers nothing. `in2lambda spec run` marks the + marker ignored and assigns the blocks after it to that question's first slot. +- A block the **`part`** or **`solution`** selector matches is a **solution**. Solutions fill the + slots in order: each question's parts, or the question itself where it has no parts. +- The **`layout`** describes the sheet, and describes no document after it. Solutions written + separately follow the order of the questions, which is the `PartsSepSol` rule, whatever layout + the sheet uses. + +`in2lambda spec run` reports a solution past the last slot as being in no field, like any other +unmatched block. A solution assigned to a question that an earlier solution has answered is +refused, naming the field — `q2.solution`, say — as already written. No command writes a field +twice. `in2lambda draft field replace` changes the wording of a written field, and `in2lambda +source add --start-over` begins the draft again. + +## What a spec writes + +The questions are `q1`, `q2` and so on in the order they appear, and the parts of a question are +`q1.p1`, `q1.p2`. A spec fills in `q1.text`, `q1.p1.text`, `q1.p1.solution` and, for a question +answered as a whole, `q1.solution`. The `in2lambda draft` commands write the same names, so a +draft filled in either way holds the same fields. Each field records the lines it was copied from +and that a spec wrote it. + +The draft's log names the spec with its hash, so `in2lambda draft replay` rebuilds the same draft +from the same spec. `in2lambda draft replay` refuses a spec edited since the run, because the spec +on disk describes a different draft. `in2lambda spec run` refuses an edited spec over a draft it +has already filled in for the same reason: the draft would hold fields that no spec on disk wrote, +and no replay could check it again. diff --git a/in2lambda/draft/__init__.py b/in2lambda/draft/__init__.py index 87b1f1e..0c452a8 100644 --- a/in2lambda/draft/__init__.py +++ b/in2lambda/draft/__init__.py @@ -1,13 +1,13 @@ -"""Builds up a draft by commands, and rebuilds it from the ones it recorded. +"""Builds a draft up by commands, and rebuilds it from the commands it recorded. -A draft is written by a sequence of commands, some of them chosen by a model. Every -command that changes one is recorded in the draft's ``log`` as it is applied, and every -field a command writes carries where it came from, so that :func:`replay` can build the -same draft again out of the frozen markdown and the log alone, with no model in the loop. -That is what makes a run reproducible, and a saved run a test. +A sequence of commands writes a draft, and a model chooses some of them. :func:`apply` +records every command that changes a draft in the draft's ``log``, and every field a +command writes records where its value came from, so that :func:`replay` builds the same +draft again from the frozen markdown and the log alone, with no model involved. A saved +run is therefore a test. -Commands reach a draft only through :func:`apply`, which is what keeps the log complete: -a handler registered with :func:`command` is never called by anything else. +Commands reach a draft only through :func:`apply`, which keeps the log complete: nothing +else calls a handler registered with :func:`command`. """ import re @@ -36,60 +36,59 @@ 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 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. +The caller passes the frozen markdown of every source in, in the order the draft froze +them, so that a handler quoting a source by line range quotes the same text on a replay +as it did on the first run. `directory` is where the draft is, and so where a file a +command names sits. A handler returns the name of what it wrote: the key of the field, or +the block ids a split made. The caller needs that name for the next command, and a +command that writes a draft's worth of fields returns what it left out. """ _HANDLERS: dict[str, Handler] = {} -"""Every command there is, by the name a log entry names it with.""" +"""Every command, by the name a log entry calls it.""" _RANGE = re.compile(r"s(\d+)(?::(\d+))?") """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``. +"""A block id or a line range with its source 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. +One number and one slash: what follows the slash is an id or a range, and never another +source number. So ``1/2/b3`` matches nothing here, and is refused as the address +``1/2/b3`` instead of being read as source 1's ``2/b3``. """ class MalformedCommand(SourceError): - """A log holds something that is not a command, so nothing can be made of it.""" + """A log holds an entry that is not a command.""" class UnknownCommand(SourceError): - """A log names a command that nothing registered, so the draft cannot be rebuilt.""" + """A log names a command this version of in2lambda does not have.""" class NoSuchBlock(SourceError): - """A command names a block the frozen source has not got.""" + """A command names a block no frozen source holds.""" class NoSuchLines(SourceError): - """A command names lines the frozen source has not got, or names them as nothing.""" + """A command names lines no frozen source holds, or writes a range as nothing.""" class NoSuchQuestion(SourceError): - """A command adds to a question nothing has written yet.""" + """A command adds to a question no command has written.""" class AlreadyFilled(SourceError): - """A command would write a field that is written, or lines another field took.""" + """A command would write a written field, or lines another field was taken from.""" class NoSuchField(SourceError): - """A command changes the wording of a field the draft has not got as text.""" + """A command changes the wording of a field the draft does not hold as text.""" class NotOnce(SourceError): - """The wording a command replaces is not in the field exactly once.""" + """The wording a command replaces occurs in the field other than once.""" class ReplayDiffers(SourceError): @@ -97,9 +96,9 @@ class ReplayDiffers(SourceError): class SpecChanged(SourceError): - """A file a log names - a spec, or its predicates - is not the one that ran. + """A file a log names - a spec, or its predicates - is not the file that ran. - Either it has changed since, or it has gone. + The file has changed since the run, or it has been deleted. """ @@ -107,7 +106,8 @@ def command(name: str) -> Callable[[Handler], Handler]: """Registers a handler as the command of that name. Args: - name: What a log entry calls it, as it is typed: ``"mark ignore"``. + name: The name a log entry gives the command, as a reader types it: + ``"mark ignore"``. Returns: The decorator, which returns the handler unchanged. @@ -135,45 +135,44 @@ def record( Args: draft: The draft to write into. - key: What the field is called, unique within the draft. - value: What it is. - layer: What wrote it: 1 a spec, 2 a predicate, 3 a range taken from the source, - 4 a literal someone typed. A reader deciding whether to trust a field wants - to know which of those it was. + key: The name of the field, unique within the draft. + value: The value to write. + layer: What wrote the value: 1 a spec, 2 a predicate, 3 a range taken from the + source, 4 a literal a reader typed. A reader deciding how far to trust a + field reads the layer. ranges: The line ranges of the frozen source the value was copied from, as - ``[[start, end], ...]``, and empty where it was not copied from any. - by: Who ran the command, as a name or a model. - 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. + ``[[start, end], ...]``, and empty where the value was copied from none. + by: Who ran the command, as a person's name or a model. + edited: Whether the value differs from what the source says. A literal is the one + value a command writes that arrives edited; every other field is edited when + a later command replaces the wording. 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. + from 1. The field records the number only where it is not 1, 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. + The key, so that a handler returns the field it wrote. Raises: - AlreadyFilled: the field is written already, or the lines it was to be copied - 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. + AlreadyFilled: the field is written already, or the lines to copy from are the + lines another field of the same source was copied from. No command writes a + field twice, because `field replace` changes the wording of a written field. + The message names the field, and for taken lines both fields. """ if key in draft["fields"]: raise AlreadyFilled( - f"{key} is already written, and no command here writes a field twice. Run " + f"{key} is already written, and no command writes a field twice. Run " "in2lambda draft field replace to change the wording it holds, or " "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. + # Only fields quoted from the same source: line 12 of the solutions document is + # not line 12 of the sheet, so two fields quoting line 12 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. + # Each range on its own, so that the message names the range in the way: a field + # edited by hand holds several ranges, and the others may be lines this command + # is free to take. for taken in field["ranges"]: if overlapping(ranges, [taken]): raise AlreadyFilled( @@ -193,7 +192,7 @@ def record( def _fault(entry: Any) -> str: - """What is wrong with the shape of a log entry, or "" if nothing is.""" + """What is wrong with the shape of a log entry, or "" if nothing is wrong.""" if not isinstance(entry, dict): return "is not an object" if missing := sorted({"command", "args", "by"} - entry.keys()): @@ -206,11 +205,11 @@ def _fault(entry: Any) -> str: def _checked(entry: Any) -> Command: - """One entry of a log, given that it has the shape of a command. + """One entry of a log, where that entry has the shape of a command. - Both readers of a log come through here - the one applying an entry and the one - looking over the entries already applied - so that a hand-edited log says the same - thing whichever of them reads it first. + Both readers of a log call this - the one applying an entry, and the one reading the + entries already applied - so that a hand-edited log raises the same message whichever + reads it first. Raises: MalformedCommand: the entry is not a command. @@ -224,12 +223,12 @@ def _checked(entry: Any) -> Command: def _argument(args: dict[str, Any], name: str, command: str, kind: type = str) -> Any: - """One argument of a command, given that the log entry gave it as `kind`. + """One argument of a command, where the log entry gave it as `kind`. - Handlers take their arguments through this rather than indexing, so that a log - entry missing one, or holding a number where a name belongs, says which argument it - is rather than raising at whoever ran it. Every argument is a name but the line a - block is split at. + Handlers read their arguments through this instead of indexing `args`, so that a log + entry missing an argument, or holding a number where a name belongs, names the + argument in the message. Every argument is a name except the line a block is split + at. Raises: MalformedCommand: the entry has no argument of that name, or has one that is @@ -257,10 +256,10 @@ def apply( Args: draft: The draft to change, in place. 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. - directory: Where the draft is, and so what a file the command names is beside. + entry: The command, as the log writes it. Typed as Any and not as `Command`, + because a log is read from a file a reader can edit, so this function + reports the entry's shape instead of assuming it. + directory: Where the draft is, and so where a file the command names sits. Returns: What the command wrote, as the handler names it. @@ -278,8 +277,8 @@ def apply( 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 - # report with it rather than leaving one that describes something else. + # The report describes the draft as it stood, so a command that changes the draft + # deletes the report. draft.pop("report", None) return written @@ -293,27 +292,27 @@ def execute(entry: Command, draft: str | Path) -> str: Returns: What the command wrote, as the handler names it: the key of a field, the block - ids a split made, or what a spec run left in no field at all. + ids a split made, or the blocks a spec run matched to no field. Raises: - SourceError: the draft is missing, is not one of ours, or was written from - markdown that has changed since; or the command is unknown or refused. + SourceError: the draft is missing, is not a draft in2lambda wrote, or was + written from markdown that has changed since; or the command is unknown or + refused. """ path = Path(draft) 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. + # A handler is given the folder and not the draft, because the files it reads - a + # spec, a file of predicates - are named from the folder. written = apply(found, sources, entry, str(path.parent)) save(path, found) return written def replay(draft: str | Path) -> None: - """Rebuilds a draft from its source and its log, and checks it is the same. + """Rebuilds a draft from its sources and its log, and checks the result matches. - Nothing is written: the point is to find out whether what is on disk is what its - commands say it should be, and a replay that wrote the answer could not tell anyone - it was different. + :func:`replay` writes nothing. It reports whether the draft on disk is the draft its + commands build, and a replay that wrote its result could report no difference. Args: draft: The path of the draft to replay. @@ -322,15 +321,15 @@ def replay(draft: str | Path) -> None: DraftExists: the markdown has changed since the draft was written from it, so the commands would be replayed against lines they were not run against. MalformedCommand: the log holds something that is not a command. - UnknownCommand: the log names a command nothing here registered. + UnknownCommand: the log names a command nothing registered. SpecChanged: a spec the log was run with has changed or gone since. ReplayDiffers: the rebuilt draft is not the one on disk, byte for byte. """ _require_conversion_tools() path = Path(draft) found, sources = frozen(path) - # From the markdown rather than from the draft: the blocks are as much a product of - # the sources as the fields are, and copying them across would not check them. + # The blocks are rebuilt from the markdown and not copied from the draft: the blocks + # come from the sources as the fields do, and copying them would check nothing. rebuilt: dict[str, Any] = { "sources": [ { @@ -347,12 +346,12 @@ def replay(draft: str | Path) -> None: } for entry in found["log"]: 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 - # depends on whether xelatex and Node are installed and the draft does not: rebuilt - # here it would come out shorter on a machine whose toolchain is not the one that - # validated, and an untouched draft would be accused of having been edited. + # No command writes the report: `in2lambda validate` writes it over the draft the + # commands left, so a replay runs the checks again. The findings + # `in2lambda.validation` made over the set are carried across instead, because they + # depend on whether xelatex and Node.js are installed and the draft does not. Run + # again on a machine without that toolchain, they would come out shorter, and the + # replay would report an untouched draft as edited. if "report" in found: carried = [ finding for finding in found["report"] if finding["check"] == "problem" @@ -361,22 +360,23 @@ def replay(draft: str | Path) -> None: if serialise(rebuilt) != path.read_bytes(): raise ReplayDiffers( - f"Replaying the log in {path.name} does not reproduce it, so what is in it " - "did not all come from the commands it records - something has changed it " - "since they ran. Run in2lambda source add --start-over to begin again." + f"Replaying the log in {path.name} does not reproduce {path.name}, so some " + "of its fields did not come from the commands it records: something changed " + "the draft after those commands ran. Run in2lambda source add --start-over " + "to begin the draft again." ) def _qualified(where: str) -> tuple[int, str]: - """Which source a block id or a line range is of, and the rest of it. + """Which source a block id or a line range belongs to, 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. + ``2/b3`` is block b3 of the draft's second source, and ``2/s10:14`` its lines 10 to + 14. An id or range with no number in front belongs to the first source, which is how + every command written while a draft held one source still reads. ``1/b3`` names the + block ``b3`` names. - 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 + Anything else is returned as the first source's, under the name it was given, so that + the caller reports the address a reader typed: ``1/2/b3`` names no block of any source and is refused as ``1/2/b3``. """ if (named := _QUALIFIED.fullmatch(where)) is None: @@ -385,11 +385,11 @@ def _qualified(where: str) -> tuple[int, str]: 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. + """Which source a block belongs to and the block itself, where a source holds it. Raises: - 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. + NoSuchBlock: no source holds that id, either because no block is numbered that + way or because the draft does not hold the source the id names. """ source, name = _qualified(block) wanted = _numbered(source, name) @@ -413,29 +413,29 @@ def _block(draft: dict[str, Any], block: str) -> tuple[int, dict[str, Any]]: def _lines( 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. + """Which source a ``text`` argument names, and its first and last line. - 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. Either names a source after the first by - writing its number in front: ``2/b3``, ``2/s10:14``. + A block id names the lines that block spans, which `in2lambda source show` prints + beside it. A range names the lines outright, which quotes part of a block without + splitting the block. Either names a source after the first by writing that source's + number in front: ``2/b3``, ``2/s10:14``. Raises: - 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. + NoSuchBlock: the argument is neither a range nor a block a frozen source holds. + NoSuchLines: the argument is a range of lines the source it names does not hold, + or of a source the draft does not hold. """ 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. + # Block ids are b1, b2, b3a, so a name starting with s is read as a range, and a + # malformed range is reported as one. 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." + f"the draft holds {len(sources)}. Run in2lambda source add FILE to freeze " + "another source beside them." ) lines = len(sources[source - 1].splitlines()) if (named := _RANGE.fullmatch(name)) is not None: @@ -443,10 +443,11 @@ def _lines( if 1 <= start <= end <= lines: return source, start, end raise NoSuchLines( - 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, with the source's number in front - 2/s10:14 " - "- for any but the first. Run in2lambda source show to see them numbered." + f"{command} was given {where}, which is not lines of source {source}: source " + f"{source} has {lines} lines. Lines are named s16, or s10:14 for a range running " + "from an earlier line to a later one, with the source's number in front - " + "2/s10:14 - for any source after the first. Run in2lambda source show to see the " + "lines numbered." ) @@ -461,15 +462,15 @@ def _fill( ) -> str: """Writes the field a command fills, from its ``text`` or its ``literal``. - 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. + ``text`` copies the field out of a frozen source. ``literal`` types the field out + where no source holds the wording in a form the field takes. + A literal quotes nothing: it is layer 4, it records no range, and it arrives edited, + because its value is not what any source says. Raises: - MalformedCommand: the command gives both of them, or neither, or gives one of + MalformedCommand: the command gives both arguments, or neither, or gives one of them as something other than text. - NoSuchBlock, NoSuchLines: its ``text`` is not somewhere in a source. + NoSuchBlock, NoSuchLines: the command's ``text`` names no lines of a source. AlreadyFilled: the field, or the lines it names, are taken. """ text, literal = args.get("text"), args.get("literal") @@ -484,8 +485,8 @@ def _fill( f"{args!r} in the log is not a command {command} can run: it gives neither " 'a "text" nor a "literal", so there is nothing for it to write.' ) - # Back through `_argument` now that which of them was given is settled, so that one - # given as a number is a message about the argument rather than a failure inside. + # Back through `_argument` now that the choice is settled, so that an argument given + # as a number is reported by name. if literal is not None: return record( draft, @@ -513,17 +514,17 @@ def _fill( 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 of one frozen source as a field holds 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. - The block the lines fall in says whether they are, rather than the text itself, so - that a paragraph reading like a list item is quoted as it is written. + Lines quoted out of a list item are dedented by the item's own indentation, which the + markdown requires and the author did not write. The ranges still name the source + lines. The block the lines fall in decides whether they are dedented, and the text + does not, so that a paragraph reading like a list item is quoted as it is written. """ text = "\n".join(markdown.splitlines()[start - 1 : end]) - # 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. + # Blocks do not overlap, so the block holding the first line is the block the lines + # belong to. A nested item falls in that block as well, because only a top-level item + # is a block of its own. block = next( ( held @@ -536,10 +537,10 @@ def _quoted( def _next(draft: dict[str, Any], prefix: str) -> str: - """The first of ``{prefix}1``, ``{prefix}2``... the draft has no text for. + """The first of ``{prefix}1``, ``{prefix}2``... that the draft holds no text for. - Ids are worked out rather than given, so that replaying a log numbers the questions - and their parts exactly as the run that recorded it did. + in2lambda works ids out instead of taking them as arguments, so that replaying a log + numbers the questions and their parts as the run that recorded it did. """ number = 1 while f"{prefix}{number}.text" in draft["fields"]: @@ -548,11 +549,11 @@ def _next(draft: dict[str, Any], prefix: str) -> str: def _require_question(draft: dict[str, Any], question: str, command: str) -> None: - """Checks the draft has the question a command adds to. + """Checks that the draft holds the question a command adds to. Raises: - NoSuchQuestion: nothing has written that question's text, so there is nothing - for a part or a solution to belong to. + NoSuchQuestion: no command has written that question's text, so a part or a + solution has no question to belong to. """ if f"{question}.text" not in draft["fields"]: raise NoSuchQuestion( @@ -569,10 +570,10 @@ def _mark_ignore( by: str, directory: str, ) -> str: - """Marks one block of a frozen source as nothing to take a question from.""" + """Marks one block of a frozen source as holding no question, part or solution.""" 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. + # The id as the draft holds it, so that a block named 1/b3 writes the b3.ignore that + # b3 writes, and a block of a later source writes 2/b3.ignore. return record( draft, f"{found['id']}.ignore", @@ -592,7 +593,7 @@ def _question_add( by: str, directory: str, ) -> str: - """Adds a question, taking the first number no question has taken.""" + """Adds a question, under the first number no question holds.""" return _fill( draft, sources, @@ -611,7 +612,7 @@ def _part_add( by: str, directory: str, ) -> str: - """Adds a part to a question, taking the first number that question has not.""" + """Adds a part to a question, under the first number that question does not hold.""" question = _argument(args, "question", "part add") _require_question(draft, question, "part add") return _fill( @@ -632,10 +633,10 @@ def _question_solution( by: str, directory: str, ) -> str: - """Gives a question its worked solution, wherever it is written. + """Gives a question its worked solution, from wherever the solution 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. + A solution is often written in a document of its own: ``--text 2/b4`` names the block + of the solutions frozen beside the sheet. """ question = _argument(args, "question", "question solution") _require_question(draft, question, "question solution") @@ -657,40 +658,39 @@ def _field_replace( by: str, directory: str, ) -> str: - """Replaces one piece of wording inside a field that is written already. + """Replaces one piece of wording inside a field that is already written. - Some faults can only be fixed by changing the text: a brace the OCR dropped leaves - maths KaTeX will not render, and no range of the source says it correctly. The - layer and the ranges are left as they were, so the change can still be shown - against the lines the field was taken from, and `edited` says what is there now is - not what those lines say. + An edit is the only fix for some faults: a brace the OCR dropped leaves maths KaTeX + will not render, and no range of the source holds that maths correctly. The layer + and the ranges stay as they were, so the change can be shown against the lines the + field was taken from, and `edited` records that the value differs from those lines. Raises: - MalformedCommand: an argument is missing, or ``old`` is not a regular - expression with ``regex``. - NoSuchField: the draft has no field of that name holding text. - NotOnce: ``old`` is not in the field exactly once, so which of it was meant is - not something to guess at. + MalformedCommand: an argument is missing, or ``regex`` was passed and ``old`` is + not a regular expression. + NoSuchField: the draft holds no field of that name holding text. + NotOnce: ``old`` occurs in the field other than once, so this command cannot + tell which occurrence was meant. """ key = _argument(args, "field", "field replace") old = _argument(args, "old", "field replace") new = _argument(args, "new", "field replace") - # Only when it is there, so that a command nobody passed --regex to records no - # argument for it, as every other option of a command does. + # Read only when present, so that a command run without --regex records no argument + # for it, as every other option does. regex = "regex" in args and _argument(args, "regex", "field replace", bool) field = draft["fields"].get(key) if field is None or not isinstance(field.get("value"), str): raise NoSuchField( f"There is no field {key} holding text in the draft: field replace changes " - "the wording of a field one of the commands before it has written." + "the wording of a field an earlier command wrote." ) value = field["value"] try: found = len(re.findall(old, value)) if regex else value.count(old) - # A function rather than new itself, because re.sub reads a string as a - # template, in which \t is a tab and \frac is an error. What is being repaired - # here is LaTeX, so NEW is what gets written, backslashes and all. + # A function, not `new` itself, because re.sub reads a string as a template, in + # which \t is a tab and \frac is an error. This command repairs LaTeX, so NEW is + # written as it was typed, backslashes and all. replaced = ( re.sub(old, lambda _: new, value, count=1) if regex @@ -703,9 +703,9 @@ def _field_replace( ) from None if found != 1: raise NotOnce( - f"{old!r} occurs {found} times in {key} rather than once, so there is no " - "one place in it to replace. Give more of the wording around it, or pass " - "--regex and a pattern that matches it alone." + f"{old!r} occurs {found} times in {key} rather than once, so field replace " + "cannot tell which occurrence to replace. Give more of the wording around " + "it, or pass --regex and a pattern that matches one occurrence." ) field["value"] = replaced @@ -722,12 +722,13 @@ def _split_block( by: str, directory: str, ) -> str: - """Cuts one block of a frozen source in two, so each half can be named. + """Cuts one block of a frozen source in two, so that each half has an id. 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 - is untouched and the halves are ``b3a`` and ``b3b``, so a replay, which rebuilds the - blocks from the markdown and then runs the log over them, arrives at the same ids. + is unchanged and the halves are ``b3a`` and ``b3b``, so that a replay, which rebuilds + the blocks from the markdown and then runs the log over them, arrives at the same + ids. """ block = _argument(args, "block", "split block") at = _argument(args, "at", "split block", int) @@ -736,7 +737,7 @@ def _split_block( 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." + "each half must hold a line." ) held = draft["sources"][source - 1]["blocks"] index = held.index(found) @@ -748,21 +749,20 @@ def _split_block( def _file_as_run(directory: str, name: str, digest: str) -> bytes: - """A file the log says a spec run used, given it still says what it said then. + """A file the log says a spec run used, where that file still holds what it held. Args: - directory: Where the draft is, and so what the file is beside. - name: What the log calls the file: the spec, or the predicates it names. - digest: What the log says the file hashed to when it ran. + directory: Where the draft is, and so where the file sits. + name: The name the log gives the file: the spec, or the predicates it names. + digest: The hash the log records for the file at the time it ran. Returns: - The contents of the file, for whoever is about to run it. + The contents of the file, for the caller about to run it. Raises: - SpecChanged: there is no such file beside the draft, or it is not the one the - log records running. Either way the fields the spec wrote are fields nothing - on disk would write again, so neither a replay nor another run can check - them. + SpecChanged: no such file sits beside the draft, or the file is not the one the + log records running. The fields the spec wrote are then fields no file on + disk would write again, so neither a replay nor another run can check them. """ try: raw = (Path(directory) / name).read_bytes() @@ -782,13 +782,13 @@ def _file_as_run(directory: str, name: str, digest: str) -> bytes: def _files(args: dict[str, Any]) -> list[tuple[str, str]]: - """What a `spec run` entry says it ran, as ``(name, hash)`` for each file. + """The files a `spec run` entry says it ran, as ``(name, hash)`` for each file. - The spec, and then the Python file of predicates it named, where it named one. + The spec, and then the Python file of predicates the spec named, where it named one. Raises: - MalformedCommand: the entry names a file without hashing it, or the other way - about. + MalformedCommand: the entry names a file without its hash, or a hash without its + file. """ files = [ ( @@ -807,15 +807,15 @@ def _files(args: dict[str, Any]) -> list[tuple[str, str]]: def spec_command(name: str, by: str, draft: str | Path) -> Command: - """The `spec run` entry for a spec, with everything it depends on hashed into it. + """The `spec run` entry for a spec, with the hash of every file it depends on. - The hashes go in the log beside the names, so that a replay can tell whether it is - running the files that wrote the fields it is checking. + The log records each hash beside the name of its file, so that a replay can check + that it is running the files that wrote the fields it is checking. Args: - name: The spec to run, as it is to be named in the log: beside the draft. - by: Who is running it, as a name or a model. - draft: The path of the draft the spec is to fill in, which the spec is beside. + name: The spec to run, as the log is to name it: beside the draft. + by: Who runs it, as a person's name or a model. + draft: The path of the draft the spec fills in, which the spec sits beside. Returns: The command, for :func:`execute` to run. @@ -836,9 +836,8 @@ def spec_command(name: str, by: str, draft: str | Path) -> Command: args: dict[str, Any] = {"spec": name, "hash": _digest(raw)} spec = in2lambda.spec.load(raw) if spec.predicates is not None: - # Beside the spec, which is what a spec naming a file next to it means and all - # that load lets one name, and recorded from the draft's directory, which is - # what the log names things from. + # The predicates file sits beside the spec, which is the only place `load` + # accepts, and the log names it from the draft's directory. beside = (Path(name).parent / spec.predicates).as_posix() try: code = (directory / beside).read_bytes() @@ -859,13 +858,12 @@ def _spec_run( by: str, directory: str, ) -> str: - """Fills in a draft's fields from a spec of selectors over its frozen sources.""" + """Fills a draft's fields in 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 - # no longer reproduce whatever it is spelled as now, so what has run is what to - # check. On a replay this re-reads files whose own entries checked them, which is a - # file read each. + # Every file every spec run in the log used, and not only the files this entry + # names: a spec edited since leaves fields the log can no longer reproduce, whatever + # name it goes under now. On a replay this re-reads files that their own entries + # checked, at one file read each. for entry in map(_checked, draft["log"]): if entry["command"] == "spec run": for file, digest in _files(entry["args"]): @@ -875,16 +873,16 @@ def _spec_run( spec = in2lambda.spec.load(raw[0]) functions = None if spec.predicates is not None: - # Named by the entry rather than taken from the spec, so that what is run is the + # The entry names the file, and the spec does not, so that the file run is the # file the hash beside it in the log was checked against. 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 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. + # The selectors run over the blocks the parser makes of the sources, and a `split + # block` run since leaves the draft holding halves the parser never made. So an + # ignored block takes its id and its range from here and not from the draft: the + # field then spans the whole ignored block, and `uncovered`, which reads the lines a + # field was taken from, counts each half of a split block as covered. documents = [ (_elements(markdown, number), markdown) for number, markdown in enumerate(sources, start=1) @@ -905,7 +903,8 @@ def _spec_run( 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. + # The field `mark ignore` writes, so that `uncovered` reads one field whichever + # command wrote it. for block_id in ignored: source, span = lines[block_id] record( @@ -917,9 +916,9 @@ def _spec_run( 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 - # in the words `in2lambda validate` says it in, since it is the same check. + # A spec writes a draft's worth of fields, so it returns the blocks it matched to no + # field, and a reader acts on those. The wording is `in2lambda validate`'s, because + # the check is the same. if left_out := uncovered(draft): return "\n".join(finding["message"] for finding in left_out) return "Every block is in a field or ignored." diff --git a/in2lambda/draft/export.py b/in2lambda/draft/export.py index 80ab983..536d3ce 100644 --- a/in2lambda/draft/export.py +++ b/in2lambda/draft/export.py @@ -1,18 +1,18 @@ -"""Turns a finished draft into the set it describes, to upload or to look at. - -A draft is a map of fields - ``q1.text``, ``q1.p2.text``, ``q1.solution`` - and an -export is a :class:`~in2lambda.api.set.Set` of questions holding parts. :func:`as_set` -is the one place that reads the one as the other, so both what is written out and what -is rendered for review come from the same reading of the draft. - -:func:`build` refuses a draft the checks have not looked at, or have found an error in; -what they found at level warning - a question or part nothing answers - it says and -exports anyway, since a sheet whose solutions are in another file or nowhere is still a -sheet. There is no timestamp in that: every command that changes a draft takes its -report with it, so a draft holding one has been checked since it last changed, and -`in2lambda.source.frozen` refuses one whose source has moved on underneath it. -:func:`render` is gated on nothing, since looking at a draft is how what the checks -found gets fixed. +"""Turns a finished draft into the set it describes, to upload or to read. + +A draft is a map of fields - ``q1.text``, ``q1.p2.text``, ``q1.solution`` - and an export +is a :class:`~in2lambda.api.set.Set` of questions holding parts. :func:`as_set` is the one +function that reads a draft as a set, so the set written out and the set rendered for +review come from one reading of the draft. + +:func:`build` refuses a draft the checks have not read, or have found an error in. A +finding at level warning - a question or part nothing answers - is printed, and +:func:`build` writes the set, because a sheet whose solutions are in another file is +still a sheet. No timestamp records the check: every command that changes a draft deletes +its report, so a draft holding a report has been checked since it last changed, and +`in2lambda.source.frozen` refuses a draft whose source has changed since. :func:`render` +runs whether or not the report holds findings, because reading a draft is how an author +fixes what the checks found. """ import re @@ -35,15 +35,15 @@ class NotValidated(SourceError): - """A draft is being exported that the checks have not passed, or not seen at all.""" + """A draft being exported has not passed the checks, or has not been checked.""" class MissingImage(SourceError): """A field refers to an image file that is not beside the draft. - The checks do not look at files, so such a draft validates clean; it is refused - here rather than exported, since what would be uploaded is a question with a broken - figure in it. + The checks read the draft and not the folder holding it, so such a draft passes them. + :func:`build` refuses the draft, because the set would upload a question holding a + broken figure. """ @@ -52,19 +52,18 @@ def as_set(draft: dict[str, Any], directory: str = ".") -> Set: Args: draft: A draft, as `in2lambda.source.frozen` reads one. - directory: Where the draft is, and so what the images it names are beside. + directory: Where the draft is, and so where the images it names sit. Returns: - One question per ``qN.text``, holding one part per ``qN.pM.text`` with the - worked solution written for it. A question's own ``qN.solution`` answers every - part that has none of its own; where every part has one already, or the - question was written without parts, it becomes a part of its own holding - nothing but that solution. That is the rule - :meth:`~in2lambda.api.question.Question.add_solution` applies, so a draft - exports as the same sheet converted by `in2lambda convert` does. A question - written with neither parts nor a solution holds one part with nothing in it, - which is the question as the draft has it. A block marked ignore is in no - question: it is the source's, not the set's. + One question per ``qN.text``, holding one part per ``qN.pM.text`` with the worked + solution written for it. A question's own ``qN.solution`` answers every part that + has no solution of its own. Where every part has one already, or the question was + written without parts, ``qN.solution`` becomes a part of its own holding that + solution. :meth:`~in2lambda.api.question.Question.add_solution` applies the same + rule, so a draft exports as the same sheet converted by `in2lambda convert` does. + A question written with neither parts nor a solution holds one empty part, which + is the question as the draft holds it. A block marked ignore is in no question, + because it belongs to the source and not to the set. Examples: >>> from in2lambda.draft.export import as_set @@ -94,10 +93,10 @@ def as_set(draft: dict[str, Any], directory: str = ".") -> Set: question.parts[-1].worked_solution = fields[written]["value"] if (written := f"q{number}.solution") in fields: # A sheet often writes one worked solution for a whole question, which - # answers each part it does not answer separately. Where nothing is left for - # it to answer it is a part of its own, as `add_solution` makes it one: a - # solution written beside a solution for every part is still the author's - # wording, and dropping it would export less than the draft holds. + # answers each part that has no solution of its own. Where no part is left to + # answer, the solution becomes a part of its own, as `add_solution` makes it + # one: the wording is the author's, and dropping it would export less than + # the draft holds. if all(part_of.worked_solution for part_of in question.parts): question.parts.append(Part(worked_solution=fields[written]["value"])) else: @@ -105,14 +104,14 @@ def as_set(draft: dict[str, Any], directory: str = ".") -> Set: if not part_of.worked_solution: part_of.worked_solution = fields[written]["value"] if not question.parts: - # A question whose parts are yet to be written is still exported, and an - # empty part is what it holds: `json_convert` leaves a question with no - # parts at all carrying the template's own placeholder wording, which is - # wording no field of the draft holds. + # A question whose parts are not yet written is exported with one empty part, + # because `json_convert` leaves a question holding no parts with the + # template's own placeholder wording, which no field of the draft wrote. question.parts.append(Part()) - # As the export refers to them: beside the draft, since that is where a command - # naming a file names one. Whether the file is there is `build`'s question, not - # asked here, so that a draft can be rendered while its figures are being found. + # The paths are read as the export writes them, beside the draft, because a + # command names a file from the draft's directory. `build` checks that each file + # is there, and this function does not, so that a draft renders while its figures + # are still being found. for _, markdown in _fields(question, number): question.images += [ str(Path(directory) / reference) @@ -122,23 +121,22 @@ def as_set(draft: dict[str, Any], directory: str = ".") -> Set: def located(draft: dict[str, Any]) -> dict[str, str]: - """Which field of a draft each place `in2lambda.validation` reports against is. + """The field of a draft each place `in2lambda.validation` reports against. - :func:`as_set` read backwards. The validator names a question, a part and a field - of the export, which is no address in the draft that wrote it, so this walks the - fields the way :func:`as_set` walks them and must be changed with it. + :func:`located` reads :func:`as_set` backwards. The validator names a question, a + part and a field of the export, which name no field of the draft that wrote them, so + this function walks the fields as :func:`as_set` walks them, and changes with it. Args: draft: A draft, as `in2lambda.source.frozen` reads one. Returns: The draft's field key for each location of the set it describes, the question's - own location included - where a problem about the whole question, such as an - image the export would not contain, is reported. A part answered by its - question's solution is located at that solution, since that is the field to go - and edit. Places no field of the draft wrote - a part's answer, the empty part - a question written without any exports as - are not here: nothing is in them - for the validator to find. + own location included, where a problem about the whole question is reported, such + as an image the export would not contain. A part answered by its question's + solution is located at that solution, which is the field to edit. Places no field + of the draft wrote - a part's answer, the empty part a question written without + parts exports as - are left out, because the validator finds nothing in them. Examples: >>> from in2lambda.draft.export import located @@ -166,7 +164,7 @@ def located(draft: dict[str, Any]) -> dict[str, str]: for index, part in enumerate(parts): where[_location(number, "", index, "text")] = f"q{number}.p{part}.text" written = f"q{number}.p{part}.solution" - # On the value and not the key, as `as_set` decides it: a solution field + # On the value and not the key, as `as_set` reads it: a solution field # written empty leaves the part for its question's solution to answer. if fields.get(written, {}).get("value"): where[_location(number, "", index, "worked solution")] = written @@ -175,63 +173,63 @@ def located(draft: dict[str, Any]) -> dict[str, str]: if solution in fields and all( fields.get(f"q{number}.p{part}.solution", {}).get("value") for part in parts ): - # The part `as_set` appends for a question's solution with no part left for - # it to answer, which is the last one and holds nothing else. + # The part `as_set` appends for a question's solution when no part is left to + # answer, which is the last part and holds nothing else. where[_location(number, "", len(parts), "worked solution")] = solution return where def build(draft: str | Path, output_dir: str = "out") -> Path: - """Writes a draft out as a Lambda Feedback set, if it is clean. + """Writes a draft out as a Lambda Feedback set, where the checks found no error. Args: draft: The path of the draft to export. output_dir: Where to write the set's folder and its zip. Returns: - The zip that was written, which is what Lambda Feedback imports. + The zip that was written, which Lambda Feedback imports. Raises: - NotValidated: the draft has not been checked since it last changed, or the - checks found an error in it. Either way what would be uploaded is not what - anybody has looked at. + NotValidated: the draft has not been checked since it last changed, or the checks + found an error in it. The set would then hold what nobody has read. MissingImage: a field refers to an image file that is not beside the draft. - SourceError: the draft is missing, is not one of ours, or was written from - markdown that has changed since. + SourceError: the draft is missing, is not a draft in2lambda wrote, or was written + from markdown that has changed since. Warns: UserWarning: once per finding the checks made at level warning, which is a - question or part the draft has no solution for. The set is written with it. + question or part the draft holds no solution for. The set is written all the + same. """ - # Here rather than at the top of the module: `report` checks the set this writes, so - # it imports this, and only what reads a report - this one function - needs it back. + # Imported here and not at the top of the module: `report` checks the set this + # function writes, so `report` imports this module, and only this function reads a + # report back. from in2lambda.draft.report import errors path = Path(draft) found, _ = frozen(path) if "report" not in found: raise NotValidated( - f"{path.name} has not been validated since it last changed, so what it " - "would export is what nothing has checked. Run in2lambda validate." + f"{path.name} has not been validated since it last changed, so its export " + "would hold what nothing has checked. Run in2lambda validate." ) if refusing := errors(found["report"]): raise NotValidated( "\n".join(finding["message"] for finding in refusing) - + f"\n{path.name} is not exported while its report says this. Fix what it " - "names, or mark the blocks it is about as ignored, and run in2lambda " - "validate again." + + f"\n{path.name} is not exported while its report holds these findings. Fix " + "the fields they name, or mark the blocks they are about as ignored, and " + "run in2lambda validate again." ) for finding in found["report"]: - # Said rather than refused: a sheet whose solutions are elsewhere or absent is - # one to export as it stands, and writing one in to quiet this would put wording - # into the set that no source of it says. + # Printed and not refused: a sheet whose solutions are in another file, or + # absent, is exported as it stands, and writing a solution in would put wording + # in the set that no source holds. warnings.warn(finding["message"], stacklevel=2) exported = as_set(found, str(path.parent)) - # The export carries every image a field refers to into media/, which is the only - # place Lambda Feedback looks for one, so a file that is not there is not something - # to write the set without: `json_convert` would raise a bare FileNotFoundError over - # it. The checks read the draft and not the folder it is in, so a draft they found - # nothing in can still say this. + # The export copies every image a field refers to into media/, which is the only + # place Lambda Feedback reads an image from, and `json_convert` raises a bare + # FileNotFoundError over a file that is not there. The checks read the draft and not + # the folder holding it, so a draft they passed can still reach this. for number, question in enumerate(exported.questions, start=1): for image in question.images: if not Path(image).is_file(): @@ -248,11 +246,12 @@ def render(draft: str | Path, output_dir: str = "out") -> list[Path]: """Writes each question of a draft as a PDF, for review. The questions are compiled as Lambda Feedback's own PDF generator compiles them, - under a heading naming each, so what comes out is what a student would be shown. - The checks are not run first: looking at a draft is how what they found gets fixed. - Nor does a figure that is not beside the draft stop a question being looked at - - the compiler drops the reference and typesets the rest of it - or a question the - compiler gives up on altogether stop the rest of the draft being written out. + under a heading naming each question, so that the PDF shows what a student is shown. + :func:`render` does not run the checks first, because reading a draft is how an + author fixes what the checks found. A figure that is not beside the draft does not + stop a question being rendered - the compiler drops the reference and typesets the + rest - and a question the compiler gives up on does not stop the rest of the draft + being written. Args: draft: The path of the draft to render. @@ -263,10 +262,9 @@ def render(draft: str | Path, output_dir: str = "out") -> list[Path]: Raises: ConversionToolsMissing: pandoc or xelatex is not installed. - CompileFailed: no question could be rendered at all, so there is nothing to - look at. - SourceError: the draft is missing, is not one of ours, or was written from - markdown that has changed since. + CompileFailed: no question rendered, so there is no PDF to read. + SourceError: the draft is missing, is not a draft in2lambda wrote, or was written + from markdown that has changed since. Warns: UserWarning: once per LaTeX error in a question that was rendered anyway, and @@ -284,8 +282,8 @@ def render(draft: str | Path, output_dir: str = "out") -> list[Path]: for index, question in enumerate(as_set(found, str(path.parent)).questions): stem = _question_stem(index, _question_title(question, index)) output = Path(output_dir) / f"{stem}.pdf" - # Headed with the question's number, so that a stack of these can be read - # through, and so that a question with nothing written in it is still a page. + # Headed with the question's number, so that a stack of PDFs reads in order and + # a question with nothing written in it is still a page. heading = f"Question {index + 1}" fields = [(heading, f"# {heading}")] + _fields(question, index + 1) try: @@ -297,12 +295,12 @@ def render(draft: str | Path, output_dir: str = "out") -> list[Path]: warnings.warn(str(problem), stacklevel=2) written.append(output) if refused and not written: - # Nothing at all to look at, which is a failed run rather than a fault in one - # question of it, so it is said the way a draft that cannot be read is. + # No PDF to read, which is a failed run and not a fault in one question, so + # `render` raises as it does for a draft it cannot read. raise pdf.CompileFailed("; ".join(refused)) for failure in refused: - # One question TeX cannot finish is a fault in that question like any other, and - # the ones that do compile are still what the draft is being rendered for. + # One question TeX cannot finish is a fault in that question, and the questions + # that do compile are what the author asked to read. warnings.warn(failure, stacklevel=2) return written @@ -310,9 +308,9 @@ def render(draft: str | Path, output_dir: str = "out") -> list[Path]: def _fields(question: Question, number: int) -> list[tuple[str, str]]: """Every markdown field of a question, each with where to report an error in it. - Named as `in2lambda.validation` names them, save for the title, since a draft - writes none: the renderer marks the document with these, so what it reports back - reads the same as what the validator reports. + The names are `in2lambda.validation`'s, except for the title, which no draft writes. + The renderer marks the document with these names, so that it reports an error in the + words the validator reports one in. """ where = f"Question {number}" fields = [(f"{where}, main text", question.main_text)] diff --git a/in2lambda/draft/report.py b/in2lambda/draft/report.py index 2412797..e3389cd 100644 --- a/in2lambda/draft/report.py +++ b/in2lambda/draft/report.py @@ -1,27 +1,25 @@ -"""Checks a draft over as a whole, and writes what it finds into it. - -A draft is written one command at a time, and what a run of them left out is not -something any one command can see: a block nobody quoted, two fields taken from the same -lines, a question numbered 3 where there is no 2, a part with nothing answering it. So -the finished draft is looked over at once, and what the checks find is written into it as -its ``report``, which is what whoever is writing the draft - an agent or a person - reads -to find out what is left to do, without reading the draft itself. - -Everything here reports, never refuses: what the checks found may well be deliberate, and -deciding that is whoever is writing the draft's to do. The checks themselves read only -what is in the draft - its blocks, its field keys, their ranges and their values - and -what the text of a question says is `in2lambda.validation`'s: the set the draft describes -is exported and checked over as well, so that maths Lambda Feedback will not render is -reported against the field it is written in rather than found after uploading. - -Each finding carries the level it is found at, which is what `in2lambda.draft.export` -goes by. An error is the draft contradicting its own source or its own export - lines -nothing accounts for, two fields quoting the same ones, a numbering with a hole in it, a -quotation of nothing, maths that will not render - and there is no sheet those are right -about. A warning is something that may well be right: half the sheets there are write -their solutions in another file, or have none, so a question nothing answers is said to -whoever is building the set rather than stopping them - inventing a solution to quiet it -is the one thing nobody wanted. +"""Checks a draft as a whole, and writes what the checks find into the draft. + +A draft is written one command at a time, and no one command sees what a run of them left +out: a block nobody quoted, two fields taken from the same lines, a question numbered 3 +where there is no 2, a part nothing answers. So the checks read the finished draft and +write what they find into it as its ``report``, which the agent or the person writing the +draft reads to find what is left to do, without reading the draft itself. + +The checks report and never refuse, because the author may have intended what a check +found. The checks themselves read only the draft - its blocks, its field keys, their +ranges and their values. `in2lambda.validation` reads the text of a question: +:func:`problems` exports the set the draft describes and checks that set, so that maths +Lambda Feedback will not render is reported against the field holding it, before the set +is uploaded. + +Each finding records the level it was found at, which `in2lambda.draft.export` reads. An +error is the draft contradicting its own source or its own export: lines no field +accounts for, two fields quoting the same lines, a hole in the numbering, a field holding +nothing, maths that will not render. No sheet is written that way. A warning is a finding +the author may have intended: many sheets write their solutions in another file, or write +none, so in2lambda reports a question nothing answers to the person building the set and +writes the set. """ import re @@ -36,18 +34,18 @@ Finding = dict[str, Any] """One thing a check found: ``{"check", "level", "field", "ranges", "message"}``. -``check`` is which check found it - ``problem`` where it was `in2lambda.validation`, -over the set the draft describes - ``level`` :data:`ERROR` or :data:`WARNING`, ``field`` -the block id or field key it is about, ``ranges`` the lines in question as -``[[start, end], ...]``, and ``message`` a sentence naming all of that, so that a line of -the report can be acted on by itself. +``check`` names the check that found it, and is ``problem`` where `in2lambda.validation` +found it over the set the draft describes. ``level`` is :data:`ERROR` or :data:`WARNING`. +``field`` is the block id or field key the finding is about. ``ranges`` are the lines, as +``[[start, end], ...]``. ``message`` is a sentence naming all of those, so that one line +of the report can be acted on by itself. """ ERROR = "error" -"""A finding the draft cannot be exported over: it says something its source does not.""" +"""A finding that stops an export: the draft says what its source does not say.""" WARNING = "warning" -"""A finding the export says and goes on past: it may be what the sheet really is.""" +"""A finding the export prints before writing the set, because the sheet may be right.""" _NUMBERED = re.compile(r"((?:q\d+\.p)|q)(\d+)\.text") """A question's or a part's text, split into what numbers it and the number.""" @@ -67,7 +65,7 @@ def overlapping(ranges: list[list[int]], other: list[list[int]]) -> bool: Args: ranges: Line ranges, as ``[[start, end], ...]``, each end inclusive. - other: The ranges to test them against. + other: The ranges to test `ranges` against. Returns: Whether the two sets share a line. @@ -87,7 +85,7 @@ def overlapping(ranges: list[list[int]], other: list[list[int]]) -> bool: def _where(ranges: list[list[int]]) -> str: - """The lines something covers, as a message names them, or "" if it covers none.""" + """The lines a finding covers, as its message names them, or "" for no lines.""" if not ranges: return "" return " (lines " + ", ".join(f"{start}-{end}" for start, end in ranges) + ")" @@ -107,22 +105,21 @@ def _runs(lines: list[int]) -> list[list[int]]: def uncovered(draft: dict[str, Any]) -> list[Finding]: """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. - Blocks are accounted for by the lines the fields were taken from rather than by name, - so that a block `split block` has cut in two is covered by an ignore of the whole. + A block quoted in part is reported for the rest of it: a question taken from the first + line of a block leaves the other lines unaccounted for. This check accounts for a + block by the lines the fields were taken from and not by the block's name, so that an + ignore of a whole block covers both halves of a block `split block` has cut in two. Args: draft: A draft, as `in2lambda.source.frozen` reads one. Returns: - One :data:`Finding` per block with lines nothing has made anything of, in - 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. + One :data:`Finding` per block holding lines no field accounts for, in document + order and source by source. `in2lambda.spec` reports through this function as + well as the checks do, because a spec run asks the same question as 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. + # the sheet, so a field quoting one document accounts for no line of the other. claimed = { (field.get("source", 1), line) for field in draft["fields"].values() @@ -156,8 +153,8 @@ def uncovered(draft: dict[str, Any]) -> list[Finding]: def _overlaps(draft: dict[str, Any]) -> list[Finding]: """Pairs of fields quoted from some of the same lines. - No command writes such a pair - `record` refuses the second of them - so this is here - for a draft edited by hand, where one of the two fields is quoting the wrong thing. + No command writes such a pair, because `record` refuses the second field. This check + reports a draft edited by hand, in which one of the two fields quotes the wrong lines. """ fields = draft["fields"] keys = sorted(fields) @@ -172,18 +169,18 @@ def _overlaps(draft: dict[str, Any]) -> list[Finding]: } for index, key in enumerate(keys) for other in keys[index + 1 :] - # Of the same source, since the same lines of two documents are not the same - # lines, as `in2lambda.draft.record` compares them. + # Of the same source, because the same line numbers in two documents are + # different 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"]) ] def _gaps(draft: dict[str, Any]) -> list[Finding]: - """Questions or parts numbered past one that was never written. + """Questions or parts numbered past a number that no command wrote. - Numbers are given out by `in2lambda.draft._next`, which leaves no gap, so this too is - a draft that was edited: a question renumbered, or one deleted out of the middle. + `in2lambda.draft._next` gives the numbers out and leaves no gap, so this check too + reports a draft edited by hand: a question renumbered, or one deleted from the middle. """ numbered: dict[str, list[int]] = {} for key in draft["fields"]: @@ -205,12 +202,12 @@ def _gaps(draft: dict[str, Any]) -> list[Finding]: def _without_solutions(draft: dict[str, Any]) -> list[Finding]: - """Parts, and questions written without any, that nothing in the draft answers. + """Parts, and questions written without parts, that nothing in the draft answers. - A part is answered by its own solution or by the solution of the question it belongs - to, since a sheet often writes one worked solution covering every part at once. A - question with parts is answered through them and is not reported itself; one with - none is a question in its own right, and is reported where nothing answers it. + A part is answered by its own solution, or by the solution of the question it belongs + to, because a sheet often writes one worked solution covering every part. A question + with parts is answered through its parts and is not reported. A question without parts + is reported where nothing answers it. """ fields = draft["fields"] found = [] @@ -250,7 +247,7 @@ def _without_solutions(draft: dict[str, Any]) -> list[Finding]: def _empty(draft: dict[str, Any]) -> list[Finding]: - """Fields holding nothing, which is a quotation of the wrong lines or of none.""" + """Fields holding nothing, which quote the wrong lines or no lines at all.""" return [ { "check": "empty", @@ -271,11 +268,10 @@ def checks(draft: dict[str, Any]) -> list[Finding]: draft: A draft, as `in2lambda.source.frozen` reads one. Returns: - One :data:`Finding` per thing found, earliest line first and then by what it is - about, with the findings about no particular line last. An empty list means the - draft covers its source once each, with nothing missing from its numbering; a - list holding only warnings is one `in2lambda.draft.export.build` says and - exports over. + One :data:`Finding` per fault, earliest line first and then by the field each one + is about, with the findings about no particular line last. An empty list means the + draft covers its source once over, with no hole in its numbering. A list holding + only warnings is a list `in2lambda.draft.export.build` prints and exports over. Examples: >>> from in2lambda.draft.report import checks @@ -299,7 +295,7 @@ def checks(draft: dict[str, Any]) -> list[Finding]: def _order(finding: Finding) -> tuple[int | float, str]: - """Where a finding goes in a report: earliest line first, then by what it is about.""" + """Where a finding sorts in a report: earliest line first, then by field.""" return ( finding["ranges"][0][0] if finding["ranges"] else _UNPLACED, finding["field"], @@ -313,8 +309,8 @@ def errors(findings: list[Finding]) -> list[Finding]: findings: A report, as :func:`checks` or :func:`validate` writes one. Returns: - Those at level :data:`ERROR`, in the order they were reported. The rest are - warnings, which `in2lambda.draft.export.build` says and exports anyway. + The findings at level :data:`ERROR`, in the order they were reported. The rest + are warnings, which `in2lambda.draft.export.build` prints before writing the set. Examples: >>> from in2lambda.draft.report import errors @@ -328,36 +324,35 @@ def errors(findings: list[Finding]) -> list[Finding]: def problems(draft: dict[str, Any], directory: str = ".") -> list[Finding]: """What `in2lambda.validation` finds in the set the draft describes. - The draft is exported as it stands and the set checked over - maths delimiters, - what KaTeX will not render, images the export would not carry, and the compile - Lambda Feedback's PDF generator does - so that a question that will not render is - reported while the draft is being written rather than after it is uploaded. + :func:`problems` exports the draft as it stands and checks the set: maths delimiters, + expressions KaTeX will not render, images the export would not carry, and the compile + Lambda Feedback's PDF generator performs. A question that will not render is reported + while the draft is being written, before the set is uploaded. Args: draft: A draft, as `in2lambda.source.frozen` reads one. - directory: Where the draft is, and so what the images it names are beside. + directory: Where the draft is, and so where the images it names sit. Returns: - One :data:`Finding` per problem, named by the field of the draft it is in - rather than by the question and part of the export, so that a line of it can be - acted on with `field replace`. A problem about no one field - the set as a - whole failing to compile - keeps the validator's own naming of where it is. - All of them are at level :data:`ERROR`: what Lambda Feedback will not render is - not something to upload. + One :data:`Finding` per problem, named by the field of the draft holding it and + not by the question and part of the export, so that a line of the report can be + acted on with `field replace`. A problem about no one field, such as the set + failing to compile, keeps the validator's own name for where it is. Every finding + is at level :data:`ERROR`, because Lambda Feedback will not render what they name. Warns: UserWarning: pandoc or xelatex is not installed, so the set was not compiled. """ where = located(draft) if not where: - # A draft with no question in it yet describes an empty set, which has nothing - # to find and is not worth a xelatex run to find it in. + # A draft holding no question describes an empty set, which holds no problem to + # find and does not merit a xelatex run. return [] missing = pdf.missing_tools() if missing: - # As `_katex_rejections` does without Node: a check that cannot be run here says - # what to install and leaves the rest of the report alone. + # As `_katex_rejections` does without Node.js: a check that cannot run names the + # packages to install and leaves the rest of the report alone. warnings.warn( "The set the draft describes was not compiled as the PDF generator would: " "install " + " and ".join(missing), @@ -375,8 +370,8 @@ def problems(draft: dict[str, Any], directory: str = ".") -> list[Finding]: if location: key = where[location] ranges = fields[key]["ranges"] - # Whatever the location says past the field: KaTeX names the characters of - # it that it stopped at, and those are the field's characters here as well. + # Whatever the location says past the field: KaTeX names the characters it + # stopped at, and those are characters of the field. rest = problem.location[len(location) :] finding = { "check": "problem", @@ -394,34 +389,33 @@ def problems(draft: dict[str, Any], directory: str = ".") -> list[Finding]: "message": str(problem), } if finding not in found: - # A question's solution answers every part of it that has no solution of its - # own, so one fault in it is found once per part. They are the same field, - # the same lines and the same wording: a second line of the report saying so - # is a `field replace` that would be refused for finding nothing to replace. + # A question's solution answers every part that has no solution of its own, + # so one fault in that solution is found once per part. Each finding names + # the same field, the same lines and the same wording, and a second line of + # the report would send the author to a `field replace` with nothing left to + # replace. found.append(finding) return found def validate(draft: str | Path) -> list[Finding]: - """Checks a draft over and writes the report into it. + """Checks a draft and writes the report into it. - The report replaces whatever one is there, and is dropped again by the next command - that changes the draft: it describes the draft as it stood, and a report saying - something else is worse than none at all. + The report replaces the report already there, and the next command that changes the + draft deletes it, because the report describes the draft as it stood. Args: draft: The path of the draft to check. Returns: - What the checks and `in2lambda.validation` found, as it was written into the - draft. + What the checks and `in2lambda.validation` found, as written into the draft. Raises: DraftMissing: there is no draft at that path. - DraftUnreadable: what is there is not a draft anything here wrote. + DraftUnreadable: the file at that path is not a draft in2lambda wrote. 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. + the lines the report names would not be the lines it was written about. Warns: UserWarning: a check could not be run here - see :func:`problems`. diff --git a/in2lambda/json_convert/json_convert.py b/in2lambda/json_convert/json_convert.py index 503b0ee..fccb907 100644 --- a/in2lambda/json_convert/json_convert.py +++ b/in2lambda/json_convert/json_convert.py @@ -24,22 +24,23 @@ def _image_for(reference: str, images: list[str]) -> Optional[str]: - """Which of a question's images a markdown reference names, if any. + """Which of a question's images a markdown reference names. - Matched by file name, because that is the link between the two: a filter resolves - the very path it leaves in the markdown, and Lambda Feedback finds an image in - ``media/`` by its file name alone. Only where a question lists two files of the same - name does the rest of the reference decide, by naming the end of one of their paths. + An image is matched by file name, which is the link between a reference and a file: a + filter resolves the path it writes into the markdown, and Lambda Feedback finds an + image in ``media/`` by its file name alone. Where a question lists two files of the + same name, the rest of the reference decides, by naming the end of one of their paths. Returns: - The image, or None if the question lists none of that name - in which case the - reference is left as written, which :mod:`in2lambda.validation` reports. + The image, or None where the question lists no image of that name. The writer + then writes the reference as it stands, and :mod:`in2lambda.validation` reports + it. """ named = [image for image in images if Path(image).name == Path(reference).name] if len(named) > 1: - # A filter keeps the reference as the document wrote it but normalises the path - # it lists beside it, so a `..` is present on one side only and has to come off - # for the two to line up. A `.` is already gone, dropped by ``pathlib``. + # A filter writes the reference as the document wrote it and normalises the path + # it lists beside it, so a `..` appears on one side only and comes off for the + # two to match. ``pathlib`` has already dropped a `.`. parts = tuple(part for part in Path(reference).parts if part != "..") named = [ image for image in named if Path(image).parts[-len(parts) :] == parts @@ -53,7 +54,7 @@ def _templates() -> tuple[dict[str, Any], dict[str, Any]]: Returns: The question template and the set template. """ - # Use path so minimal template can be found regardless of where the user is running python from. + # An absolute path, so that the templates are found whatever the working directory is. with open(Path(__file__).with_name(MINIMAL_QUESTION_TEMPLATE), "r") as file: question_template = json.load(file) @@ -66,16 +67,16 @@ def _templates() -> tuple[dict[str, Any], dict[str, Any]]: def _zip(files: list[Path], root: Path, zip_path: str) -> None: """Zips the given files, keeping where they sit relative to a folder. - Only what this run wrote is listed, so whatever else the folder holds is neither - uploaded nor removed. + The archive lists only the files this run wrote, so whatever else the folder holds is + left out of the upload and left on disk. Args: files: The files to include, all inside root. root: The folder the archive names are relative to. - zip_path: The path where the zip file will be created. + zip_path: The path to create the zip file at. """ - # Sort by archive name for deterministic, alphabetical order, and name each file - # once: a file written twice is still one file on disk. + # Sorted by archive name, so that the zip lists its files in one order, and each file + # is named once: a file written twice is one file on disk. names = sorted({str(file.relative_to(root)): file for file in files}.items()) with zipfile.ZipFile(zip_path, "w") as zf: for name, file in names: @@ -207,9 +208,9 @@ def _question_title(question: Question, i: int) -> str: def _question_stem(i: int, title: str) -> str: - # Lambda Feedback names the file after the title with only spaces made - # underscores. Path separators go too, so a title cannot leave the set folder, - # and so do the characters Windows forbids in file names. + # Lambda Feedback names the file after the title, with spaces replaced by + # underscores. Path separators are replaced too, so that a title cannot leave the set + # folder, as are the characters Windows forbids in a file name. return ( "question_" + str(i).zfill(3) @@ -232,8 +233,8 @@ def _question_json( output["displayWorkedSolution"] = question.display_worked_solution output["displayStructuredTutorial"] = question.display_structured_tutorial output["displayChatbot"] = question.display_chatbot - # Unset optional settings are omitted rather than given a value Lambda Feedback - # never chose. + # An unset optional setting is left out of the JSON, so that in2lambda writes no + # value the author did not choose. for key, value in { "skill": question.skill, "guidance": question.guidance, @@ -253,10 +254,10 @@ def _question_json( def _media_name(image: str, stem: str, taken: set[str]) -> str: - """What an image is called in ``media/``, which is flat and so has one of each name. + """The name an image is written under in ``media/``, which is one flat folder. - Its own file name, or, where that name is another file's already, the name Lambda - Feedback's own exports give an image: the question's, numbered. + The image's own file name, or, where another file holds that name, the name Lambda + Feedback's own exports give an image: the question's name, numbered. """ name = Path(image).name if name not in taken: @@ -270,10 +271,11 @@ def _media_name(image: str, stem: str, taken: set[str]) -> str: def _with_media_names(value: Any, question: Question, media: dict[str, str]) -> Any: """A question's JSON with every image reference in it rewritten to its media name. - Walked rather than taken field by field because a reference can be written in any - markdown the question holds - its text, a part's, a worked solution, a final answer, - an answer box's wording or one of its options - and a second list of those here would - drift from the one :mod:`in2lambda.validation` already checks. + This function walks the JSON instead of reading named fields, because a reference can + be written in any markdown the question holds: its text, a part's text, a worked + solution, a final answer, an answer box's wording or one of its options. A second + list of those fields here would drift from the list :mod:`in2lambda.validation` + already checks. """ if isinstance(value, dict): return { @@ -288,7 +290,7 @@ def rewrite(reference: re.Match[str]) -> str: image = _image_for(reference[1], question.images) if image is None: return reference[0] - # Only the path is replaced; the alt text beside it may well read the same. + # Only the path is replaced, because the alt text beside it may read the same. name = media[os.path.abspath(image)] return reference[0][: reference.start(1) - reference.start()] + name + ")" @@ -309,10 +311,10 @@ def _write_question( i: Its order number, which also prefixes the file name. template: The loaded JSON from the minimal question template. folder: The folder to write into. - media: What the export has carried into ``media/`` so far, each image's path on - disk against the name it was written under. Added to as this question's - images are copied, so that a file two questions use is one file under one - name. + media: The images the export has copied into ``media/`` so far, each image's path + on disk against the name it was written under. This function adds the + question's images to it, so that a file two questions use is one file under + one name. Returns: The files written. @@ -326,7 +328,7 @@ def _write_question( if path in media: continue media[path] = _media_name(path, stem, set(media.values())) - # Only a question with an image gets a media folder at all. + # A media folder is created only for a question that holds an image. (folder / "media").mkdir(exist_ok=True) written.append(Path(shutil.copy(path, folder / "media" / media[path]))) @@ -340,9 +342,9 @@ def _write_question( def write_question(question: Question, output_dir: str, number: int = 0) -> None: """Writes a single question as its own Lambda Feedback import. - The question gets a folder named after it, holding its JSON and its images under - ``media``, and a zip of that folder. There is no set file: this is what Lambda - Feedback takes when importing one question into a set that already exists. + The question is written to a folder named after it, holding the question's JSON and + its images under ``media``, and to a zip of that folder. The folder holds no set + file, because Lambda Feedback imports one question into a set that already exists. Args: question: The question to write. @@ -368,16 +370,15 @@ def converter( """Turns a set of question objects into Lambda Feedback JSON. Args: - question_template: The loaded JSON from the minimal question template (it needs to be in sync). - set_template: The loaded JSON from the minimal set template (it needs to be in sync). - SetQuestions: A Set object containing questions. - output_dir: The absolute path for where to produced the final JSON/zip files. + question_template: The JSON loaded from the minimal question template. + set_template: The JSON loaded from the minimal set template. + SetQuestions: The set of questions to write. + output_dir: Where to write the JSON and zip files. """ ListQuestions = SetQuestions.questions set_name = SetQuestions._name set_description = SetQuestions._description - # create directory to put the questions os.makedirs(output_dir, exist_ok=True) output_question = os.path.join(output_dir, set_name) os.makedirs(output_question, exist_ok=True) @@ -393,30 +394,26 @@ def converter( set_template["structuredTutorialVisibility"] = str( SetQuestions._structuredTutorialVisibility.status ) - # create the set file folder = Path(output_question) set_file = folder / f"set_{set_name}.json" with open(set_file, "w") as file: json.dump(set_template, file) written = [set_file] - # Named across the whole set, since media/ is one folder for all of its questions. + # Named across the whole set, because media/ is one folder for every question. media: dict[str, str] = {} for i, question in enumerate(ListQuestions): written += _write_question(question, i, question_template, folder, media) - # output zip file in destination folder _zip(written, folder, output_question + ".zip") def main(set_questions: Set, output_dir: str) -> None: - """Loads the templates and calls the main converter function. - - This ultimately then produces the Lambda Feedback JSON/ZIP files. + """Loads the templates and writes the set as Lambda Feedback JSON and a zip. Args: - set_questions: A Set object containing questions. - output_dir: Where to output the final Lambda Feedback JSON/ZIP files. + set_questions: The set of questions to write. + output_dir: Where to write the JSON and zip files. """ question_template, set_template = _templates() converter(question_template, set_template, set_questions, output_dir) @@ -428,9 +425,9 @@ def load(path: str) -> Set: That is the set's name, description and visibilities, and each question's title, main text, parts, worked solutions, images and settings. - A zip is extracted to a new temporary directory, which is left for the operating - system to clear: the loaded images point into it and must still exist when the - set is written out. + A zip is extracted into a new temporary directory, which the operating system clears: + the loaded images point into that directory and must exist when the set is written + out. Args: path: An exported set, as a folder or a zip, with or without a top-level folder. @@ -439,7 +436,7 @@ def load(path: str) -> Set: The set, with each question's images as absolute paths into ``media/``. Raises: - ValueError: If the export does not hold exactly one ``set_*.json``. + ValueError: the export does not hold one ``set_*.json``. """ root = Path(path) if root.suffix == ".zip": @@ -485,8 +482,8 @@ def load(path: str) -> Set: else "" ), answer=part["answerContent"], - # Exports do not always list areas in order; an area's contentAfter - # leads into the one numbered after it. + # An export does not always list areas in order, and an area's + # contentAfter leads into the area numbered after it. response_areas=[ _response_area_from_json(area) for area in sorted( @@ -506,9 +503,9 @@ def load(path: str) -> Set: for image in media if image.name.startswith(f"{question_file.stem}_") ], - # Every loaded part already has its text and solution, so further - # add_part_text/add_solution calls must add parts after them rather - # than overwrite the first. + # Every loaded part holds its text and its solution, so a later + # add_part_text or add_solution call appends a part instead of + # overwriting the first. _last_part={"solution": len(parts), "text": len(parts)}, skill=question_json.get("skill"), guidance=question_json.get("guidance"), diff --git a/in2lambda/main.py b/in2lambda/main.py index 3452411..c92b3b8 100644 --- a/in2lambda/main.py +++ b/in2lambda/main.py @@ -1,10 +1,4 @@ -"""The main input for in2lambda, defining both the CLT and main library function.""" - -# This commented block makes it run the local files rather than the pip library (I think, I don't understand it. Kevin wrote it.) -# -# import sys -# import os -# sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +"""The in2lambda command line, and the library function behind `in2lambda convert`.""" import getpass import importlib @@ -23,9 +17,9 @@ import in2lambda.source from in2lambda.api.set import Set -# All four are in other people's scripts as in2lambda.main names, whether or not they -# are used here: `_pandoc` and `file_type` were defined here before there was an -# in2lambda.source, and `ConversionToolsMissing` is what `runner` documents raising. +# Other people's scripts import all four as in2lambda.main names: `_pandoc` and +# `file_type` were defined here before in2lambda.source existed, and `runner` documents +# raising `ConversionToolsMissing`. from in2lambda.source import ( # noqa: F401 # Re-exported, so not unused. ConversionToolsMissing, SourceError, @@ -38,11 +32,11 @@ @contextmanager def _message_not_traceback(): # No annotation: beartype 0.22 on 3.10 checks the # decorated object, a _GeneratorContextManager, against a generator hint. - """Turns anything raised for a reader into what to do about it and a non-zero exit. + """Turns a `SourceError` into a message and a non-zero exit. - Every command wraps whatever it calls in this: a missing pandoc, a draft from - somewhere else, a source that has moved on are all things the person running it can - act on, and none of them are worth a traceback. + Every command wraps its call in this. A missing pandoc, a draft another tool wrote + and a source that has changed are faults the person running the command can act on, + and a traceback tells them less than the message does. """ try: yield @@ -52,11 +46,12 @@ def _message_not_traceback(): # No annotation: beartype 0.22 on 3.10 checks the @contextmanager def _warnings_said(): # Unannotated for the same reason as _message_not_traceback. - """Echoes whatever is warned inside it as a line, as `runner` says its problems. + """Echoes each warning raised inside it as a line, as `runner` prints its problems. - What `build` and `render` warn about is something they wrote out anyway - a question - nothing answers, a question xelatex gave up on - so it belongs beside what they - wrote, and is said even where the command goes on to refuse for another reason. + `build` and `render` warn about a question they wrote out all the same - a question + nothing answers, a question xelatex gave up on - so each warning is printed beside + what the command wrote, and is printed where the command goes on to refuse for + another reason. """ with warnings.catch_warnings(record=True) as said: warnings.simplefilter("always") @@ -68,13 +63,13 @@ def _warnings_said(): # Unannotated for the same reason as _message_not_traceba def docx_to_md(docx_file: str) -> str: - """Converts .docx files to markdown. + """Converts a .docx file to markdown. Args: - docx_file: A file path with the file extension included. + docx_file: A file path, including the file extension. Returns: - the contents of the .docx file in markdown formatting + The contents of the .docx file as markdown. """ return _pandoc(docx_file, "markdown").decode("utf-8") @@ -85,18 +80,17 @@ def runner( output_dir: Optional[str] = None, answer_file: Optional[str] = None, ) -> Set: - r"""Takes in a TeX file for a given subject and outputs how it's broken down within Lambda Feedback. + r"""Converts a question document into the set Lambda Feedback imports. Args: - question_file: The absolute path to a TeX question file. - chosen_filter: The filter chosen to parse the TeX file. - output_dir: An optional argument for where to output the Lambda Feedback compatible json/zip files. - answer_file: The absolute path to a TeX answer file. + question_file: The absolute path to a question file. + chosen_filter: The filter that parses the document. + output_dir: Where to write the JSON and zip files. None writes no files. + answer_file: The absolute path to a file of answers. Returns: - A list of questions and how they would be broken down into different Lambda Feedback sections - in a Python-readable format. If `output_dir` is specified, the corresponding json/zip files are - produced. + The set the document describes, as questions holding parts. Where `output_dir` is + given, `runner` also writes the JSON and zip files. Raises: ConversionToolsMissing: pandoc or panflute is not installed. @@ -113,14 +107,12 @@ def runner( _require_conversion_tools() import panflute as pf - # The list of questions for Lambda Feedback as a Python API. set_obj = Set() - # Dynamically import the correct pandoc filter depending on the subject. + # The filter is named on the command line, so it is imported by name. filter_module = importlib.import_module(f"in2lambda.filters.{chosen_filter}.filter") if file_type(question_file) == "docx": - # Convert .docx to md using Pandoc and proceed text = docx_to_md(question_file) input_format = "markdown" else: @@ -129,7 +121,6 @@ def runner( input_format = file_type(question_file) - # Parse the Pandoc AST using the relevant panflute filter. pf.run_filter( filter_module.pandoc_filter, doc=pf.convert_text(text, input_format=input_format, standalone=True), @@ -138,7 +129,6 @@ def runner( parsing_answers=False, ) - # If separate answer TeX file provided, parse that as well. if answer_file: if file_type(answer_file) == "docx": answer_text = docx_to_md(answer_file) @@ -158,10 +148,10 @@ def runner( parsing_answers=True, ) - # Report before writing anything: the problems are the set's whether or not it is - # written out, and an author reading the command line should see them first. A check - # that could not be run at all - the maths, with no Node.js to render it - warns - # instead, and is caught here so that it reads as a line rather than a traceback. + # Reported before anything is written: the problems belong to the set whether or not + # in2lambda writes it out, and an author reads them first. A check that could not run + # at all - the maths, with no Node.js to render it - warns instead, and is caught + # here so that it prints as a line and not as a traceback. with warnings.catch_warnings(record=True) as not_checked: warnings.simplefilter("always") problems = set_obj.problems() @@ -170,7 +160,6 @@ def runner( for warning in not_checked: click.echo(f"Warning: {warning.message}") - # Read the Python API format and convert to JSON. if output_dir is not None: set_obj.to_json(output_dir) @@ -178,14 +167,14 @@ def runner( class _Cli(click.RichGroup): - """The in2lambda group, which says what to run when given the pre-2.0 command line.""" + """The in2lambda group, which names the command to run for a pre-2.0 command line.""" def resolve_command(self, ctx, args): # type: ignore[no-untyped-def] - """Fail with the new command line rather than click's handling of an unknown name. + """Refuses an unknown command by naming the command to run. Click resolves a first argument starting with ``/`` or ``.`` by printing the - group's help and exiting successfully, so `in2lambda /path/to/questions.tex - PartsSepSol` would look like it had worked while converting nothing. + group's help and exiting 0, so `in2lambda /path/to/questions.tex PartsSepSol` + would read as a successful run that converted nothing. """ # Shell completion resolves partial command lines, and must not raise. if not ctx.resilient_parsing and self.get_command(ctx, args[0]) is None: @@ -205,10 +194,9 @@ def cli() -> None: @cli.command() -@click.argument( # Use resolve_path to get absolute path +@click.argument( "question_file", type=click.Path(exists=True, readable=True, resolve_path=True) ) -# Python files in the subjects directory @click.argument( "chosen_filter", type=click.Choice(in2lambda.filters.builtin_filters(), case_sensitive=False), @@ -219,7 +207,7 @@ def cli() -> None: "output_dir", default="./out", show_default=True, - help="Directory to output json/zip files to.", + help="Directory to write the JSON and zip files to.", type=click.Path(resolve_path=True), ) @click.option( @@ -227,21 +215,21 @@ def cli() -> None: "-a", "answer_file", default=None, - help="File containing solutions for QUESTION_FILE.", + help="File holding the solutions to QUESTION_FILE.", type=click.Path(resolve_path=True, exists=True, dir_okay=False), ) def convert( question_file: str, chosen_filter: str, output_dir: str, answer_file: Optional[str] ) -> None: - """Takes in a QUESTION_FILE for a given SUBJECT and produces Lambda Feedback compatible json/zip files.""" - # main() is made separate from click() so that it can be easily imported as part of a library. + """Converts QUESTION_FILE with CHOSEN_FILTER into Lambda Feedback JSON and a zip.""" + # `runner` is separate from this command so that a script can import it. with _message_not_traceback(): runner(question_file, chosen_filter, output_dir, answer_file) @cli.group("source") def source_group() -> None: - """Freezes the source documents of a draft, so their text can be quoted by line range.""" + """Freezes the source documents of a draft, so that their text can be quoted.""" _draft = click.option( @@ -250,7 +238,7 @@ def source_group() -> None: 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.""" +"""Which draft a command works on, because a folder of sheets holds a draft per sheet.""" @source_group.command("add") @@ -274,13 +262,12 @@ def source_group() -> None: 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. + The draft is named after the first file - questions.draft.json - unless --draft names + the draft 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. Naming an existing draft with --draft adds a file to + that draft as its next source. The first source's blocks and lines are named b3 and + s10:14, and 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) @@ -297,7 +284,7 @@ def source_show(draft: Optional[str]) -> None: @cli.group("draft") def draft_group() -> None: - """Builds up a draft, recording every command in it.""" + """Builds a draft up, recording every command in the draft.""" _by = click.option( @@ -305,7 +292,7 @@ def draft_group() -> None: default=getpass.getuser, help="Who to record the command as having been run by. [default: your username]", ) -"""Who ran a draft command, which every one of them records.""" +"""Who ran a draft command, which every draft command records.""" def _text_or_literal(command: Callable[..., None]) -> Callable[..., None]: @@ -313,14 +300,14 @@ def _text_or_literal(command: Callable[..., None]) -> Callable[..., None]: for option in ( click.option( "--literal", - help="The text itself, where the source does not say it in a form the " - "field can take. Marks the field as edited.", + help="The text itself, for wording the source does not hold in a form the " + "field takes. Marks the field as edited.", ), click.option( "--text", - help="Where in a frozen source the text is: a block id such as b3, or " + help="Where the text is in a frozen source: 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.", + "- for any source after the first. Run in2lambda source show to see both.", ), ): command = option(command) @@ -328,10 +315,11 @@ def _text_or_literal(command: Callable[..., None]) -> Callable[..., None]: def _run(command: str, args: dict[str, Any], by: str, draft: Optional[str]) -> None: - """Runs one draft command against the draft asked for and says what it wrote. + """Runs one draft command against the draft named, and prints what it wrote. - Arguments nobody gave are left out rather than recorded as nulls: the log is what a - replay runs, and an option that was not passed is not an argument of the command. + An argument the reader did not give is left out of the log, and is not recorded as + null: a replay runs the log, and an option nobody passed is not an argument of the + command. """ with _message_not_traceback(): written = in2lambda.draft.execute( @@ -349,7 +337,7 @@ def _run(command: str, args: dict[str, Any], by: str, draft: Optional[str]) -> N @draft_group.group("mark") def draft_mark() -> None: - """Says what to make of a block of the frozen source.""" + """Marks a block of the frozen source.""" @draft_mark.command("ignore") @@ -357,13 +345,13 @@ def draft_mark() -> None: @_by @_draft def draft_mark_ignore(block: str, by: str, draft: Optional[str]) -> None: - """Marks BLOCK as nothing to take a question from.""" + """Marks BLOCK as holding no question, part or solution.""" _run("mark ignore", {"block": block}, by, draft) @draft_group.group("question") def draft_question() -> None: - """Adds a question to the draft, or says where its solution is written.""" + """Adds a question to the draft, or names where its solution is written.""" @draft_question.command("add") @@ -373,7 +361,7 @@ def draft_question() -> None: def draft_question_add( text: Optional[str], literal: Optional[str], by: str, draft: Optional[str] ) -> None: - """Adds a question, numbered after the ones already there.""" + """Adds a question, numbered after the questions already written.""" _run("question add", {"text": text, "literal": literal}, by, draft) @@ -415,7 +403,7 @@ def draft_part_add( by: str, draft: Optional[str], ) -> None: - """Adds a part of QUESTION, numbered after the parts it already has.""" + """Adds a part to QUESTION, numbered after the parts QUESTION already holds.""" _run( "part add", {"question": question, "text": text, "literal": literal}, by, draft ) @@ -423,7 +411,7 @@ def draft_part_add( @draft_group.group("split") def draft_split() -> None: - """Cuts up a block of the frozen source that is really two things.""" + """Cuts a block of the frozen source that holds two things.""" @draft_split.command("block") @@ -438,7 +426,7 @@ def draft_split_block(block: str, at: int, by: str, draft: Optional[str]) -> Non @draft_group.group("field") def draft_field() -> None: - """Changes the wording of a field the draft has written already.""" + """Changes the wording of a field the draft already holds.""" @draft_field.command("replace") @@ -448,14 +436,14 @@ def draft_field() -> None: @click.option( "--regex", is_flag=True, - help="Read OLD as a regular expression, and NEW as what to replace it with.", + help="Read OLD as a regular expression, and NEW as the replacement.", ) @_by @_draft def draft_field_replace( field: str, old: str, new: str, regex: bool, by: str, draft: Optional[str] ) -> None: - """Replaces OLD with NEW in FIELD, which OLD has to occur exactly once in.""" + """Replaces OLD with NEW in FIELD, where OLD occurs once.""" _run( "field replace", {"field": field, "old": old, "new": new, "regex": True if regex else None}, @@ -467,7 +455,7 @@ def draft_field_replace( @draft_group.command("replay") @_draft def draft_replay(draft: Optional[str]) -> None: - """Rebuilds a draft from its log and checks it is the same.""" + """Rebuilds a draft from its log and checks the result matches.""" with _message_not_traceback(): in2lambda.draft.replay(in2lambda.source.find(draft)) click.echo("Replays as it stands.") @@ -479,13 +467,13 @@ def spec_group() -> None: @spec_group.command("run") -# Named from the draft's directory rather than from here, which is where `spec_command` -# looks for it and how the log records it, so click is not the one to check it is there. +# The spec is named from the draft's directory, which is where `spec_command` reads it +# and how the log records it, so click does not check that the file is there. @click.argument("spec") @_by @_draft def spec_run(spec: str, by: str, draft: Optional[str]) -> None: - """Fills the draft's fields in from SPEC, and says which blocks it left out.""" + """Fills the draft's fields in from SPEC, and reports the blocks left in no field.""" with _message_not_traceback(): path = in2lambda.source.find(draft) report = in2lambda.draft.execute( @@ -497,23 +485,23 @@ def spec_run(spec: str, by: str, draft: Optional[str]) -> None: @cli.command("validate") @_draft def validate(draft: Optional[str]) -> None: - """Checks a draft over and writes the report into it. + """Checks a draft and writes the report into it. Reports source blocks in no field and not marked ignore, two fields taken from the same lines, gaps in the numbering of the questions or their parts, and fields holding - nothing. The set the draft describes is checked over as well - maths delimiters, what + nothing. The set the draft describes is checked as well - 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 - each against - the field it is written in. All of those in2lambda build refuses; a question or part - nothing answers is reported as a warning, which it builds over. Finding something is - not a failure: the report is written into the draft either way, and replaced by the - next one. + Feedback's PDF generator performs where pandoc and xelatex are installed - each + against the field holding it. in2lambda build refuses every one of those. A question + or part nothing answers is reported as a warning, which in2lambda build prints before + writing the set. A finding is not a failure: the report is written into the draft, + and the next run of the checks replaces it. """ with _message_not_traceback(): report = in2lambda.draft.report.validate(in2lambda.source.find(draft)) for finding in report: - # Marked as such, since the two are acted on differently and the report is often - # read off the terminal rather than out of the draft. + # Marked in the output, because a warning and an error are acted on differently + # and the report is often read from the terminal and not from the draft. if finding["level"] == in2lambda.draft.report.WARNING: click.echo(f"Warning: {finding['message']}") else: @@ -531,7 +519,7 @@ def validate(draft: Optional[str]) -> None: help="Directory to write the files to.", type=click.Path(resolve_path=True), ) -"""Where what a command makes is written, as `convert` has always taken it.""" +"""Where a command writes its files, named as `convert` has always named it.""" @cli.command("build") @@ -540,10 +528,10 @@ def validate(draft: Optional[str]) -> None: def build(output_dir: str, draft: Optional[str]) -> None: """Writes a draft out as a Lambda Feedback set. - Refused unless in2lambda validate has been run since the draft last changed and - found no error, so that what is uploaded is what the checks have been over. What it - found at level warning - a question or part with no solution written for it - is - said, and the set written all the same. + Refused unless in2lambda validate has run since the draft last changed and found no + error, so that the set uploaded is the set the checks have read. A finding at level + warning - a question or part with no solution written for it - is printed, and the + set is written all the same. """ with _message_not_traceback(), _warnings_said(): written = in2lambda.draft.export.build( @@ -559,11 +547,11 @@ def render(output_dir: str, draft: Optional[str]) -> None: """Writes each question of a draft as a PDF, for review. The questions are compiled as Lambda Feedback's PDF generator compiles them, which - needs pandoc and xelatex. What the checks have to say about the draft is not asked: - a draft is rendered to look at, including one there is something to fix in. + needs pandoc and xelatex. in2lambda render does not read the draft's report: a draft + is rendered to be read, including a draft with something to fix in it. """ - # A question xelatex complains about is still written out, and what it refused is a - # line to read rather than a traceback. + # A question xelatex complains about is written out all the same, and what xelatex + # refused is printed as a line and not as a traceback. with _message_not_traceback(), _warnings_said(): written = in2lambda.draft.export.render( in2lambda.source.find(draft), output_dir=output_dir diff --git a/in2lambda/source/__init__.py b/in2lambda/source/__init__.py index 6d17258..e6e291c 100644 --- a/in2lambda/source/__init__.py +++ b/in2lambda/source/__init__.py @@ -1,22 +1,23 @@ -"""Freezes the source documents of a draft, so their text can be quoted by line range. +"""Freezes the source documents of a draft, so that 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 -address if the text it points into cannot move underneath it. So the document is frozen -once: converted to markdown, hashed, and written down beside a ``FILE.draft.json`` -listing every top-level block with the lines it spans. +A tool that writes questions from a document copies the wording out of the source, and a +line range identifies that wording only while the text does not change. So in2lambda +freezes the document once: :func:`add` converts it to markdown, hashes the markdown, and +writes a ``FILE.draft.json`` beside it listing every top-level block with the lines that +block spans. The draft is named after the source it was frozen from, so a folder holding a term's -worth of sheets holds a draft for each rather than one they take turns overwriting. +sheets holds one draft per sheet. -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. +A draft freezes several documents where a sheet is written as several files, such as the +questions in one file and the solutions in another. The sources are numbered in the order +they were frozen, and a block id or a line range of any source after the first carries +that source's number: ``2/b3``, ``2/s10:14``. The first source's ids and ranges are +written plain. -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. +Every function here needs pandoc, and the parsing needs panflute, which only the +``convert`` extra installs. :func:`add` raises :class:`ConversionToolsMissing` naming what +to install. """ import hashlib @@ -30,22 +31,22 @@ from typing import Any, Optional DRAFT_SUFFIX = ".draft.json" -"""What a frozen source is written to, beside the source itself and named after it.""" +"""The suffix of a draft, which is written beside its source and named after it.""" def _field_fault(field: Any) -> str: - """What is wrong with the shape of one field of a draft, or "" if nothing is. + """What is wrong with the shape of one field of a draft, or "" if nothing is wrong. - ``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 + A field is checked for ``ranges``, ``source`` and ``value``, because those are the + parts of a field this package 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. + `in2lambda.draft.report.checks` reports a field holding an empty value. The check + asks only whether a value is present, because the checks read a value as a string or + not at all. The layer, the edited flag and the editor are written and read back + whole, and `in2lambda draft replay` catches an edit to any of them 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. + A field quoted from the first source holds no ``source`` key, as every field of a + draft frozen from one document does. """ if not isinstance(field, dict): return "is not an object" @@ -66,31 +67,29 @@ def _field_fault(field: Any) -> str: _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 -through, which is what the refusal says. - -A draft `in2lambda validate` has been run on also has a ``report``, which is not required -and not looked into: nothing here reads one back, and the next run of the checks writes -whatever is there over. +"""The keys a draft holds, and so the keys a file must hold to be read as a draft. + +``sources`` holds one ``{source, hash, blocks}`` per frozen document, in the order they +were frozen. A draft written before a draft could hold more than one source holds those +three keys at the top level, and is refused as a draft in2lambda did not write: its ids +and ranges were written against a shape this package no longer reads. The refusal says to +freeze the document again. + +A draft written before ``log`` and ``fields`` existed holds neither, and is refused as a +draft in2lambda did not write: there is no command log to replay it from, and an empty log +would claim its fields came from nowhere. The refusal says to freeze the source again. + +A draft that `in2lambda validate` has been run on also holds a ``report``, which is not +required and not checked: nothing here reads a report back, and the next run of the checks +writes over it. """ _MARKDOWN = "commonmark_x" """The dialect the frozen markdown is written in, and read back as. -Writer and reader have to agree: pandoc's ``markdown`` writer emits fenced divs and -bracketed spans that a commonmark reader would take as ordinary text. ``commonmark_x`` -also covers the ``$...$`` maths and the ``{width=...}`` attributes a converted document -carries. +The writer and the reader must agree. Pandoc's ``markdown`` writer emits fenced divs and +bracketed spans that a commonmark reader reads as ordinary text. ``commonmark_x`` also +covers the ``$...$`` maths and the ``{width=...}`` attributes a converted document holds. """ _POSITION = re.compile(r"(?:[^@;]*@)?(\d+):\d+-(\d+):(\d+)") @@ -98,12 +97,11 @@ def _field_fault(field: Any) -> str: class SourceError(RuntimeError): - """Freezing or printing a source could not be done, for a reason worth printing. + """Freezing or printing a source failed, for a reason worth printing. - The command line turns any of these into a message and a non-zero exit, so - anything a reader could do something about - a draft from somewhere else, a file - that has moved - is raised as one of these rather than left as whatever the - standard library raised on the way past. + The command line prints the message of any of these and exits non-zero. A fault a + reader can act on, such as a draft another tool wrote or a file that has moved, is + raised as one of these. """ @@ -112,23 +110,23 @@ class ConversionToolsMissing(SourceError): class DraftExists(SourceError): - """A draft is already there and was not made from this version of the source.""" + """A draft is already there and was not written from this version of the source.""" class DraftMissing(SourceError): - """There is no draft where one was looked for.""" + """There is no draft at the path a command looked in.""" class ManyDrafts(SourceError): - """A directory holds more than one draft, so which was meant has to be said.""" + """A directory holds more than one draft, so the command cannot choose one.""" class DraftUnreadable(SourceError): - """There is a file where the draft goes, but it is not a draft.""" + """A file is where the draft goes, and that file is not a draft.""" class SourceUnreadable(SourceError): - """The markdown to read has moved, or is not text.""" + """The markdown to read has moved, or is not UTF-8 text.""" def draft_of(source: str | Path) -> Path: @@ -136,8 +134,8 @@ def draft_of(source: str | Path) -> Path: Args: source: The document that was or would be frozen, in any format :func:`add` - takes. A draft's own path is given back as it is, so that anything taking - one from a reader can take either. + takes. A draft's own path is returned unchanged, so that a command reading a + path from a reader accepts either. Returns: The path of that document's draft. @@ -156,20 +154,19 @@ def draft_of(source: str | Path) -> Path: def find(given: Optional[str] = None, directory: str = ".") -> Path: - """Which draft a command was asked to work on, or the one draft there is. + """The draft a command was asked to work on, or the one draft in a directory. Args: - given: What a reader named, as the draft or as the source it was frozen from, - or nothing to go by what is in `directory`. - directory: Where to look when nothing was named. + given: The draft a reader named, as the draft's path or as the path of the + source it was frozen from. None reads `directory` instead. + directory: Where to look when the reader named no draft. Returns: The path of the draft to read. Raises: - DraftMissing: nothing was named and there is no draft to fall back on. - ManyDrafts: nothing was named and there is more than one, so a folder of - sheets does not silently act on whichever sorts first. + DraftMissing: the reader named no draft and `directory` holds none. + ManyDrafts: the reader named no draft and `directory` holds several. """ if given is not None: return draft_of(given) @@ -183,7 +180,7 @@ def find(given: Optional[str] = None, directory: str = ".") -> Path: ) raise ManyDrafts( f"There is more than one draft in {where}: " - f"{', '.join(path.name for path in found)}. Say which with --draft." + f"{', '.join(path.name for path in found)}. Name one with --draft." ) @@ -191,7 +188,7 @@ def _require_conversion_tools() -> None: missing = [] if shutil.which("pandoc") is None: missing.append("pandoc (see https://pandoc.org/installing.html)") - # Both come from the one extra, so they are named together rather than twice over. + # panflute and pyyaml come from the one extra, so one hint names both packages. if absent := [ package for module, package in (("panflute", "panflute"), ("yaml", "pyyaml")) @@ -205,16 +202,16 @@ def _require_conversion_tools() -> None: def file_type(file: str) -> str: - """Determines which pandoc file format to use for a given file. + """The pandoc input format for a file, read from the file's extension. See https://github.com/jgm/pandoc/blob/bad922a69236e22b20d51c4ec0b90c5a6c038433/src/Text/Pandoc/Format.hs#L171 - (or any newer commit) for pandoc's supported file extensions. + (or any newer commit) for the extensions pandoc supports. Args: - file: A file path with the file extension included. + file: A file path, including the file extension. Returns: - An option in `pandoc --list-input-formats` that matches the given file type + The option of `pandoc --list-input-formats` that matches the extension. Examples: >>> from in2lambda.source import file_type @@ -245,36 +242,36 @@ def file_type(file: str) -> str: ): return "markdown" case "docx": - return "docx" # Pandoc doesn't seem to support .doc, and panflute doesn't like .docx. + return "docx" # Pandoc reads no .doc, and panflute does not read .docx. raise RuntimeError(f"Unsupported file extension: .{extension}") def _pandoc(file: str, to: str) -> bytes: """The given file, as pandoc writes it in the `to` format. - Undecoded, because what is written to disk and what is hashed have to be the same - bytes; whoever wants the text of it decodes it themselves. + The bytes are returned undecoded, because the file written to disk and the bytes + hashed must be the same. A caller wanting the text decodes them. """ return subprocess.check_output(["pandoc", file, "-f", file_type(file), "-t", to]) def _digest(data: bytes) -> str: - """How a frozen markdown is named in its draft, so that a change to it shows up. + """How a frozen markdown is named in its draft, so that a change to it is reported. - The bytes of the file, not the text they decode to: the draft is checked by whoever - is quoting the markdown, who has nothing but the file, and `sha256sum` on it has to - give the same answer whatever the line endings in it are. + The digest is of the bytes of the file, not of the text they decode to: whoever + quotes the markdown checks the draft against the file alone, and `sha256sum` on that + file must give the same answer whatever line endings the file holds. """ return f"sha256:{hashlib.sha256(data).hexdigest()}" def _source(path: Path) -> tuple[bytes, str]: - """A markdown file as bytes and as text, given it is still there and still text. + """A markdown file as bytes and as text, where the file is present and is text. - Both freezing and showing read one, and someone who has moved the file or saved it - in some other encoding wants telling which it was, not a traceback. The bytes are - what gets hashed, and `bytes.decode` rewrites no line endings, so the text still - has whatever the file has. + Both freezing and showing read a markdown file, and a reader who has moved the file + or saved it in another encoding is told which fault occurred. The bytes are what + :func:`_digest` hashes, and `bytes.decode` rewrites no line endings, so the text + holds the line endings the file holds. """ try: raw = path.read_bytes() @@ -293,12 +290,12 @@ def _source(path: Path) -> tuple[bytes, str]: def _draft(path: Path) -> dict[str, Any]: - """The draft at the given path, given that something here wrote it. + """The draft at the given path, where in2lambda wrote it. Raises: - DraftMissing: nothing is there at all. - DraftUnreadable: something is, but it is not JSON or it is not a draft. Either - way it is not this package's to read from or write over. + DraftMissing: there is no file at that path. + DraftUnreadable: the file is not JSON, or is not a draft. in2lambda neither + reads from nor writes over such a file. """ if not path.is_file(): raise DraftMissing( @@ -318,11 +315,11 @@ def _draft(path: Path) -> dict[str, Any]: ) from None if missing := [field for field in _FIELDS if field not in fields]: raise DraftUnreadable( - f"{path} is not a draft anything here wrote: it has no " - f"{' or '.join(missing)} in it. {advice}" + f"{path} is not a draft in2lambda wrote: it holds no " + f"{' or '.join(missing)}. {advice}" ) - # The one gate everything reading a draft passes through, so a hand-edited log or - # fields is refused here rather than as a TypeError from whatever iterated it. + # Every read of a draft passes through here, so a hand-edited log or fields is + # refused with a message instead of a TypeError from the code that iterates it. for field, shape, called in ( ("sources", list, "a list"), ("log", list, "a list"), @@ -330,45 +327,43 @@ def _draft(path: Path) -> dict[str, Any]: ): if not isinstance(draft[field], shape): raise DraftUnreadable( - f"{path} is not a draft anything here wrote: its {field} is " + f"{path} is not a draft in2lambda 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. + # `add` freezes a file or refuses, so in2lambda writes no empty list. A draft + # holding one has been edited by hand, and every id and range in it names a + # document that is no longer frozen. The rest of this package reads the first + # source as the one an unqualified id belongs to. 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}" + f"{path} is not a draft in2lambda wrote: its sources is empty, so it names " + f"no frozen document for its fields to have been quoted out of. {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"{path} is not a draft in2lambda 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( - f"{path} is not a draft anything here wrote: its fields has {key} " - f"that {fault}. {advice}" + f"{path} is not a draft in2lambda wrote: its fields holds {key}, which " + f"{fault}. {advice}" ) return draft def serialise(draft: dict[str, Any]) -> bytes: - """The bytes a draft is written as, which is the only form it is ever written in. + """The bytes a draft is written as, which is the only form a draft is written in. - Sorted, and bytes rather than text, so that the same draft is the same file: - replaying a command log has to reproduce the draft exactly, which it cannot do - if the key order depends on what order something happened to write the keys in, or - if the newlines depend on which machine wrote them. + The keys are sorted and the result is bytes, so that the same draft is the same + file. `in2lambda draft replay` reproduces a draft byte for byte, which fails if the + key order follows the order the keys were written in, or if the newlines follow the + machine that wrote them. """ return (json.dumps(draft, indent=2, sort_keys=True) + "\n").encode("utf-8") @@ -379,22 +374,22 @@ def save(path: Path, draft: dict[str, Any]) -> None: def frozen(draft: str | Path) -> tuple[dict[str, Any], list[str]]: - """A draft and the markdown of every source it names, still unmoved. + """A draft and the markdown of every source it names, where no source has changed. Args: draft: The path of the draft to read. Returns: - 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. + 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 named unqualified. Raises: DraftMissing: there is no draft at that path. - DraftUnreadable: what is there is not a draft anything here wrote. + DraftUnreadable: the file at that path is not a draft in2lambda wrote. 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. + message names the file that changed, because a draft may hold several. """ path = Path(draft) found = _draft(path) @@ -431,11 +426,11 @@ def to_dict(self) -> dict[str, str | int]: def _numbered(source: int, name: str) -> str: - """A block id or a line range as the source it names something in writes it. + """A block id or a line range as the source it names 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. + The first source writes them plain - ``b3``, ``s10:14`` - as every draft did when a + draft held one source. Every source after it prefixes its number and a slash, so + that an id or a range names the document it belongs to. """ return name if source == 1 else f"{source}/{name}" @@ -450,9 +445,8 @@ def blocks(markdown: str, source: int = 1) -> list[Block]: Returns: One :class:`Block` per block, numbered ``b1`` onwards. The blocks do not - overlap and every line of the document falls in at most one: a block that is - none of the types the agent quotes is still listed, as ``other``, rather than - leaving its lines unaddressable. + overlap, and every line of the document falls in at most one block. A block of a + type no command quotes is listed as ``other``, so that its lines have an id. Examples: >>> from in2lambda.source import blocks @@ -471,21 +465,19 @@ def blocks(markdown: str, source: int = 1) -> list[Block]: def dedented(text: str) -> str: r"""Some lines of a list item, with the item's own indentation off every one. - A field quoted out of a list item would otherwise carry the marker and the - continuation indent the markdown needed to hold it together, and four leading - spaces after a blank line are a code block wherever the field is rendered. + A field quoted out of a list item would otherwise hold the marker and the + continuation indent the markdown needs, and four leading spaces after a blank line + render as a code block. Args: - text: The lines as the source writes them, the first of them holding the - item's marker. + text: The lines as the source writes them, the first holding the item's marker. Returns: - The same lines with the marker off the first and as much of the same width - off each of the rest as it has to give, so that a list nested inside the item - keeps its own relative indent. Text whose first line has no marker on it comes - back unchanged, but a paragraph reading like one - ``A. Smith says`` - would be - dedented, so what this is called on is decided by the block's type rather than - by its text. + The same lines, with the marker off the first line and as much of the same width + off each line below as that line has to give, so that a list nested inside the + item keeps its relative indent. Text whose first line holds no marker is returned + unchanged. A paragraph that reads like a marker - ``A. Smith says`` - would be + dedented, so the caller decides by the block's type and not by its text. Examples: >>> from in2lambda.source import dedented @@ -509,9 +501,9 @@ def dedented(text: str) -> str: 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 - stringifies to - which the block alone does not say, so anything matching against - the source takes this and projects the blocks out of it, as :func:`blocks` does. + A selector matches on the element's type, its heading level and the text it + stringifies to, none of which a block records, so code matching against the source + calls this and projects the blocks out of the result, as :func:`blocks` does. """ import panflute as pf @@ -520,9 +512,9 @@ def _elements(markdown: str, source: int = 1) -> list[tuple[Block, Any]]: ) found = [span for element in document.content for span in _spans(element, pf)] # Where no blank line separates one block from the next - a list straight after a - # paragraph, a definition list - pandoc reports the first as running on into the - # second's first line, so no block is allowed to reach where the next one starts, - # nor past the end of the document. + # paragraph, a definition list - pandoc reports the first block as reaching into the + # second block's first line. So a block ends before the next block starts, and + # before the end of the document. limits = [start - 1 for _, start, _, _ in found[1:]] + [len(markdown.splitlines())] return [ (Block(_numbered(source, f"b{number}"), kind, start, min(end, limit)), element) @@ -535,16 +527,15 @@ def _elements(markdown: str, source: int = 1) -> list[tuple[Block, Any]]: def _spans(element, pf): # type: ignore[no-untyped-def] """The ``(type, start, end, element)`` quadruples one top-level element accounts for. - A list is several: the ticket asks for a list item, not a list, and an item spans - everything nested under it. The element given back is the one that block is, past - the Div `sourcepos` wraps it in, so that whatever matches on it matches on what an - author would call it. + A list accounts for several: a command quotes a list item, not a list, and an item + spans everything nested under it. The element returned is the block itself, past the + Div `sourcepos` wraps it in, so that a selector matches the element an author would + name. """ inner = _unwrapped(element, pf) if isinstance(inner, (pf.BulletList, pf.OrderedList)): - # An item with nothing in it - a lone bullet, which a .docx often has - holds - # no element to take a position from, so there is no range to give it and it - # is left out rather than guessed at. + # An empty item - a lone bullet, which a .docx often holds - carries no element + # with a position, so it has no range and is left out. return [ ("list item", _range(item.content[0])[0], _range(item.content[-1])[1], item) for item in inner.content @@ -554,10 +545,11 @@ def _spans(element, pf): # type: ignore[no-untyped-def] def _unwrapped(element, pf): # type: ignore[no-untyped-def] - """What an element is, past the Div that `sourcepos` wraps it in. + """An element, past the Div that `sourcepos` wraps it in. - Only elements that take attributes of their own (a heading, a table) carry - ``data-pos`` directly; pandoc wraps the rest in a Div to hang it on. + Only elements that take attributes of their own, such as a heading or a table, carry + ``data-pos`` directly. Pandoc wraps every other element in a Div to hang ``data-pos`` + on. """ if isinstance(element, pf.Div) and element.attributes.get("wrapper"): return element.content[0] @@ -565,11 +557,12 @@ def _unwrapped(element, pf): # type: ignore[no-untyped-def] def _kind(inner, pf) -> str: # type: ignore[no-untyped-def] - """Which of the ticket's block types an unwrapped element is.""" + """The :class:`Block` type an unwrapped element is.""" if isinstance(inner, pf.Header): return "heading" if isinstance(inner, (pf.Para, pf.Plain)): - # A paragraph holding nothing but one image, or one $$...$$, is that thing. + # A paragraph holding one image, or one $$...$$, and nothing else is typed as + # that element. contents = [ item for element in inner.content @@ -588,8 +581,8 @@ def _kind(inner, pf) -> str: # type: ignore[no-untyped-def] def _range(element) -> tuple[int, int]: # type: ignore[no-untyped-def] """The first and last line an element covers, from its ``data-pos``. - An element may carry more than one position, in which case they are parts of it and - the whole of it is wanted. An end at column 1 means the block stopped before that + An element may carry more than one position, one per part of the element, and the + whole element is wanted. An end at column 1 means the block stopped before that line, which is how pandoc reports every block that ends in a newline. """ positions = _POSITION.findall(element.attributes["data-pos"]) @@ -606,63 +599,61 @@ def add( ) -> 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 a source by line range can - tell that the lines it was given still say what they said. + A .docx or .tex file is converted to markdown beside it. A markdown file is frozen + as it stands and nothing is copied. Either way the markdown is hashed and its blocks + are written to ``FILE.draft.json``, so that code quoting a source by line range can + check that those lines still hold the text they held. - 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. + The files are numbered in the order they are given. A file already frozen into the + draft beside them is checked against the hash it was frozen at, and is not frozen + again. So a sheet and the solutions written separately are frozen together, or the + solutions added later as the next source, and the questions keep the ids and the + lines the commands so far were run against. Args: 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. + start_over: Freeze the files again, discarding the draft already there. + into: The draft to freeze the files into, as the draft's path or as the path of + a source already in it. None names the draft after the first file. A file + frozen into a draft already written becomes a source of that draft. Returns: The path of the draft that was written. Raises: ConversionToolsMissing: pandoc or panflute is not installed. - 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. + SourceError: the files are not all in one directory, so no one draft sits + beside them. + SourceUnreadable: a file is markdown, and is not UTF-8 text. + DraftUnreadable: a draft beside the files is not a draft in2lambda wrote, so + this function neither reads a hash out of it nor writes over it. 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`. + overwrite a file that no draft claims. `start_over` overrides both. """ _require_conversion_tools() 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)}." + "A draft sits beside the documents it was frozen from, so the files frozen " + f"into one draft 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. + # A draft already here holds the commands run against it, which freezing the same + # files again does not undo: those commands were run against these lines, so they + # still hold. The blocks are kept for the same reason, and parsing the markdown does + # not always give them: `split block` cuts a block in two, and parsing again would + # undo that cut while keeping the log entry recording it, leaving the ids the fields + # were written against naming nothing. --start-over is the only way to discard them. 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. + # Every file is read and parsed before any file is written: a parse that fails part + # way through would otherwise leave a markdown on disk that no draft claims, and the + # next run would refuse to overwrite a file this run wrote. converted: list[tuple[Path, bytes]] = [] for path in paths: if file_type(str(path)) == "markdown": @@ -687,7 +678,7 @@ def add( 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 " + "so in2lambda does not overwrite it. Move it aside, or run in2lambda " "source add --start-over." ) sources.append( @@ -701,22 +692,22 @@ def add( ) 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. + # The bytes pandoc wrote, so that the file on disk hashes to `digest`. Writing + # text would rewrite the line endings on Windows. frozen_path.write_bytes(raw) - # Freezing is where a draft starts, not something it records: a replay is the log - # applied to this, so `add` is the only thing that writes a draft it did not run. + # Freezing starts a draft and is not recorded in the log: a replay applies the log + # to what `add` wrote, so `add` writes no log entry of its own. save( draft, { "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. + # The report still describes the draft where every file named was frozen + # already, because `add` then writes the draft back unchanged. An appended + # source is a document the checks have never read, and every block of it is + # in no field, so the report is deleted as any change to a draft deletes it. + # `in2lambda build` then asks for the checks again. **( {"report": existing["report"]} if existing.get("report") is not None and sources == existing["sources"] @@ -735,20 +726,19 @@ def show(draft: str | Path) -> str: Returns: 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. + where a block starts there, then the line number and the line itself. A draft of + more than one source heads each source with its number and its name, because the + line numbers start again at 1 in each source. Raises: DraftMissing: there is no draft at that path. - DraftUnreadable: what is there is not a draft anything here wrote. + DraftUnreadable: the file at that path is not a draft in2lambda wrote. 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. + the ids would be printed against lines they were not taken from. """ - # 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. + # A line range identifies text only while that text does not change: ids printed + # against markdown the draft was not written from would look right and be wrong. found, sources = frozen(draft) printed = [] diff --git a/in2lambda/spec/__init__.py b/in2lambda/spec/__init__.py index 9eff8c4..4261fa3 100644 --- a/in2lambda/spec/__init__.py +++ b/in2lambda/spec/__init__.py @@ -1,7 +1,7 @@ r"""Reads a spec of selectors, and says what each block of a frozen source is. A spec is a small YAML file saying which blocks of a document are questions, which are -parts and which are solutions, and which layout they are written in:: +parts and which are solutions, and which layout the document is written in:: question: Header level=2 text~'^Question' part: Para text~'^\([a-z]\) ' @@ -10,26 +10,26 @@ ignore: Header level=1 layout: PartsSepSol -Where a selector cannot say it, a spec names a Python file beside it and calls functions -from it: ``predicates: predicates.py`` and then ``question: Para bold_lead()``, where -``bold_lead`` takes the panflute element and says whether the block is one. The file is -run by :func:`predicates` out of the bytes its caller hashed, so what runs is the file -the draft's log records having run. - -Nothing here decides what a question is: the selectors say which blocks are which, and -the layout says how a solution is paired up with the question or part it answers, which -is the one thing that differs between the filters in :mod:`in2lambda.filters` and is -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. +Where no selector can classify a block, a spec names a Python file beside it and calls +functions from that file: ``predicates: predicates.py``, and then ``question: Para +bold_lead()``, where ``bold_lead`` takes the panflute element and returns whether the +block is a question. :func:`predicates` runs the file from the bytes its caller hashed, so +that the file run is the file the draft's log records. + +The selectors classify each block as a question, part or solution. The layout assigns each +solution to the question or part it answers, which is the one thing that differs between +the filters in :mod:`in2lambda.filters` and is copied from them here. :func:`fields` +returns one field per question, part and solution, so that a draft written by a spec holds +the fields a draft written by hand holds. + +The same spec runs over every source a draft has frozen. The first source is the sheet, +laid out as the layout says. Every source after it is a document of solutions written +separately, and its solutions are paired onto the questions of the sheet as ``in2lambda +convert -a`` pairs an answers file, with the ``question`` selector picking out the marker +above each question's solutions. + +Reading a spec needs pyyaml, which only the ``convert`` extra installs. The command +calling this module checks for pyyaml first, along with pandoc and panflute. """ import re @@ -43,7 +43,7 @@ from in2lambda.source import Block, SourceError, dedented _KEYS = ("question", "part", "solution", "strip", "ignore", "layout", "predicates") -"""Everything a spec may say. Anything else in one is a typo, and is refused as one.""" +"""Every key a spec may hold. Any other key is a typo, and is refused as one.""" _ATTRIBUTES = ("level", "text", "label") """What a constraint can be about: a heading's level, a block's text, its first word.""" @@ -51,10 +51,10 @@ _ROLES = ("ignore", "question", "part", "solution") """The selectors a spec holds, in the order a block is tried against them. -A block is whatever the first of them to match it says it is. The order is this one -whatever order a spec writes its keys in: ignore before the rest so that a page nobody -wants is out of the way, and question before part so that a question numbered like one -of its own parts is still the question. +The first selector to match a block classifies it. The order is this one whatever order a +spec writes its keys in: ignore before the rest, so that a page of instructions is out of +the way, and question before part, so that a question numbered like one of its own parts +is read as the question. """ _TOKEN = re.compile( @@ -64,8 +64,8 @@ ) """One word of a selector: a constraint, a ``predicate()`` call, or a block type. -The call comes before the type, so that ``bold_lead()`` is read as a call rather than as -a type named ``bold_lead`` with a pair of brackets nothing can make anything of. +The call comes before the type, so that ``bold_lead()`` is read as a call and not as a +type named ``bold_lead`` followed by brackets the parser cannot read. """ @@ -74,7 +74,7 @@ class BadSpec(SourceError): def _refuse(line: int, message: str) -> BadSpec: - """A refusal of a spec, which always ends by saying which line to go and look at.""" + """A refusal of a spec, which ends by naming the line to read.""" return BadSpec(f"{message} See line {line} of the spec.") @@ -86,7 +86,7 @@ class Constraint: wanted: "re.Pattern[str] | str" def holds(self, value: Optional[str]) -> bool: - """Whether a block's attribute is what this asks for, given the block has one.""" + """Whether a block's attribute matches, which is False where it has none.""" if value is None: return False if isinstance(self.wanted, str): @@ -96,7 +96,7 @@ def holds(self, value: Optional[str]) -> bool: @dataclass class Selector: - """Which blocks of a document a spec is talking about.""" + """The blocks of a document that one key of a spec selects.""" type: Optional[str] = None constraints: list[Constraint] = field(default_factory=list) @@ -110,18 +110,18 @@ def matches( pf: Any, functions: Optional[dict[str, Callable[[Any], Any]]] = None, ) -> bool: - """Whether the block at `index` is one of these. + """Whether the block at `index` matches this selector. Args: elements: Every block of the document, as the panflute element it is. - index: Which of them to decide about. - pf: The panflute module, imported by the caller that has it. + index: Which block to test. + pf: The panflute module, imported by the caller that holds it. functions: The predicates the spec's file holds, as :func:`predicates` bound them, and None where the spec calls none. Returns: - True if the element is of this type, meets every constraint, satisfies every - predicate it calls, and comes after something the ``after`` selector matches. + True where the element is of this type, meets every constraint, satisfies + every predicate it calls, and follows a block the ``after`` selector matches. """ element = elements[index] if self.after is not None and not any( @@ -136,15 +136,15 @@ def matches( for constraint in self.constraints ): return False - # Last, so that someone's own code only sees the blocks the rest of the selector - # has already agreed about - a predicate written for a Para is only given one. + # Last, so that a reader's own code is given only the blocks the rest of the + # selector matched: a predicate written for a Para is given a Para. called = functions or {} return all(called[name](element) for name in self.predicates) @dataclass class Spec: - """What a spec file says, once it has been read.""" + """The keys a spec file holds, once :func:`load` has read them.""" question: Selector layout: str @@ -153,11 +153,11 @@ class Spec: ignore: Optional[Selector] = None strip: "list[re.Pattern[str]]" = field(default_factory=list) predicates: Optional[str] = None - """The Python file its selectors call functions from, where any of them do.""" + """The Python file its selectors call functions from, where any selector does.""" class Field(NamedTuple): - """One field a spec fills in: what it is called, what it says, where it came from.""" + """One field a spec fills in: its key, its value, and the lines it came from.""" key: str value: str @@ -167,7 +167,7 @@ class Field(NamedTuple): def _attribute(name: str, element: Any, pf: Any) -> Optional[str]: - """What a block says for one attribute, or None where it has not got one.""" + """What a block holds for one attribute, or None where the block holds none.""" if name == "level": return str(element.level) if isinstance(element, pf.Header) else None text = pf.stringify(element).strip() @@ -177,7 +177,7 @@ def _attribute(name: str, element: Any, pf: Any) -> Optional[str]: def _split(text: str) -> tuple[str, Optional[str]]: - """A selector either side of its comma, which a quoted regex may hold its own of.""" + """A selector's text either side of its comma, ignoring commas inside quotes.""" quote = "" for position, character in enumerate(text): if quote: @@ -191,15 +191,15 @@ def _split(text: str) -> tuple[str, Optional[str]]: def _clause(text: str, line: int, after: Optional[Selector] = None) -> Selector: - """One ``[Type] constraint*`` of a selector, given it says nothing else.""" + """One ``[Type] constraint*`` of a selector, where the text says nothing else.""" selector = Selector(after=after) position = 0 while position < len(text): if (token := _TOKEN.match(text, position)) is None: raise _refuse( line, - f"{text[position:].strip()!r} is not something a selector says. A " - "selector is a block type and then any number of name=value or " + f"{text[position:].strip()!r} is not something a selector holds. A " + "selector is a block type followed by any number of name=value or " "name~'regex' constraints.", ) position = token.end() @@ -217,7 +217,7 @@ def _clause(text: str, line: int, after: Optional[Selector] = None) -> Selector: if name not in _ATTRIBUTES: raise _refuse( line, - f"{name} is not something a block has: a constraint is about " + f"{name} is not an attribute a block holds: a constraint names " f"{', '.join(_ATTRIBUTES)}.", ) wanted = next( @@ -234,21 +234,21 @@ def _clause(text: str, line: int, after: Optional[Selector] = None) -> Selector: def _type(name: str, line: int) -> str: - """A block type, given pandoc has one of that name.""" + """A block type, where pandoc holds an element of that name.""" import panflute as pf found = getattr(pf, name, None) if not (isinstance(found, type) and issubclass(found, pf.Element)): raise _refuse( line, - f"{name} is not a pandoc element. A selector names one as pandoc does - " - "Header, Para, ListItem - or leaves the type out to match any block.", + f"{name} is not a pandoc element. A selector names an element as pandoc " + "does - Header, Para, ListItem - or omits the type to match any block.", ) return name def _pattern(regex: Any, line: int) -> "re.Pattern[str]": - """A regex, given it is one. A backslash in YAML wants single quotes around it.""" + """A regex, where the value is one. A backslash in YAML needs single quotes.""" try: return re.compile(regex) except (re.error, TypeError) as error: # TypeError: a strip list of numbers. @@ -258,7 +258,7 @@ def _pattern(regex: Any, line: int) -> "re.Pattern[str]": def _selector(text: Any, line: int) -> Selector: - """One selector of a spec, as its `after` clause and the rest.""" + """One selector of a spec, split into its `after` clause and the rest.""" if not isinstance(text, str): raise _refuse(line, f"A selector is a line of text, which {text!r} is not.") head, tail = _split(text.strip()) @@ -269,29 +269,29 @@ def _selector(text: Any, line: int) -> Selector: raise _refuse( line, "A selector's comma separates its `after` clause from the rest, and " - f"{text!r} has no `after` in it.", + f"{text!r} holds no `after` clause.", ) return _clause(head.strip(), line) def load(text: "str | bytes") -> Spec: - r"""Reads a spec, given that it says what a spec says. + r"""Reads a spec, where the text says what a spec says. Args: - text: The contents of the spec file, as text or as the bytes it was read as. - The bytes are handed to YAML rather than decoded here, since YAML knows - which encoding a file is in from its byte order mark and refuses one it - cannot read the way it refuses anything else about a spec. + text: The contents of the spec file, as text or as the bytes it was read as. The + bytes are passed to YAML undecoded, because YAML reads a file's encoding from + its byte order mark and refuses an encoding it cannot read as it refuses + anything else about a spec. Returns: The spec, with its selectors parsed and its strip patterns compiled. Raises: BadSpec: the text is not YAML, is in an encoding YAML cannot read, is not a - mapping, says something a spec does not, holds a selector, pattern or layout - that cannot be read, names a file of predicates that is not beside it, or - calls a function without naming the file its functions are in. Every one of - them says which line to look at. + mapping, holds a key a spec does not hold, holds a selector, pattern or + layout this module cannot read, names a file of predicates that is not beside + it, or calls a function without naming the file holding it. Every message + names the line to read. Examples: >>> from in2lambda.spec import load @@ -300,11 +300,10 @@ def load(text: "str | bytes") -> Spec: """ import yaml - # The composed nodes carry the line each key is written on; the values come from - # safe_load, which builds them rather than leaving them as nodes to unpick. Both - # are read here, since a file that composes can still fail to be built - a tag - # nothing constructs, a key nothing can hash - and that is as much a fault in the - # spec as a quote left open. + # The composed nodes record the line each key is written on, and safe_load builds the + # values, which the nodes leave to be unpicked. Both run here, because a file that + # composes can still fail to build - a tag nothing constructs, a key nothing can hash + # - and that is a fault in the spec as much as a quote left open is. try: node = yaml.compose(text) given = yaml.safe_load(text) @@ -322,25 +321,25 @@ def load(text: "str | bytes") -> Spec: if isinstance(key, yaml.ScalarNode) } - # By str, because a key someone has written need not be one: `1: Header` is YAML. + # Sorted by str, because a key a reader wrote need not be one: `1: Header` is YAML. if unknown := sorted(set(given) - set(_KEYS), key=str): raise _refuse( lines.get(unknown[0], 1), - f"{unknown[0]} is not something a spec says. A spec says " + f"{unknown[0]} is not a key a spec holds. A spec holds " f"{', '.join(_KEYS)}.", ) if missing := [key for key in ("question", "layout") if key not in given]: raise _refuse( 1, - "A spec says which blocks are questions and how they are laid out, so it " - f"has to have a {' and a '.join(missing)} in it.", + "A spec says which blocks are questions and how the solutions are laid out, " + f"so it must hold a {' and a '.join(missing)}.", ) layout = given["layout"] if layout not in builtin_filters(): raise _refuse( lines["layout"], - f"{layout} is not a layout in2lambda has. The layouts are the filters: " + f"{layout} is not a layout in2lambda holds. The layouts are the filters: " f"{', '.join(builtin_filters())}.", ) @@ -351,14 +350,14 @@ def load(text: "str | bytes") -> Spec: ) file = given.get("predicates") - # Beside the spec, and so a name with nothing of a path in it. A spec that could - # name a file anywhere would run and log one the folder it is in does not hold, and - # the draft would then only replay where that file still sat outside the folder. + # Beside the spec, so the name holds no path. A spec free to name a file anywhere + # would run and log a file the spec's own folder does not hold, and the draft would + # then replay only where that file still sat outside the folder. if file is not None and (not isinstance(file, str) or Path(file).name != file): raise _refuse( lines["predicates"], f"predicates names a Python file beside the spec, which {file!r} is not. " - "The name has no directory in it: the file is in the spec's own folder.", + "The name holds no directory: the file is in the spec's own folder.", ) question = _selector(given["question"], lines["question"]) rest = { @@ -369,9 +368,9 @@ def load(text: "str | bytes") -> Spec: if selector is not None and (called := _called(selector)): raise _refuse( lines[role], - f"{called[0]}() is a function, and the spec does not say which " - "Python file its functions are in. Put the file beside the spec " - "and name it with a predicates: line.", + f"{called[0]}() is a function, and the spec does not name the Python " + "file holding its functions. Put the file beside the spec and name " + "it with a predicates: line.", ) return Spec( question=question, @@ -385,31 +384,31 @@ def load(text: "str | bytes") -> Spec: def _called(selector: Selector) -> list[str]: - """Every function a selector calls, its ``after`` clause included.""" + """Every function a selector calls, including the calls in its ``after`` clause.""" return selector.predicates + (_called(selector.after) if selector.after else []) def predicates(spec: Spec, code: bytes, name: str) -> dict[str, Callable[[Any], Any]]: - """The functions a spec's selectors call, out of the file it names. + """The functions a spec's selectors call, from the file the spec names. Args: spec: The spec whose selectors call them, as :func:`load` read it. - code: What the file holds, as the bytes its caller hashed. The file is run from - these rather than imported by its path, so that what runs is what was - checked against the hash the draft's log recorded. - name: What the file is called, for the traceback of anything it raises and for - the refusal of anything it has not got. + code: The contents of the file, as the bytes its caller hashed. The file is run + from those bytes and not imported by its path, so that the code run is the + code checked against the hash the draft's log records. + name: The file's name, for the traceback of anything it raises and for the + message naming a function it does not hold. Returns: - One callable per function the spec's selectors name, ready for + One callable per function the spec's selectors name, for :meth:`Selector.matches`. Raises: BadSpec: the file holds no function of a name a selector calls, or holds something of that name that cannot be called. """ - # Run as a module of its own rather than imported by path, so that nothing about - # where the file is - a name already imported, a stale .pyc - decides what runs. + # Run as a module of its own and not imported by path, so that nothing about where + # the file sits - a name already imported, a stale .pyc - decides what runs. module = types.ModuleType("in2lambda_predicates") exec(compile(code, name, "exec"), module.__dict__) found = {} @@ -420,9 +419,9 @@ def predicates(spec: Spec, code: bytes, name: str) -> dict[str, Callable[[Any], function = getattr(module, called, None) if not callable(function): raise BadSpec( - f"{name} has no function {called} in it, and the spec calls " + f"{name} holds no function {called}, and the spec calls " f"{called}(). A predicate is a function of one argument, the " - "panflute element, that says whether the block is one of those." + "panflute element, that returns whether the selector matches." ) found[called] = function return found @@ -431,15 +430,15 @@ def predicates(spec: Spec, code: bytes, name: str) -> dict[str, Callable[[Any], def _optional( given: dict[str, Any], name: str, lines: dict[str, int] ) -> Optional[Selector]: - """One of the selectors a spec need not have.""" + """One of the selectors a spec need not hold.""" return _selector(given[name], lines[name]) if name in given else None def _stems(roles: list[Optional[str]]) -> list[Optional[str]]: - """What each question and part is called - ``q1``, ``q1.p1`` - in document order. + """The name of each question and part - ``q1``, ``q1.p1`` - in document order. - The same names `in2lambda.draft._next` gives out, so that a draft filled in by a spec - and one filled in by hand hold the same keys, and the checks read either. + `in2lambda.draft._next` gives out the same names, so that a draft filled in by a spec + and a draft filled in by hand hold the same keys, and the checks read either. """ stems: list[Optional[str]] = [None] * len(roles) questions, parts = 0, 0 @@ -456,9 +455,9 @@ def _stems(roles: list[Optional[str]]) -> list[Optional[str]]: 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 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. + A question with parts is answered part by part, and a question without parts is + answered itself. The solutions of a PartsSepSol document are written in that order, + as are the solutions of a document written beside the sheet. """ questions: list[tuple[str, list[str]]] = [] for index, role in enumerate(roles): @@ -474,7 +473,7 @@ def _keys(layout: str, roles: list[list[Optional[str]]]) -> list[list[Optional[s 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. + from the sheet, and its solutions are paired onto the sheet's questions. """ stems = _stems(roles[0]) slots = _slots(roles[0], stems) @@ -489,7 +488,7 @@ def _laid_out( 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.""" + """The field each block of the sheet goes in, or None where the layout assigns none.""" separate = iter([slot for question in slots for slot in question]) keys: list[Optional[str]] = [None] * len(roles) question: Optional[str] = None @@ -503,8 +502,8 @@ def _laid_out( parts.append(str(stems[index])) keys[index] = f"{stems[index]}.text" elif role == "solution" and question: - # Which solution answers what, mirroring the filter the layout names. The - # four filters are the four layouts; a fifth would need its rule adding. + # Which solution answers what, as the filter the layout names does. The four + # filters are the four layouts, and a fifth layout needs its rule added here. target: Optional[str] match layout: case "PartsOneSol": # One solution to the whole question. @@ -523,14 +522,14 @@ def _laid_out( 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. - 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. + `in2lambda convert -a` pairs an answers file this way. A block the ``question`` + selector matches is a marker - the ``Q2.`` written above the solutions to the second + question - which answers nothing and assigns the blocks after it to that question's + first slot. Every other block the spec matched, by its ``part`` selector or by its + ``solution`` selector, is a solution, and the solutions take the slots in order: each + question's parts, or the question itself where it has no parts. A solution past the + last slot is in no field. A solution assigned to a question that an earlier solution + answered is refused by `in2lambda.draft.record`, which names both fields. """ ordered = [ (number, slot) for number, question in enumerate(slots) for slot in question @@ -560,11 +559,11 @@ def _roles( 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. + """What the spec makes of each block of one source, or None where it matches none. 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. + names a position in one document - so each source is classified on its own, whatever + the sources before it hold. """ found = [element for _, element in elements] return [ @@ -591,18 +590,18 @@ def fields( 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. + blocks of its frozen markdown beside the element of each, as + :func:`in2lambda.source._elements` returns them, and the markdown 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 + One :class:`Field` per question, part and solution the spec matched, each naming + the source it came from, and the ids of the blocks to mark as ignored: the blocks 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. + solutions, which name the question the solutions under them answer. A block in + neither list is in no field, which `in2lambda.draft.report.uncovered` reports. """ import panflute as pf @@ -623,22 +622,22 @@ def fields( 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. + # A marker is ignored and not quoted: it names the question the solutions under + # it answer, and that question's text was copied 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: - """A block's own lines of the source, with the spec's strip patterns taken off. + """A block's own lines of the source, with the spec's strip patterns removed. - The markdown rather than the text pandoc stringifies it to, so that the maths, the - emphasis and the images in a question survive into the field. + The value is the markdown, and not the text pandoc stringifies it to, so that the + maths, the emphasis and the images of a question survive into the field. """ text = "\n".join(lines[block.start - 1 : block.end]) - # The list marker and the indent under it are the markdown's, not the author's, so - # they come off before the spec's patterns, which are for what is left. + # The list marker and the indent under it belong to the markdown and not to the + # author, so they come off before the spec's patterns, which are for the rest. if block.type == "list item": text = dedented(text) for pattern in spec.strip: diff --git a/in2lambda/validation/__init__.py b/in2lambda/validation/__init__.py index 97b95bf..aa5adf8 100644 --- a/in2lambda/validation/__init__.py +++ b/in2lambda/validation/__init__.py @@ -1,17 +1,17 @@ """Checks a question set for what Lambda Feedback would refuse or render wrongly. -A question can be perfectly valid JSON and still fail to import, or import and then -look wrong: an answer that does not fit the box marking it, an image the export will -not contain, maths KaTeX cannot render. Authors otherwise find this out by uploading -and looking. - -Everything here reports, never refuses: :func:`validate` returns what it found and the -export goes ahead regardless, since a problem may well be deliberate. - -Maths is rendered with KaTeX itself, which needs Node.js, and the set is compiled as the -PDF generator compiles it, which needs pandoc and xelatex. Both are optional: without -Node the maths check is skipped with a warning saying so, and without the compiler -:mod:`in2lambda.validation.pdf` reports what to install. +A question can be valid JSON and still fail to import, or import and then render wrongly: +an answer that does not fit the box marking it, an image the export will not contain, +maths KaTeX cannot render. An author otherwise finds this out by uploading the set and +reading it. + +The checks report and never refuse: :func:`validate` returns what it found, and the export +goes ahead, because an author may have intended a problem. + +KaTeX renders the maths, which needs Node.js, and the set is compiled as the PDF generator +compiles it, which needs pandoc and xelatex. Both are optional. Without Node.js, +:func:`validate` skips the maths check and warns that it did. Without pandoc and xelatex, +:mod:`in2lambda.validation.pdf` names the packages to install. """ import json @@ -62,12 +62,13 @@ def _location( ) -> str: """Where in a set something is, as every message here names it. - The one place that naming lives, since `in2lambda.draft.export` reads it backwards - to say which field of a draft a problem reported against it came from. + This function is the one place that naming is written, because + `in2lambda.draft.export` reads it backwards to find the field of a draft a problem + was reported against. Args: number: The question's number, from 1. - title: The question's title, quoted even where it is empty. + title: The question's title, quoted even where the title is empty. part: Which part of the question, from 0, or None for the question itself. field: Which field - ``main text``, ``worked solution`` - or None for the question or the part as a whole. @@ -85,16 +86,16 @@ def validate(question_set: Set, compile: bool = True) -> list[Problem]: Args: question_set: The set about to be exported. - compile: Whether to also compile the set as Lambda Feedback's PDF generator - will, which needs pandoc and xelatex - see + compile: Whether to compile the set as well, as Lambda Feedback's PDF generator + compiles it, which needs pandoc and xelatex - see :mod:`in2lambda.validation.pdf`. Returns: One :class:`~in2lambda.api.problem.Problem` per problem found, each naming the - question, part and field to look at, in the order they are written - save for - what KaTeX refused, which comes last because the whole set is rendered at once. - An empty list means nothing was found - not that the set will import, since - only some mistakes can be seen from here. + question, the part and the field to read, in the order they are written. What + KaTeX refused comes last, because in2lambda renders the whole set in one process. + An empty list means these checks found nothing, and not that the set will import: + they find some mistakes and not others. Examples: >>> from in2lambda.api.set import Set @@ -105,9 +106,9 @@ def validate(question_set: Set, compile: bool = True) -> list[Problem]: ['Question 1 "Angles", main text: ^\\circ does not display; write the degree sign ° instead'] """ problems: list[Problem] = [] - # Every markdown field with the location to report it against, kept so that the - # whole set can then be compiled in one go rather than a field at a time. The maths - # is collected the same way, and rendered in one Node process. + # Every markdown field with the location to report it against, collected so that the + # whole set compiles in one run and not a field at a time. The maths is collected the + # same way, and rendered in one Node process. fields: list[tuple[str, str]] = [] images: list[str] = [] expressions: list[_Expression] = [] @@ -147,8 +148,8 @@ def check( problems += [ Problem(area_where, message) for message in _area_problems(area) ] - # An answer box is only in the PDF if it is marked to be, so LaTeX it - # would not compile cannot break one unless it is. + # An answer box reaches the PDF only where it is marked to, so LaTeX in a + # box left out breaks no compile. for field, markdown in ( ("pre_text", area.pre_text), ("post_text", area.post_text), @@ -184,11 +185,11 @@ def _markdown_problems( ) -> list[Problem]: """Every problem in one markdown field, reported against `location`. - The question is needed because an image reference is only good if that image is - among the question's, and so will be written into the export's ``media/``. + The question is needed because an image reference is good only where that image is + one of the question's, and so is written into the export's ``media/``. - The field's maths is appended to `expressions` rather than rendered here, so that - the whole set takes one Node process instead of one per field. + The field's maths is appended to `expressions` and not rendered here, so that the + whole set takes one Node process and not one per field. """ problems: list[Problem] = [] @@ -196,9 +197,9 @@ def _markdown_problems( if delimiters is not MathDelimiterError.PASSED: problems.append(Problem(location, delimiters.value)) - # The writer rewrites a reference to the name of the image it matches, and carries - # that image into media/; one it matches nothing for is left as written, which is - # exactly the reference Lambda Feedback will not find. + # The writer rewrites a reference to the name of the image it matches, and copies + # that image into media/. A reference matching no image is written as it stands, and + # Lambda Feedback does not find it. for reference in _IMAGE.findall(markdown): if _image_for(reference, question.images) is None: problems.append( @@ -217,10 +218,10 @@ def _katex_problems( ) -> list[Problem]: """Maths that KaTeX, which Lambda Feedback renders with, will not display. - Expressions the lists have nothing to say about are appended to `expressions` for - KaTeX itself to render. The ones they do object to are not: their message says what - to write instead, where KaTeX's only says what it choked on, and one fault reads - better as one line. + An expression the lists say nothing about is appended to `expressions` for KaTeX + itself to render. An expression the lists object to is not appended: their message + names what to write instead, where KaTeX's message names the character it stopped at, + and one fault reads better as one line. """ problems: list[Problem] = [] lacks = _katex_lacks() @@ -250,9 +251,9 @@ def _katex_problems( "^\\circ does not display; write the degree sign ° instead", ) ) - # Where the field's delimiters are wrong, what is between them is not reliably - # the expression the author meant, so it is not rendered. The checks above are - # reported against the field rather than a character range, so they still run. + # Where the field's delimiters are wrong, the text between them may not be the + # expression the author wrote, so KaTeX does not render it. The checks above + # report against the field and not a character range, so they still run. if not unsupported and delimiters is MathDelimiterError.PASSED: expressions.append( _Expression(location, span.start() + 1, span.end(), maths, display) @@ -262,10 +263,10 @@ def _katex_problems( def _katex_rejections(expressions: list[_Expression]) -> list[Problem]: - """What KaTeX itself refuses to render, the whole set in one Node process. + """What KaTeX itself refuses to render, for the whole set in one Node process. - Node is optional: someone authoring questions in Python should not have to install - it, so without it this one check is skipped and says what to install instead. + Node.js is optional, because an author writing questions in Python need not install + it. Without Node.js this one check is skipped, and the warning names what to install. """ if not expressions: return [] @@ -290,16 +291,16 @@ def _katex_rejections(expressions: list[_Expression]) -> list[Problem]: ), capture_output=True, # Not the locale's encoding: KaTeX marks where it stopped reading with - # combining low lines, so its messages are never ASCII, and Node writes - # them as UTF-8 whatever LANG says. + # combining low lines, so its messages hold characters outside ASCII, and + # Node writes them as UTF-8 whatever LANG says. encoding="utf-8", check=True, ) rejections = json.loads(rendered.stdout) except (OSError, subprocess.SubprocessError, json.JSONDecodeError) as error: - # Anything named node on the PATH is run here, and it may not be Node.js at all. - # Validation reports, never refuses, so a check that cannot be run says so and - # leaves the rest of the report - and the export - alone. + # Whatever is named node on the PATH runs here, and it may not be Node.js. These + # checks report and never refuse, so a check that cannot run warns and leaves the + # rest of the report, and the export, alone. warnings.warn( f"Maths was not checked against KaTeX: running {node} failed ({error})", stacklevel=3, @@ -320,18 +321,17 @@ def _katex_rejections(expressions: list[_Expression]) -> list[Problem]: @cache def _node() -> str | None: - """Where node is, or None if it is not installed.""" + """Where node is installed, or None where it is not installed.""" return shutil.which("node") @cache def _katex_lacks() -> dict[str, str | None]: - """What KaTeX lacks, keyed by the command as it is written rather than as a regex. + """What KaTeX lacks, keyed by the command as it is written, not as a regex. - :func:`~in2lambda.katex_convert.katex_convert.unsupported_commands` gives the lists - as they are written, where a command's backslash is escaped for the replacing pass. - The entries that are not a single command, such as whole environments, simply never - match one. + :func:`~in2lambda.katex_convert.katex_convert.unsupported_commands` returns the lists + as they are written, with a command's backslash escaped for the replacing pass. An + entry that is not a single command, such as a whole environment, matches no command. """ return { pattern.replace("\\\\", "\\"): ( @@ -342,11 +342,11 @@ def _katex_lacks() -> dict[str, str | None]: def _area_problems(area: ResponseArea) -> list[str]: - """Where an answer box's answer does not fit the box, or what marks it. + """Where an answer box's answer does not fit the box, or does not fit what marks it. - Only the three response type / evaluation function pairings the real exports use - (``tests/fixtures/exports/README.md``) are judged. Any other evaluation function - may expect an answer of any shape, and guessing at it would only cry wolf. + Only the three response type and evaluation function pairings the real exports use + (``tests/fixtures/exports/README.md``) are checked. Any other evaluation function may + expect an answer of any shape, and a check on one would report a fault that is none. """ messages = [] diff --git a/in2lambda/validation/delimiters.py b/in2lambda/validation/delimiters.py index 6824b99..16127d6 100644 --- a/in2lambda/validation/delimiters.py +++ b/in2lambda/validation/delimiters.py @@ -1,20 +1,19 @@ """Checks that ``$ ... $`` and ``$$ ... $$`` math delimiters are balanced and placed correctly. -KaTeX (and Lambda Feedback) expect inline math wrapped in single dollar signs on -one line, and display math wrapped in ``$$`` that each sit alone on their own -line. This module scans markdown character by character and reports the first -delimiter mistake it finds. +KaTeX, and so Lambda Feedback, reads inline maths wrapped in single dollar signs +on one line, and display maths wrapped in ``$$``, each of which sits alone on its +own line. :func:`math_delimiter_checker` reads markdown character by character and +reports the first delimiter mistake it finds. """ from enum import Enum class MathDelimiterError(Enum): - """Outcome of :func:`math_delimiter_checker`. + """The outcome of :func:`math_delimiter_checker`. - ``PASSED`` means no problem was found; every other member describes a - specific delimiter mistake. The value is a short human-readable message - suitable for showing on the command line. + ``PASSED`` means the checker found no mistake. Every other member names one + delimiter mistake. The value is a short message for the command line. """ PASSED = "ok" @@ -32,16 +31,16 @@ class MathDelimiterError(Enum): def math_delimiter_checker(md_content: str) -> MathDelimiterError: - r"""Scan markdown for the first math-delimiter mistake. + r"""Scans markdown for the first math-delimiter mistake. - ``\$`` is treated as a literal dollar sign, not a delimiter. + ``\$`` is a literal dollar sign and not a delimiter. Args: md_content: The markdown text to check. Returns: - ``MathDelimiterError.PASSED`` if the delimiters are well formed, - otherwise the member describing the first problem found. + ``MathDelimiterError.PASSED`` where the delimiters are well formed, and + otherwise the member naming the first mistake found. Examples: >>> from in2lambda.validation.delimiters import math_delimiter_checker @@ -54,9 +53,9 @@ def math_delimiter_checker(md_content: str) -> MathDelimiterError: >>> math_delimiter_checker("Broken $x = y") <MathDelimiterError.MISSING_CLOSING_SINGLE_DOLLAR: 'unclosed inline $ ... $'> """ - # False once we are inside a math expression and awaiting its closing delimiter. + # False inside a maths expression, while its closing delimiter is still to come. expect_open_delimiter = True - # While inside an expression, whether it opened with a single "$" (inline) or "$$" (display). + # Inside an expression, whether it opened with a single "$" (inline) or "$$" (display). expect_single_dollar = True idx = 0 diff --git a/in2lambda/validation/pdf/__init__.py b/in2lambda/validation/pdf/__init__.py index b346e2e..2e6c5d0 100644 --- a/in2lambda/validation/pdf/__init__.py +++ b/in2lambda/validation/pdf/__init__.py @@ -1,22 +1,22 @@ -"""Compiles a set the way Lambda Feedback makes a PDF of it, and reads the errors back. +"""Compiles a set as Lambda Feedback makes a PDF of it, and reads the errors back. Lambda Feedback renders question PDFs with lambda-feedback/PDF-generator: pandoc with ``template.latex`` beside this file, then xelatex. Markdown that pipeline refuses is a -fault in the set, so the whole set is compiled once here and each LaTeX error is traced +fault in the set, so this module compiles the whole set once and traces each LaTeX error back to the field it came from. -The trick for tracing is a marker: the document handed to pandoc carries a raw-LaTeX -comment naming the field before each field's markdown, and pandoc copies raw blocks -through untouched. ``xelatex -file-line-error`` then reports every error as -``set.tex:<line>: <message>``, and the last marker above that line names the field. Not -every error, though: a file LaTeX cannot find is announced with no location at all, and -is traced instead through the ``Emergency stop.`` that follows it, which has one. +A marker does the tracing. The document handed to pandoc holds a raw-LaTeX comment naming +each field before that field's markdown, and pandoc copies raw blocks through unchanged. +``xelatex -file-line-error`` reports every error as ``set.tex:<line>: <message>``, and the +last marker above that line names the field. Some errors carry no location: xelatex +announces a file it cannot find without one, and this module traces that error through the +``Emergency stop.`` after it, which carries a location. -pandoc and xelatex are both optional, as they are everywhere else in in2lambda: without -them this reports what to install rather than raising. +pandoc and xelatex are both optional, as they are everywhere else in in2lambda. Without +them this module names the packages to install and raises nothing. -The same pipeline writes the PDF itself - :func:`render` - since what a reviewer wants -to look at is the document the errors were traced out of. +The same pipeline writes the PDF itself - :func:`render` - because a reviewer reads the +document the errors were traced out of. """ import re @@ -39,51 +39,52 @@ " fonts-noto-core fonts-noto-cjk)" ), } +"""Each tool a compile needs, by the name on the PATH, with how to install it.""" _SET = "The set" -"""Where an error that is not inside any one field is reported against.""" +"""The location reported for an error that falls in no one field.""" _MARKER = "% in2lambda: " _ERROR = re.compile( r"^(?:\./)?(\S+\.(?:tex|sty|cls|def|cfg|fd|ltx)):(\d+): (.+)$", re.MULTILINE ) -"""One ``-file-line-error`` line. The file is only ``set.tex`` for the set's own text.""" +"""One ``-file-line-error`` line. Only ``set.tex`` holds the set's own text.""" _BARE = re.compile(r"^! (.+)$", re.MULTILINE) -r"""An error xelatex printed with no file and line to it. +r"""An error xelatex printed with no file and no line. -``-file-line-error`` only rewrites an error raised at a line of a file. A file that -cannot be found is announced through ``\typeout`` rather than raised, and an error -raised once the input has run out - ``File ended while scanning use of \frac`` - has no -line left to name, so both are only ever in the log behind a ``!``. +``-file-line-error`` rewrites only an error raised at a line of a file. xelatex announces +a file it cannot find through ``\typeout`` and does not raise it, and an error raised once +the input has run out - ``File ended while scanning use of \frac`` - has no line left to +name, so the log holds both behind a ``!``. """ _STOP = "Emergency stop." -"""TeX's last line, which says where it gave up rather than what was wrong.""" +"""TeX's last line, which names where it gave up and not what was wrong.""" _IMAGE = re.compile(r"(!\[[^\]]*\]\()([^)]*)(\))") """A markdown image with its path apart, so that the path can be rewritten or dropped.""" _TIMEOUT = 120 -"""Seconds for pandoc or xelatex. A set that takes longer is reported, not waited for.""" +"""Seconds for pandoc or xelatex. A set taking longer is reported, and not awaited.""" class CompileFailed(SourceError): - """The pipeline produced nothing at all. + """The pipeline produced no PDF. - Either pandoc refused the set, or xelatex wrote no PDF, or neither finished. Not a - problem in one field, since there is no generated LaTeX to trace an error back - through, so it is raised rather than reported - as a `SourceError`, which is what - the command line turns into a message rather than a traceback. + pandoc refused the set, or xelatex wrote no PDF, or one of the two did not finish. + No generated LaTeX is left to trace an error through, so this is raised and not + reported as a problem in one field. It is a `SourceError`, which the command line + prints as a message and not as a traceback. """ def missing_tools() -> list[str]: - """What is needed to compile a set but is not installed, each saying how to get it. + """The tools a compile needs that are not installed, each with how to install it. Returns: - One line per missing tool, or an empty list if a set can be compiled here. + One line per missing tool, or an empty list where a set can be compiled here. """ return [hint for tool, hint in _TOOLS.items() if shutil.which(tool) is None] @@ -94,10 +95,10 @@ def problems(fields: list[tuple[str, str]], images: list[str]) -> list[Problem]: Args: fields: Every markdown field of the set in the order it is written, each with the location - ``Question 1 "Title", part (a), text`` - to report against. - images: Every image path the set's questions hold. Those that exist are put - beside the compiled document under their file name, since that is how the - export refers to them; a reference to any other is dropped, as the image - check already reports it. + images: Every image path the set's questions hold. An image that exists is copied + beside the compiled document under its file name, which is how the export + refers to it. A reference to any other image is dropped, because + `in2lambda.validation` already reports it. Returns: One :class:`~in2lambda.api.problem.Problem` per distinct LaTeX error. An empty @@ -127,13 +128,13 @@ def render( fields: Every markdown field to render, each with the location to report an error in it against, as :func:`problems` takes them. images: Every image path the fields refer to, as :func:`problems` takes them. - output: The PDF file to write. Its directory is made if it is not there, and a - file of that name is overwritten. + output: The PDF file to write. Its directory is created where it does not exist, + and a file of that name is overwritten. Returns: - One :class:`~in2lambda.api.problem.Problem` per LaTeX error, as - :func:`problems` reports them. xelatex typesets what it can whatever it - refuses, so these say what to look at in the PDF rather than that there is none. + One :class:`~in2lambda.api.problem.Problem` per LaTeX error, as :func:`problems` + reports them. xelatex typesets what it can whatever it refuses, so each problem + names what to read in the PDF. Raises: CompileFailed: pandoc refused the fields, neither tool finished, or xelatex @@ -144,9 +145,9 @@ def render( latex, log = _compile(fields, images, work) problems = _reported(log, _locations(latex)) if not (compiled := work / "set.pdf").is_file(): - # Why nothing was typeset and not merely that nothing was: whatever stopped - # xelatex is among the problems like any other error, traced to the field - # the stop happened in where there is one. + # The message names why xelatex typeset nothing: whatever stopped it is among + # the problems like any other error, traced to the field the stop happened + # in where a field holds it. said = "; ".join(str(problem) for problem in problems) raise CompileFailed( f"xelatex produced no PDF of {output.name}" @@ -158,7 +159,7 @@ def render( def _compiled(fields: list[tuple[str, str]], images: list[str]) -> list[Problem]: - """The set's errors, compiled in a directory of its own and thrown away again.""" + """The set's errors, compiled in a directory of its own and deleted again.""" with tempfile.TemporaryDirectory() as directory: latex, log = _compile(fields, images, Path(directory)) return _reported(log, _locations(latex)) @@ -170,15 +171,14 @@ def _compile( """The set run through pandoc and then xelatex in `work`. Returns: - The LaTeX pandoc generated, whose markers say which field each line came from, - and the xelatex log. Whatever xelatex managed to typeset is left in `work` as - ``set.pdf``. + The LaTeX pandoc generated, whose markers name the field each line came from, and + the xelatex log. Whatever xelatex typeset is left in `work` as ``set.pdf``. Raises: - CompileFailed: pandoc would not read the set, so there is no LaTeX to run, or - one of the two did not finish. A set can be written that makes TeX loop - forever, which is a fault in the set like any other: both callers want it - said rather than waited for, so it is said here once. + CompileFailed: pandoc would not read the set, so there is no LaTeX to run, or one + of the two commands did not finish. A set can be written that makes TeX loop + forever, which is a fault in the set like any other, and neither caller waits + for it, so this function reports it once. """ try: return _run(fields, images, work) @@ -192,7 +192,7 @@ def _compile( def _run( fields: list[tuple[str, str]], images: list[str], work: Path ) -> tuple[str, str]: - """The two commands themselves, whatever either of them does.""" + """The two commands themselves, whatever either of them returns.""" available = set() for image in images: if Path(image).is_file(): @@ -214,8 +214,8 @@ def _run( input=_marked_document(fields, available), capture_output=True, text=True, - # Not the locale's encoding: a set holding any non-ASCII character would - # then fail to even be handed over under, say, LC_ALL=C. + # Not the locale's encoding: under LC_ALL=C a set holding any character outside + # ASCII would fail before pandoc read it. encoding="utf-8", cwd=work, timeout=_TIMEOUT, @@ -245,8 +245,8 @@ def _run( def _marked_document(fields: list[tuple[str, str]], available: set[str]) -> str: """The whole set as one markdown document, each field under a marker naming it. - The fence is four backticks so that a field which itself contains a code block - cannot close the marker's raw-LaTeX block early. + The fence is four backticks, so that a field holding a code block does not close the + marker's raw-LaTeX block early. """ blocks = [] for location, markdown in fields: @@ -263,7 +263,7 @@ def _marked_document(fields: list[tuple[str, str]], available: set[str]) -> str: def _locations(latex: str) -> list[tuple[int, str]]: - """Each marker in the generated LaTeX as the line it is on and the field it names.""" + """Each marker in the generated LaTeX, as the line it is on and the field it names.""" return [ (number, line.partition(_MARKER)[2]) for number, line in enumerate(latex.splitlines(), start=1) @@ -272,10 +272,10 @@ def _locations(latex: str) -> list[tuple[int, str]]: def _where(file: str, line: int, locations: list[tuple[int, str]]) -> str: - """The field this line of this file is in, or `_SET` if it is in none of them. + """The field this line of this file falls in, or `_SET` where it falls in none. - Only ``set.tex`` holds the set's own text, so a line of a package or a font is - nothing to do with the markers however it numbers. + Only ``set.tex`` holds the set's own text, so a line of a package or a font matches + no marker whatever its number. """ where = _SET if file == "set.tex": @@ -288,11 +288,10 @@ def _where(file: str, line: int, locations: list[tuple[int, str]]) -> str: def _reported(log: str, locations: list[tuple[int, str]]) -> list[Problem]: """The xelatex log's errors as problems, each against the field it happened in. - The same error repeated - a command used twice, say - is one problem, since the - author has one thing to go and fix. An error printed bare is reported where the - ``Emergency stop.`` after it says the reading had got to, that being the only line - of an abort with a location on it, and the stop itself only when there is nothing - else to say. + The same error repeated - a command used twice - is one problem, because the author + has one thing to fix. An error printed bare is reported where the ``Emergency stop.`` + after it says the reading reached, that being the only line of an abort carrying a + location. The stop itself is reported where there is nothing else to report. """ bare = [message.strip() for message in _BARE.findall(log)] stop = _SET if _STOP in bare else None @@ -306,7 +305,7 @@ def _reported(log: str, locations: list[tuple[int, str]]) -> list[Problem]: errors.append((_where(file, line, locations), message)) # Only the first bare error: whatever follows it is TeX unwinding from it, and the - # author has the one thing to go and fix. + # author has one thing to fix. if causes := [message for message in bare if message != _STOP]: errors.append((stop or _SET, causes[0])) elif not errors and stop: From 389ff522cca8365f219646bcb24c35ede8ac39b3 Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" <peterbjohnson@gmail.com> Date: Sun, 20 Sep 2026 22:18:22 +0100 Subject: [PATCH 2/3] implement: Rewrite the documentation and docstrings to the prose standard (t46) --- CHANGELOG.md | 6 +++--- in2lambda/main.py | 17 +++++------------ pyproject.toml | 2 +- tests/test_cli.py | 34 ++++++++++------------------------ 4 files changed, 19 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09199a1..d67f413 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,9 @@ # Changelog -## 1.1.0 +## 2.0.0 -- Converting a document is now `in2lambda convert FILE FILTER`. The options are unchanged: `-o/--out` and `-a/--answers`. `in2lambda FILE FILTER` still converts the file, and prints one line to stderr naming the `in2lambda convert` command to run, so a script or Docker invocation written before this release runs unchanged. -- A first argument that is neither a command nor a file is refused, and the message names the `in2lambda convert` command to run. Earlier versions printed the usage message and exited 0. +- Converting a document is now `in2lambda convert FILE FILTER`. The options are unchanged: `-o/--out` and `-a/--answers`. A script or Docker invocation that runs `in2lambda FILE FILTER` needs the extra word. +- `in2lambda FILE FILTER` exits with an error naming the `in2lambda convert` command to run. Earlier versions printed the usage message and exited 0. - beartype is now `^0.22`. At 0.20.0 and below, the beartype import hook leaves `cli` a plain function instead of a group, so the command line either fails to import or runs `convert` whatever the arguments are. 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 `FILE.draft.json` beside it, holding the markdown's hash and every block in the markdown 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 sheets holds one draft per sheet. `in2lambda source show` prints that markdown numbered with the block ids. Freezing a file that has changed since is refused unless `--start-over` discards the draft. Showing a file that has changed is refused as well, because its block ids would name lines they were not taken from. `in2lambda source add` needs pandoc and the `convert` extra, as `in2lambda convert` does. `in2lambda source show` reads the markdown the draft was frozen from, and needs neither. - A draft now holds a `log` of every command that changed it, and a `fields` map of the fields those commands wrote. Each field records the layer that 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. `in2lambda draft replay` rebuilds the draft from the frozen markdown and the log, and refuses unless the draft it builds matches the draft on disk byte for byte. A draft written before this release holds no `log` and is refused as a draft in2lambda did not write; `in2lambda source add --start-over` freezes the document again. diff --git a/in2lambda/main.py b/in2lambda/main.py index 0368951..fa66587 100644 --- a/in2lambda/main.py +++ b/in2lambda/main.py @@ -2,7 +2,6 @@ import getpass import importlib -import os import shlex import warnings from collections.abc import Callable # Rather than typing's, which beartype warns on. @@ -168,23 +167,17 @@ def runner( class _Cli(click.RichGroup): - """The in2lambda group, which runs ``convert`` for the older command line.""" + """The in2lambda group, which refuses the pre-2.0 command line.""" def resolve_command(self, ctx, args): # type: ignore[no-untyped-def] - """Runs ``convert`` where the first argument is a file, and refuses any other. + """Refuses a first argument that names no command, naming the command to run. - Click resolves a first argument starting with ``/`` or ``.`` by printing the - group's help and exiting 0, so `in2lambda /path/to/questions.tex PartsSepSol` - would read as a successful run that converted nothing. + Click prints the group's help and exits 0 for a first argument starting with + ``/`` or ``.``, so `in2lambda /path/to/questions.tex PartsSepSol` would convert + nothing and report no error. """ # Shell completion resolves partial command lines, and must not raise. if not ctx.resilient_parsing and self.get_command(ctx, args[0]) is None: - if os.path.isfile(args[0]): - click.echo( - f"in2lambda FILE FILTER is the old form. Run: in2lambda convert {shlex.join(args)}", - err=True, - ) - return super().resolve_command(ctx, ["convert", *args]) raise click.UsageError( f"in2lambda no longer takes a file directly. Run: in2lambda convert {shlex.join(args)}" ) diff --git a/pyproject.toml b/pyproject.toml index 911489e..1dc30e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "in2lambda" -version = "1.1.0" +version = "2.0.0" description = "Converts content ready for import into Lambda Feedback" authors = [] license = "MIT" diff --git a/tests/test_cli.py b/tests/test_cli.py index e43070b..35f3ea5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,4 @@ -"""What the command line does with the current and the old form.""" +"""What the command line does with the current and the pre-2.0 form.""" import os import shutil @@ -25,10 +25,10 @@ def test_convert_writes_the_set(filters_dir: str, tmp_path) -> None: @pytest.mark.parametrize("path", ["example.tex", "./example.tex", "ABSOLUTE"]) -def test_old_form_converts_with_a_deprecation_line( +def test_old_form_fails_and_names_convert( path: str, filters_dir: str, monkeypatch, tmp_path ) -> None: - """The old form converts whatever the file path looks like, saying so on stderr. + """The pre-2.0 form errors out whatever the file path looks like. A path starting with ``/`` or ``.`` used to make click print the help and exit 0, so every script passing a full path appeared to succeed without converting anything. @@ -42,12 +42,12 @@ def test_old_form_converts_with_a_deprecation_line( result = CliRunner().invoke(cli, [path, "PartsSepSol"]) - assert result.exit_code == 0, result.output - assert "in2lambda FILE FILTER is the old form" in result.stderr - assert (tmp_path / "out" / "set.zip").exists() + assert result.exit_code != 0 + assert "in2lambda convert" in result.output + assert not (tmp_path / "out").exists() -def test_old_form_works_when_run_as_the_installed_command( +def test_old_form_fails_when_run_as_the_installed_command( filters_dir: str, tmp_path ) -> None: """The same holds for ``cli()``, which is what the installed command runs. @@ -70,23 +70,9 @@ def test_old_form_works_when_run_as_the_installed_command( env={**os.environ, "COLUMNS": "200"}, ) - assert result.returncode == 0, result.stdout + result.stderr - assert "in2lambda FILE FILTER is the old form" in result.stderr - assert "old form" not in result.stdout - assert (tmp_path / "out" / "set.zip").exists() - - -def test_first_argument_that_is_neither_still_names_convert( - monkeypatch, tmp_path -) -> None: - """A first argument that is neither a subcommand nor a file is refused.""" - monkeypatch.setenv("COLUMNS", "200") # So the message is not wrapped mid-sentence. - monkeypatch.chdir(tmp_path) - - result = CliRunner().invoke(cli, ["missing.tex", "PartsSepSol"]) - - assert result.exit_code != 0 - assert "in2lambda convert" in result.output + assert result.returncode != 0 + assert "in2lambda convert" in result.stdout + result.stderr + assert not (tmp_path / "out").exists() def test_convert_leaves_only_what_it_writes(filters_dir: str, tmp_path) -> None: From 1003ab24e9507a75a3be2f9a2778f98cd945394a Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" <peterbjohnson@gmail.com> Date: Sun, 20 Sep 2026 22:38:52 +0100 Subject: [PATCH 3/3] implement: Rewrite the documentation and docstrings to the prose standard (t46) --- CHANGELOG.md | 6 +++--- in2lambda/main.py | 26 +++++++++++++++++++------- pyproject.toml | 2 +- tests/test_cli.py | 40 +++++++++++++++++++++++++++------------- 4 files changed, 50 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d67f413..09199a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,9 @@ # Changelog -## 2.0.0 +## 1.1.0 -- Converting a document is now `in2lambda convert FILE FILTER`. The options are unchanged: `-o/--out` and `-a/--answers`. A script or Docker invocation that runs `in2lambda FILE FILTER` needs the extra word. -- `in2lambda FILE FILTER` exits with an error naming the `in2lambda convert` command to run. Earlier versions printed the usage message and exited 0. +- Converting a document is now `in2lambda convert FILE FILTER`. The options are unchanged: `-o/--out` and `-a/--answers`. `in2lambda FILE FILTER` still converts the file, and prints one line to stderr naming the `in2lambda convert` command to run, so a script or Docker invocation written before this release runs unchanged. +- A first argument that is neither a command nor a file is refused, and the message names the `in2lambda convert` command to run. Earlier versions printed the usage message and exited 0. - beartype is now `^0.22`. At 0.20.0 and below, the beartype import hook leaves `cli` a plain function instead of a group, so the command line either fails to import or runs `convert` whatever the arguments are. 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 `FILE.draft.json` beside it, holding the markdown's hash and every block in the markdown 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 sheets holds one draft per sheet. `in2lambda source show` prints that markdown numbered with the block ids. Freezing a file that has changed since is refused unless `--start-over` discards the draft. Showing a file that has changed is refused as well, because its block ids would name lines they were not taken from. `in2lambda source add` needs pandoc and the `convert` extra, as `in2lambda convert` does. `in2lambda source show` reads the markdown the draft was frozen from, and needs neither. - A draft now holds a `log` of every command that changed it, and a `fields` map of the fields those commands wrote. Each field records the layer that 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. `in2lambda draft replay` rebuilds the draft from the frozen markdown and the log, and refuses unless the draft it builds matches the draft on disk byte for byte. A draft written before this release holds no `log` and is refused as a draft in2lambda did not write; `in2lambda source add --start-over` freezes the document again. diff --git a/in2lambda/main.py b/in2lambda/main.py index fa66587..6656497 100644 --- a/in2lambda/main.py +++ b/in2lambda/main.py @@ -2,6 +2,7 @@ import getpass import importlib +import os import shlex import warnings from collections.abc import Callable # Rather than typing's, which beartype warns on. @@ -167,19 +168,30 @@ def runner( class _Cli(click.RichGroup): - """The in2lambda group, which refuses the pre-2.0 command line.""" + """The in2lambda group, which runs `convert` for a first argument that is a file.""" def resolve_command(self, ctx, args): # type: ignore[no-untyped-def] - """Refuses a first argument that names no command, naming the command to run. - - Click prints the group's help and exits 0 for a first argument starting with - ``/`` or ``.``, so `in2lambda /path/to/questions.tex PartsSepSol` would convert - nothing and report no error. + """Runs `convert` for a file, and refuses a first argument that is neither. + + A first argument naming a file runs `in2lambda convert` on that file, and one + line on stderr names the `in2lambda convert` command to run instead, so a script + written before that command existed keeps working. A first argument that names + neither a command nor a file is refused, and the message names the `in2lambda + convert` command. Click prints the group's help and exits 0 for a first argument + starting with ``/`` or ``.``, so `in2lambda /path/to/questions.tex PartsSepSol` + would convert nothing and report no error. """ # Shell completion resolves partial command lines, and must not raise. if not ctx.resilient_parsing and self.get_command(ctx, args[0]) is None: + if os.path.isfile(args[0]): + click.echo( + f"in2lambda FILE FILTER is the old form. Run: in2lambda convert {shlex.join(args)}", + err=True, + ) + return super().resolve_command(ctx, ["convert", *args]) raise click.UsageError( - f"in2lambda no longer takes a file directly. Run: in2lambda convert {shlex.join(args)}" + f"{args[0]} is not an in2lambda command, and no file of that name exists. " + f"To convert a file, run: in2lambda convert {shlex.join(args)}" ) return super().resolve_command(ctx, args) diff --git a/pyproject.toml b/pyproject.toml index 1dc30e3..911489e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "in2lambda" -version = "2.0.0" +version = "1.1.0" description = "Converts content ready for import into Lambda Feedback" authors = [] license = "MIT" diff --git a/tests/test_cli.py b/tests/test_cli.py index 35f3ea5..fe706f6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,4 @@ -"""What the command line does with the current and the pre-2.0 form.""" +"""What the command line does with the current and the old form.""" import os import shutil @@ -25,10 +25,10 @@ def test_convert_writes_the_set(filters_dir: str, tmp_path) -> None: @pytest.mark.parametrize("path", ["example.tex", "./example.tex", "ABSOLUTE"]) -def test_old_form_fails_and_names_convert( +def test_old_form_converts_with_a_deprecation_line( path: str, filters_dir: str, monkeypatch, tmp_path ) -> None: - """The pre-2.0 form errors out whatever the file path looks like. + """The old form converts the file for every form of path, and prints one line. A path starting with ``/`` or ``.`` used to make click print the help and exit 0, so every script passing a full path appeared to succeed without converting anything. @@ -42,18 +42,18 @@ def test_old_form_fails_and_names_convert( result = CliRunner().invoke(cli, [path, "PartsSepSol"]) - assert result.exit_code != 0 - assert "in2lambda convert" in result.output - assert not (tmp_path / "out").exists() + assert result.exit_code == 0, result.output + assert "in2lambda FILE FILTER is the old form" in result.stderr + assert (tmp_path / "out" / "set.zip").exists() -def test_old_form_fails_when_run_as_the_installed_command( +def test_old_form_works_when_run_as_the_installed_command( filters_dir: str, tmp_path ) -> None: - """The same holds for ``cli()``, which is what the installed command runs. + """The old form converts through ``cli()``, which the installed command runs. - CliRunner calls ``cli.main`` instead, so it cannot see this path: on beartype - 0.18.5 the import hook left ``cli`` a plain function, and ``cli()`` ran + CliRunner calls ``cli.main`` instead, so CliRunner does not cover this path. On + beartype 0.18.5 the import hook left ``cli`` a plain function, and ``cli()`` ran ``convert``'s body whatever the arguments. """ result = subprocess.run( @@ -70,9 +70,23 @@ def test_old_form_fails_when_run_as_the_installed_command( env={**os.environ, "COLUMNS": "200"}, ) - assert result.returncode != 0 - assert "in2lambda convert" in result.stdout + result.stderr - assert not (tmp_path / "out").exists() + assert result.returncode == 0, result.stdout + result.stderr + assert "in2lambda FILE FILTER is the old form" in result.stderr + assert "old form" not in result.stdout + assert (tmp_path / "out" / "set.zip").exists() + + +def test_first_argument_that_is_neither_still_names_convert( + monkeypatch, tmp_path +) -> None: + """A first argument that is neither a subcommand nor a file is refused.""" + monkeypatch.setenv("COLUMNS", "200") # So the message is not wrapped mid-sentence. + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke(cli, ["missing.tex", "PartsSepSol"]) + + assert result.exit_code != 0 + assert "in2lambda convert" in result.output def test_convert_leaves_only_what_it_writes(filters_dir: str, tmp_path) -> None: