Skip to content

Commit cc46366

Browse files
Fold notation in in2lambda's comparison (t59)
Fold notation in in2lambda's comparison
2 parents 0f899a6 + 4264660 commit cc46366

6 files changed

Lines changed: 159 additions & 23 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,5 @@
2525
- `in2lambda convert FILE PartsOneSol` now exports the worked solution a document writes in a `solution` environment. Pandoc writes that environment as a Div whose classes hold `solution`, and the filter recognised only a Div whose first block reads `Solution`, so a document using the environment exported every question with an empty worked solution. A Div whose first block reads `Solution` is still recognised.
2626
- 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.
2727
- `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.
28-
- `in2lambda compare BUILT_ZIP EXPORT_DIR` compares two Lambda Feedback sets, each given as a folder or a zip, so that a set in2lambda wrote can be checked against the export it should reproduce. Each question's main text is compared, and each part's text and worked solution, and every difference is printed naming the question, the part and the field. Three differences in wording are taken off both sides first: a run of whitespace is compared as one space, an image is compared by the file's name, and a part holding neither text nor a worked solution is dropped where it is the question's only part. `--known FILE` names the differences the two sets are known to have, one line per difference with the ticket that would close it written after ` # `, and `in2lambda compare` exits 1 where the differences found are not the differences that file names. `in2lambda.compare.differences` and `in2lambda.compare.known` are the two functions behind the command.
28+
- `in2lambda compare BUILT_ZIP EXPORT_DIR` compares two Lambda Feedback sets, each given as a folder or a zip, so that a set in2lambda wrote can be checked against the export it should reproduce. Each question's main text is compared, and each part's text and worked solution, and every difference is printed naming the question, the part and the field. The differences in wording that are not differences in what a question says are taken off both sides first: a run of whitespace is compared as one space, a line holding nothing but hyphens is dropped, each curly quote is compared as the straight quote, an image is compared by the file's name, and a part holding neither text nor a worked solution is dropped where it is the question's only part. Inside every `$ ... $` and `$$ ... $$`, `\left` and `\right` are removed, `~`, `\,` and `\space` are compared as a space, and every run of whitespace is dropped, except that a run between a control word and a following letter is compared as one space, so that `$z=2+3 i$` and `$z=2+3i$` are the same expression and `$\alpha x$` and `$\alphax$` are two. `--known FILE` names the differences the two sets are known to have, one line per difference with the ticket that would close it written after ` # `, and `in2lambda compare` exits 1 where the differences found are not the differences that file names. `in2lambda.compare.differences` and `in2lambda.compare.known` are the two functions behind the command.
2929
- 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.

