Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,5 @@
- 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 the path of a document's draft, 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. That module reports what it changed in an expression to the `in2lambda.katex_convert` logger, which is silent unless the application configures logging.
- `in2lambda convert` now reads a .docx that holds an image. in2lambda looks in the document for the directories a `\graphicspath` names, and read the document as UTF-8 text to find them. A .docx is a zip file, so converting a Word document holding a figure raised `UnicodeDecodeError`. in2lambda now reads a document that is not UTF-8 text as naming no directory, which is what a .docx names.
- 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.
17 changes: 11 additions & 6 deletions in2lambda/filters/markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,17 @@ def image_directories(tex_file: str) -> list[str]:
>>> image_directories(tex_file)
[]
"""
with open(tex_file, "r") as file:
for line in file:
if "graphicspath" in line:
# Matches anything surrounded by curly braces, but excludes the top level
# graphicspath brace.
return [match.strip() for match in re.findall(r"{([^{]*?)}", line)]
try:
with open(tex_file, "r") as file:
for line in file:
if "graphicspath" in line:
# Matches anything surrounded by curly braces, but excludes the top level
# graphicspath brace.
return [match.strip() for match in re.findall(r"{([^{]*?)}", line)]
except UnicodeDecodeError:
# `graphicspath` is a TeX command, and a .docx is a zip rather than text: reading
# one as UTF-8 raises where there is nothing in it to find.
pass
return []


Expand Down
54 changes: 54 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import shutil
from collections.abc import Iterator
from pathlib import Path
from typing import Any

import pytest

Expand Down Expand Up @@ -47,6 +48,59 @@
"""Every spec folder, so that covering another kind of document is a folder and no code."""


AGAINST_CONVERT_DIR = Path(__file__).parent / "fixtures" / "against_convert"
"""One document per folder, beside the spec or commands that take it down both routes."""

AGAINST_CONVERT = sorted(
path for path in AGAINST_CONVERT_DIR.iterdir() if path.is_dir()
)
"""Every folder of the above, so that covering another document is a folder and no code."""


def key_paths(value: Any, path: str = "") -> set[str]:
"""Every key of a JSON value, at every depth, as ``.parts[0].workedSolution``.

Args:
value: A question, a set, or any part of one, as JSON reads it.
path: What to write in front of each key, for a value taken out of another.

Returns:
One path per key, list items numbered, so that two files can be compared by the
shape they hold rather than by what they say.
"""
if isinstance(value, dict):
paths = set()
for key, item in value.items():
paths |= {f"{path}.{key}"} | key_paths(item, f"{path}.{key}")
return paths
if isinstance(value, list):
return set().union(
*(key_paths(item, f"{path}[{i}]") for i, item in enumerate(value))
)
return set()


def unexported_keys(written: dict, exported: set[str]) -> list[str]:
"""The keys a written question or set holds that Lambda Feedback never exports there.

Args:
written: A question or a set as in2lambda wrote it, as JSON reads it.
exported: The key paths real exports hold, as :func:`key_paths` reads them off
one export or off all of them at once.

Returns:
The paths of the written file that are in none of them, in order.
"""
missing = key_paths(written) - exported
# Lambda Feedback leaves a part's workedSolution out of its export when the part
# has none, but the writer always emits one, so only then may it be absent.
for i, part in enumerate(written.get("parts", [])):
if not part["workedSolution"]["content"]:
prefix = f".parts[{i}].workedSolution"
missing = {key for key in missing if not key.startswith(prefix)}
return sorted(missing)


def frozen_sources(folder: Path) -> list[str]:
"""The documents a draft or spec folder freezes, in the order they are its sources.