‎in2lambda/compare.py‎

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,30 @@
1-
"""Compares two sets question by question, naming every place they say something else.
1+
r"""Compares two sets question by question, naming every place they say something else.
22
33
`in2lambda convert` writes a set, `in2lambda build` writes a set from a draft, and Lambda
44
Feedback exports a set. :func:`differences` compares any two of them in question and part
55
order - each question's main text, and each part's text and worked solution - and returns
66
one line per difference, naming the question, the part and the field as
77
`in2lambda.validation` names them.
88
9-
Three differences in wording are not differences in what a question says, and are taken
9+
These differences in wording are not differences in what a question says, and are taken
1010
off both sides before comparing:
1111
1212
- **Whitespace.** Every run of whitespace is compared as one space, because a draft
1313
quotes the lines pandoc wrapped where `in2lambda convert` writes a paragraph on one
1414
line.
15+
- **Separator lines.** A line holding nothing but three or more hyphens is dropped,
16+
because Lambda Feedback writes one around a display maths block where a document
17+
writes nothing.
18+
- **Quotes.** ``‘`` and ``’`` are compared as ``'``, and ``“`` and ``”`` as ``"``,
19+
because pandoc's LaTeX reader writes the curly quote where its commonmark_x writer
20+
writes the straight one.
21+
- **Maths notation.** Inside every ``$ ... $`` and ``$$ ... $$``, ``\left`` and
22+
``\right`` are removed, ``~``, ``\,`` and ``\space`` are compared as a space, and
23+
every run of whitespace is dropped, except that a run between a control word and a
24+
following letter is compared as one space. LaTeX renders ``$z=2+3 i$`` and
25+
``$z=2+3i$`` the same, and ``\mathrm{~m}`` and ``\mathrm{m}`` the same, where
26+
``\alpha x`` and ``\alphax`` are two different expressions. An export writes
27+
``$z = 2+3 i$`` where `in2lambda convert` writes ``$z=2+3i$``.
1528
- **Image references.** An image is compared by the file's name, because
1629
`in2lambda convert` writes every image as ``![pictureTag](path)`` where a draft keeps
1730
the alt text the document wrote, and an export names each file as ``media/`` holds it
@@ -25,26 +38,77 @@
2538
after `` # ``.
2639
"""
2740

41+
import re
2842
from itertools import zip_longest
2943
from pathlib import Path
3044
from typing import Any, Optional
3145

3246
from in2lambda.api.question import Question
3347
from in2lambda.api.set import Set
3448
from in2lambda.json_convert.json_convert import _IMAGE
35-
from in2lambda.validation import _location
49+
from in2lambda.validation import _COMMAND, _MATHS, _location
3650

3751
_TICKET = " # "
3852
"""What a line of a differs.txt names the ticket closing it after."""
3953

54+
_RULE = re.compile(r"(?m)^[ \t]*(?:-{3,}|\*{3,}|_{3,})[ \t]*$")
55+
"""A line holding nothing but three or more hyphens, asterisks or underscores: a markdown
56+
separator, which Lambda Feedback writes around a display maths as `---` or `***`."""
57+
58+
_QUOTES = str.maketrans({"‘": "'", "’": "'", "“": '"', "”": '"'})
59+
"""Each curly quote and the straight quote it is compared as."""
60+
61+
_SIZE = re.compile(r"\\(?:left|right)(?![a-zA-Z])")
62+
r"""``\left`` and ``\right``, which size a delimiter without changing which it is."""
63+
64+
_LATEX_SPACE = re.compile(r"~|\\,|\\space(?![a-zA-Z])")
65+
"""The three ways of writing a space inside maths."""
66+
67+
_SPACING = re.compile(rf"({_COMMAND.pattern})\s+(?=[a-zA-Z])|\s+")
68+
"""A run of whitespace inside maths, with the control word it ends where one precedes it
69+
and a letter follows it."""
70+
71+
72+
def _spacing(whitespace: re.Match[str]) -> str:
73+
r"""One space where a run of whitespace ends a control word, and nothing elsewhere.
74+
75+
``\alpha x`` is two symbols and ``\alphax`` is a control word nothing defines, so
76+
the space between a control word and a letter is the only whitespace LaTeX renders.
77+
"""
78+
return f"{whitespace[1]} " if whitespace[1] else ""
79+
80+
81+
def _maths(expression: re.Match[str]) -> str:
82+
"""One ``$ ... $`` or ``$$ ... $$`` with the notation that is not the maths folded.
83+
84+
Args:
85+
expression: A match of `in2lambda.validation._MATHS`, holding the display maths
86+
it found in its first group and the inline maths in its second.
87+
"""
88+
display = expression[1] is not None
89+
tex = expression[1] if display else expression[2]
90+
delimiter = "$$" if display else "$"
91+
folded = _LATEX_SPACE.sub(" ", _SIZE.sub("", tex))
92+
return f"{delimiter}{_SPACING.sub(_spacing, folded)}{delimiter}"
93+
4094

4195
def _text(markdown: str) -> str:
4296
"""A field with the differences in wording that are not differences taken off.
4397
44-
Every run of whitespace becomes one space, and every image reference is written as
45-
the file's name alone. The module docstring says why.
98+
A separator line is dropped, each curly quote becomes a straight quote, the maths
99+
notation inside every ``$ ... $`` and ``$$ ... $$`` is folded, every image reference
100+
is written as the file's name alone, and every run of whitespace becomes one space.
101+
The module docstring says why.
46102
"""
47-
named = _IMAGE.sub(lambda reference: f"![]({Path(reference[1]).name})", markdown)
103+
# Before the whitespace collapse below, which writes the field on one line and
104+
# leaves no line for _RULE to match.
105+
# The platform writes a space as the entity ` ` (and a hard one as ` `).
106+
without_rules = _RULE.sub("", markdown.replace(" ", " ").replace(" ", " "))
107+
folded = _MATHS.sub(_maths, without_rules.translate(_QUOTES))
108+
# A space touching a maths delimiter from outside renders the same either way:
109+
# `of $y$` and `of$y$` are one expression, so the whitespace beside `$` is dropped.
110+
folded = re.sub(r"\s*(\${1,2})\s*", r"\1", folded)
111+
named = _IMAGE.sub(lambda reference: f"![]({Path(reference[1]).name})", folded)
48112
return " ".join(named.split())
49113

50114

‎in2lambda/main.py‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -638,12 +638,15 @@ def compare(built_zip: str, export_dir: str, known_path: Optional[str]) -> None:
638638
639639
Each argument is a Lambda Feedback set, as a folder or as a zip. Each question's main
640640
text is compared, and each part's text and worked solution, and every difference is
641-
printed naming the question, the part and the field. Three differences in wording are
642-
taken off both sides first: a run of whitespace is compared as one space, an image is
641+
printed naming the question, the part and the field. The differences in wording that
642+
are not differences in what a question says are taken off both sides first: a run of
643+
whitespace is compared as one space, a line of hyphens is dropped, a curly quote is
644+
compared as a straight quote, the notation inside maths is folded, an image is
643645
compared by the file's name, and a lone empty part is dropped. in2lambda.compare says
644-
why. --known names a file of the differences the two sets are known to have, one per
645-
line as this command prints it, with a ticket written after " # ". in2lambda compare
646-
exits 1 where the differences found are not the differences --known names.
646+
which notation and why. --known names a file of the differences the two sets are known to
647+
have, one per line as this command prints it, with a ticket written after " # ".
648+
in2lambda compare exits 1 where the differences found are not the differences --known
649+
names.
647650
"""
648651
with _message_not_traceback():
649652
found = in2lambda.compare.differences(

‎tests/fixtures/against_convert/PartsOneSol/differs.txt‎

Lines changed: 0 additions & 2 deletions
This file was deleted.

‎tests/fixtures/against_convert/README.md‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,24 @@ document itself, as the ranges in `fixtures/sources` are. They were produced wit
2626
The comparison is `in2lambda.compare.differences`, whose docstring states these rules, so
2727
that this README and the code do not drift apart.
2828

29-
Each question's main text is compared, and each part's text and worked solution. Three
29+
Each question's main text is compared, and each part's text and worked solution. These
3030
differences between the routes are not differences in what a question says, and are taken
3131
off both sides before comparing:
3232

3333
- **Line breaks.** The draft quotes the lines pandoc wrapped; convert writes a paragraph
3434
on one line. Every run of whitespace is compared as one space.
35+
- **Separator lines.** A line holding nothing but three or more hyphens is dropped,
36+
because Lambda Feedback writes one around a display maths block where a document
37+
writes nothing.
38+
- **Quotes.** Pandoc's LaTeX reader writes `’` where its `commonmark_x` writer writes
39+
`'`, so `aren’t` and `aren't` are the same wording. Each curly quote is compared as
40+
the straight quote.
41+
- **Maths notation.** Inside every `$ ... $` and `$$ ... $$`, `\left` and `\right` are
42+
removed, `~`, `\,` and `\space` are compared as a space, and every run of whitespace
43+
is dropped, except that a run between a control word and a following letter is
44+
compared as one space. LaTeX renders `$z=2+3 i$` and `$z=2+3i$` the same, and
45+
`\mathrm{~m}` and `\mathrm{m}` the same, where `\alpha x` and `\alphax` are two
46+
different expressions.
3547
- **Image references.** Convert writes every image as `![pictureTag](path)`; the draft keeps
3648
the alt text the document wrote, which is empty for `\includegraphics`. The set read back
3749
from the zip names each file as it sits in the export's `media/`, where the set convert
@@ -50,7 +62,3 @@ A folder with no such file is a document the two routes say the same thing about
5062

5163
Finding a difference the file does not list fails the test, and so does agreeing where it
5264
lists one: closing a ticket below means deleting the lines it names.
53-
54-
- **Smart quotes** - pandoc's LaTeX reader writes `’` where its `commonmark_x` writer
55-
writes `'`, so convert uploads `aren’t` for `PartsOneSol/example.tex` and the draft
56-
uploads `aren't`.

‎tests/test_compare.py‎

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,51 @@ def test_a_run_of_whitespace_is_one_space() -> None:
3737
)
3838

3939

40+
def test_a_separator_line_is_dropped() -> None:
41+
"""Lambda Feedback writes a `---` line where a document writes nothing."""
42+
assert (
43+
differences(
44+
_set("The mass is\n\n---\n\n$$m = 1$$"), _set("The mass is\n$$m=1$$")
45+
)
46+
== []
47+
)
48+
49+
50+
def test_a_curly_quote_is_a_straight_quote() -> None:
51+
"""Pandoc's LaTeX reader writes `’` where its commonmark_x writer writes `'`."""
52+
assert differences(_set("It isn’t large."), _set("It isn't large.")) == []
53+
assert differences(_set("The “load”."), _set('The "load".')) == []
54+
55+
56+
def test_whitespace_inside_maths_is_dropped() -> None:
57+
"""LaTeX renders `$z=2+3 i$` and `$z=2+3i$` the same."""
58+
assert differences(_set("$z = 2+3 i$"), _set("$z=2+3i$")) == []
59+
assert differences(_set("$$\nF = pA\n$$"), _set("$$F=pA$$")) == []
60+
61+
62+
def test_a_space_between_a_control_word_and_a_letter_is_kept() -> None:
63+
r"""`\alpha x` is two symbols and `\alphax` is a control word nothing defines."""
64+
assert differences(_set(r"$\alpha x$"), _set(r"$\alpha x$")) == []
65+
66+
assert differences(_set(r"$\alpha x$"), _set(r"$\alphax$")) == [
67+
'Question 1 "", main text: the draft says '
68+
r"'$\\alpha x$' and convert says '$\\alphax$'"
69+
]
70+
71+
72+
def test_a_sized_delimiter_is_the_delimiter() -> None:
73+
r"""`\left(` and `(` render the same bracket."""
74+
assert differences(_set(r"$\left( x+1 \right)$"), _set("$(x+1)$")) == []
75+
76+
77+
def test_each_latex_space_is_a_space() -> None:
78+
r"""`~`, `\,` and `\space` are the three ways of writing a space inside maths."""
79+
assert differences(_set("$a~b$"), _set("$ab$")) == []
80+
assert differences(_set(r"$a\,b$"), _set("$ab$")) == []
81+
assert differences(_set(r"$a\space b$"), _set("$ab$")) == []
82+
assert differences(_set(r"$5\mathrm{~m}$"), _set(r"$5\mathrm{m}$")) == []
83+
84+
4085
def test_an_image_is_compared_by_the_file_name() -> None:
4186
"""The alt text and the directory differ between the routes; the file name does not."""
4287
assert (
@@ -67,12 +112,12 @@ def test_a_difference_names_the_question_the_part_and_the_field() -> None:
67112
expected = _set("Find the load.", ("State the pressure.", "$F = 2pA$"))
68113

69114
assert differences(built, expected) == [
70-
"Question 1 \"\", part (a), worked solution: the draft says '$F = pA$' and "
71-
"convert says '$F = 2pA$'"
115+
"Question 1 \"\", part (a), worked solution: the draft says '$F=pA$' and "
116+
"convert says '$F=2pA$'"
72117
]
73118
assert differences(built, expected, "set.zip", "the export") == [
74-
"Question 1 \"\", part (a), worked solution: set.zip says '$F = pA$' and "
75-
"the export says '$F = 2pA$'"
119+
"Question 1 \"\", part (a), worked solution: set.zip says '$F=pA$' and "
120+
"the export says '$F=2pA$'"
76121
]
77122

78123

@@ -164,3 +209,21 @@ def test_the_command_refuses_a_path_that_is_not_a_set(
164209
result.exception, (ValueError, zipfile.BadZipFile)
165210
), result.output
166211
assert f"{path} is not a Lambda Feedback set" in result.output
212+
213+
214+
def test_an_html_space_entity_is_a_space() -> None:
215+
a = _set("$16y''-\\pi^2y=0$ (Use $A$ and $B$ for your constants.)")
216+
b = _set("$16y''-\\pi^2y=0$     (Use $A$ and $B$ for your constants.)")
217+
assert differences(a, b) == []
218+
219+
220+
def test_whitespace_touching_a_maths_delimiter_from_outside_is_ignored() -> None:
221+
a = _set("equation of $y''+y'-6y=0$: then")
222+
b = _set("equation of$y''+y'-6y=0$: then")
223+
assert differences(a, b) == []
224+
225+
226+
def test_a_separator_of_asterisks_is_dropped_like_one_of_hyphens() -> None:
227+
a = _set("Then:\n\n$$\nx=1\n$$\n\n***\n\nRecall the rule.")
228+
b = _set("Then:\n\n$$\nx=1\n$$\n\nRecall the rule.")
229+
assert differences(a, b) == []

0 commit comments

Comments
 (0)