Expand Down
100 changes: 100 additions & 0 deletions tests/fixtures/against_convert/PartPartSolSol/commands.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
[
{
"args": {
"block": "b1"
},
"by": "tests",
"command": "mark ignore"
},
{
"args": {
"at": 9,
"block": "b2"
},
"by": "tests",
"command": "split block"
},
{
"args": {
"text": "s3:4"
},
"by": "tests",
"command": "question add"
},
{
"args": {
"question": "q1",
"text": "s5:6"
},
"by": "tests",
"command": "part add"
},
{
"args": {
"question": "q1",
"text": "s7:8"
},
"by": "tests",
"command": "part add"
},
{
"args": {
"block": "b2b"
},
"by": "tests",
"command": "mark ignore"
},
{
"args": {
"at": 17,
"block": "b3"
},
"by": "tests",
"command": "split block"
},
{
"args": {
"text": "b3a"
},
"by": "tests",
"command": "question add"
},
{
"args": {
"at": 18,
"block": "b3b"
},
"by": "tests",
"command": "split block"
},
{
"args": {
"block": "b3ba"
},
"by": "tests",
"command": "mark ignore"
},
{
"args": {
"at": 19,
"block": "b3bb"
},
"by": "tests",
"command": "split block"
},
{
"args": {
"question": "q2",
"text": "b3bba"
},
"by": "tests",
"command": "question solution"
},
{
"args": {
"block": "b3bbb"
},
"by": "tests",
"command": "mark ignore"
}
]
2 changes: 2 additions & 0 deletions tests/fixtures/against_convert/PartPartSolSol/differs.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Question 1 "", part (a), worked solution: the draft says '' and convert says '$1+1=2$' # t53: no command writes qN.pM.solution
Question 1 "", part (b), worked solution: the draft says '' and convert says '$2+2=4$' # t53: no command writes qN.pM.solution
123 changes: 123 additions & 0 deletions tests/fixtures/against_convert/PartSolPartSol/commands.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
[
{
"args": {
"block": "b1"
},
"by": "tests",
"command": "mark ignore"
},
{
"args": {
"at": 7,
"block": "b2"
},
"by": "tests",
"command": "split block"
},
{
"args": {
"at": 11,
"block": "b2b"
},
"by": "tests",
"command": "split block"
},
{
"args": {
"at": 13,
"block": "b2bb"
},
"by": "tests",
"command": "split block"
},
{
"args": {
"text": "s3:4"
},
"by": "tests",
"command": "question add"
},
{
"args": {
"question": "q1",
"text": "s5:6"
},
"by": "tests",
"command": "part add"
},
{
"args": {
"block": "b2ba"
},
"by": "tests",
"command": "mark ignore"
},
{
"args": {
"question": "q1",
"text": "b2bba"
},
"by": "tests",
"command": "part add"
},
{
"args": {
"block": "b2bbb"
},
"by": "tests",
"command": "mark ignore"
},
{
"args": {
"at": 19,
"block": "b3"
},
"by": "tests",
"command": "split block"
},
{
"args": {
"text": "b3a"
},
"by": "tests",
"command": "question add"
},
{
"args": {
"at": 20,
"block": "b3b"
},
"by": "tests",
"command": "split block"
},
{
"args": {
"block": "b3ba"
},
"by": "tests",
"command": "mark ignore"
},
{
"args": {
"at": 21,
"block": "b3bb"
},
"by": "tests",
"command": "split block"
},
{
"args": {
"question": "q2",
"text": "b3bba"
},
"by": "tests",
"command": "question solution"
},
{
"args": {
"block": "b3bbb"
},
"by": "tests",
"command": "mark ignore"
}
]
2 changes: 2 additions & 0 deletions tests/fixtures/against_convert/PartSolPartSol/differs.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Question 1 "", part (a), worked solution: the draft says '' and convert says '$1+1 = 2$' # t53: no command writes qN.pM.solution
Question 1 "", part (b), worked solution: the draft says '' and convert says '$2+2=4$' # t53: no command writes qN.pM.solution
4 changes: 4 additions & 0 deletions tests/fixtures/against_convert/PartsOneSol/differs.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Question 1 "", part (a), worked solution: the draft says 'This is the final answer. It contains the solutions for all parts without breaking them up. $$ 1+1 = 2 $$ The solution is copied across all parts.' and convert says '' # t52: the PartsOneSol filter drops every solution
Question 1 "", part (b), text: the draft says "The filter still works even if there aren't any parts" and convert says 'The filter still works even if there aren’t any parts' # smart quotes: pandoc's LaTeX reader writes ’ where its commonmark_x writer writes '
Question 1 "", part (b), worked solution: the draft says 'This is the final answer. It contains the solutions for all parts without breaking them up. $$ 1+1 = 2 $$ The solution is copied across all parts.' and convert says '' # t52: the PartsOneSol filter drops every solution
Question 2 "", part (a): the draft wrote this part and convert did not # t52: the PartsOneSol filter drops every solution
6 changes: 6 additions & 0 deletions tests/fixtures/against_convert/PartsOneSol/spec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
question: Para
part: ListItem
solution: Div
strip: ['(?m)^::: \{\.solution\}\n', '(?m)^:::$']
ignore: Header
layout: PartsOneSol
Loading
Loading