From 9444b06c1043a0509712361d3956c9fef3e69c5b Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Sun, 20 Sep 2026 10:18:17 +0100 Subject: [PATCH 1/4] implement: Freeze a source document into a draft (t22) --- CHANGELOG.md | 1 + in2lambda/main.py | 110 +++--- in2lambda/source/__init__.py | 318 ++++++++++++++++++ tests/conftest.py | 6 + tests/fixtures/sources/README.md | 22 ++ tests/fixtures/sources/docx/expected.json | 44 +++ tests/fixtures/sources/docx/source.docx | Bin 0 -> 74214 bytes tests/fixtures/sources/markdown/expected.json | 44 +++ tests/fixtures/sources/markdown/source.md | 16 + tests/fixtures/sources/tex/expected.json | 38 +++ tests/fixtures/sources/tex/source.tex | 14 + tests/test_cli.py | 2 +- tests/test_conversion_tools.py | 12 + tests/test_source.py | 100 ++++++ 14 files changed, 659 insertions(+), 68 deletions(-) create mode 100644 in2lambda/source/__init__.py create mode 100644 tests/fixtures/sources/README.md create mode 100644 tests/fixtures/sources/docx/expected.json create mode 100644 tests/fixtures/sources/docx/source.docx create mode 100644 tests/fixtures/sources/markdown/expected.json create mode 100644 tests/fixtures/sources/markdown/source.md create mode 100644 tests/fixtures/sources/tex/expected.json create mode 100644 tests/fixtures/sources/tex/source.tex create mode 100644 tests/test_source.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cc2ce88..8afeec7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,4 +5,5 @@ - Converting a document is now `in2lambda convert FILE FILTER`, with the same options as before (`-o/--out`, `-a/--answers`). Scripts and Docker invocations that run `in2lambda FILE FILTER` need the extra word. - `in2lambda FILE FILTER` exits with an error naming the command to run instead, rather than printing its usage and exiting successfully. - beartype is now `^0.22`. At 0.20.0 and below its import hook leaves `cli` a plain function rather than a group, so the new command line either fails to import or runs `convert` whatever the arguments; 0.20.1 is the first version that works. +- `in2lambda source add FILE` freezes a document: it converts .docx and .tex to markdown beside the file, and writes a `draft.json` holding the markdown's hash and every block in it with the lines it spans, so that another tool can quote the source by line range. `in2lambda source show` prints that markdown numbered with the block ids. Freezing a file that has changed since is refused unless `--start-over` says to discard the draft. Both need pandoc and the `convert` extra, as `convert` does. - The Python API is unchanged: `in2lambda.main.runner` and everything under `in2lambda.api` take the same arguments and return the same objects. diff --git a/in2lambda/main.py b/in2lambda/main.py index 52b1de0..5e5eab7 100644 --- a/in2lambda/main.py +++ b/in2lambda/main.py @@ -7,32 +7,25 @@ # sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) import importlib -import importlib.util import shlex -import shutil -import subprocess from typing import Optional import rich_click as click import in2lambda.filters +import in2lambda.source from in2lambda.api.set import Set - -class ConversionToolsMissing(RuntimeError): - """Document conversion was asked for without pandoc or panflute installed.""" - - -def _require_conversion_tools() -> None: - missing = [] - if shutil.which("pandoc") is None: - missing.append("pandoc (see https://pandoc.org/installing.html)") - if importlib.util.find_spec("panflute") is None: - missing.append("panflute (pip install 'in2lambda[convert]')") - if missing: - raise ConversionToolsMissing( - f"Converting documents needs {' and '.join(missing)}." - ) +# Both were defined here before there was an in2lambda.source, and are in other +# people's scripts as in2lambda.main names. +from in2lambda.source import ( + ConversionToolsMissing, + DraftExists, + DraftMissing, + _pandoc, + _require_conversion_tools, + file_type, +) def docx_to_md(docx_file: str) -> str: @@ -44,53 +37,7 @@ def docx_to_md(docx_file: str) -> str: Returns: the contents of the .docx file in markdown formatting """ - md_output = subprocess.check_output(["pandoc", docx_file, "-t", "markdown"]) - return md_output.decode("utf-8") - - -def file_type(file: str) -> str: - """Determines which pandoc file format to use for a given file. - - See https://github.com/jgm/pandoc/blob/bad922a69236e22b20d51c4ec0b90c5a6c038433/src/Text/Pandoc/Format.hs#L171 - (or any newer commit) for pandoc's supported file extensions. - - Args: - file: A file path with the file extension included. - - Returns: - An option in `pandoc --list-input-formats` that matches the given file type - - Examples: - >>> from in2lambda.main import file_type - >>> file_type("example.tex") - 'latex' - >>> file_type("/some/random/path/demo.md") - 'markdown' - >>> file_type("no_extension") - Traceback (most recent call last): - RuntimeError: Unsupported file extension: .no_extension - >>> file_type("demo.unknown_extension") - Traceback (most recent call last): - RuntimeError: Unsupported file extension: .unknown_extension - """ - match (extension := file.split(".")[-1].lower()): - case "tex" | "latex" | "ltx": - return "latex" - case ( - "md" - | "rmd" - | "markdown" - | "mdown" - | "mdwn" - | "mkd" - | "mkdn" - | "text" - | "txt" - ): - return "markdown" - case "docx": - return "docx" # Pandoc doesn't seem to support .doc, and panflute doesn't like .docx. - raise RuntimeError(f"Unsupported file extension: .{extension}") + return _pandoc(docx_file, "markdown") def runner( @@ -154,9 +101,7 @@ def runner( # 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) answer_format = "markdown" else: @@ -251,5 +196,36 @@ def convert( raise click.ClickException(str(error)) from None +@cli.group("source") +def source_group() -> None: + """Freezes a source document, so its text can be quoted by line range.""" + + +@source_group.command("add") +@click.argument("file", type=click.Path(exists=True, dir_okay=False, resolve_path=True)) +@click.option( + "--start-over", + is_flag=True, + help="Freeze FILE again, discarding the draft already there.", +) +def source_add(file: str, start_over: bool) -> None: + """Converts FILE to markdown and records its blocks in draft.json beside it.""" + try: + draft = in2lambda.source.add(file, start_over) + except (ConversionToolsMissing, DraftExists) as error: + # Exit with what to do about it rather than a traceback. + raise click.ClickException(str(error)) from None + click.echo(f"Wrote {draft}") + + +@source_group.command("show") +def source_show() -> None: + """Prints the frozen markdown of the draft in this directory, numbered.""" + try: + click.echo(in2lambda.source.show()) + except DraftMissing as error: + raise click.ClickException(str(error)) from None + + if __name__ == "__main__": cli() diff --git a/in2lambda/source/__init__.py b/in2lambda/source/__init__.py new file mode 100644 index 0000000..70fab12 --- /dev/null +++ b/in2lambda/source/__init__.py @@ -0,0 +1,318 @@ +"""Freezes a source document, so that its 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 ``draft.json`` listing +every top-level block with the lines it spans. + +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. +""" + +import hashlib +import importlib.util +import json +import re +import shutil +import subprocess +from dataclasses import dataclass +from pathlib import Path + +DRAFT = "draft.json" +"""What a frozen source is written to, beside the source itself.""" + +_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. +""" + +_POSITION = re.compile(r"(?:[^@;]*@)?(\d+):\d+-(\d+):(\d+)") +"""One ``line:column-line:column`` of a ``data-pos``, which may name a file and repeat.""" + + +class ConversionToolsMissing(RuntimeError): + """Document conversion was asked for without pandoc or panflute installed.""" + + +class DraftExists(RuntimeError): + """A draft is already there and was not made from this version of the source.""" + + +class DraftMissing(RuntimeError): + """There is no draft to show in the directory asked about.""" + + +def _require_conversion_tools() -> None: + missing = [] + if shutil.which("pandoc") is None: + missing.append("pandoc (see https://pandoc.org/installing.html)") + if importlib.util.find_spec("panflute") is None: + missing.append("panflute (pip install 'in2lambda[convert]')") + if missing: + raise ConversionToolsMissing( + f"Converting documents needs {' and '.join(missing)}." + ) + + +def file_type(file: str) -> str: + """Determines which pandoc file format to use for a given file. + + See https://github.com/jgm/pandoc/blob/bad922a69236e22b20d51c4ec0b90c5a6c038433/src/Text/Pandoc/Format.hs#L171 + (or any newer commit) for pandoc's supported file extensions. + + Args: + file: A file path with the file extension included. + + Returns: + An option in `pandoc --list-input-formats` that matches the given file type + + Examples: + >>> from in2lambda.source import file_type + >>> file_type("example.tex") + 'latex' + >>> file_type("/some/random/path/demo.md") + 'markdown' + >>> file_type("no_extension") + Traceback (most recent call last): + RuntimeError: Unsupported file extension: .no_extension + >>> file_type("demo.unknown_extension") + Traceback (most recent call last): + RuntimeError: Unsupported file extension: .unknown_extension + """ + match (extension := file.split(".")[-1].lower()): + case "tex" | "latex" | "ltx": + return "latex" + case ( + "md" + | "rmd" + | "markdown" + | "mdown" + | "mdwn" + | "mkd" + | "mkdn" + | "text" + | "txt" + ): + return "markdown" + case "docx": + return "docx" # Pandoc doesn't seem to support .doc, and panflute doesn't like .docx. + raise RuntimeError(f"Unsupported file extension: .{extension}") + + +def _pandoc(file: str, to: str) -> str: + """The given file, as pandoc writes it in the `to` format.""" + output = subprocess.check_output(["pandoc", file, "-f", file_type(file), "-t", to]) + return output.decode("utf-8") + + +@dataclass +class Block: + """One top-level block of a frozen source, and the lines it spans. + + Lines are 1-based and inclusive, so `start` and `end` are the numbers + :func:`show` prints beside that block's first and last line. + """ + + id: str + type: str + start: int + end: int + + def to_dict(self) -> dict[str, str | int]: + """The block as it is written into ``draft.json``.""" + return {"id": self.id, "type": self.type, "start": self.start, "end": self.end} + + +def blocks(markdown: str) -> list[Block]: + r"""Every top-level block of some markdown, in the order it is written. + + Args: + markdown: A document in the dialect :func:`add` freezes to. + + 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. + + Examples: + >>> from in2lambda.source import blocks + >>> blocks("# Title\n\nSome words.\n") + [Block(id='b1', type='heading', start=1, end=1), Block(id='b2', type='paragraph', start=3, end=3)] + """ + import panflute as pf + + document = pf.convert_text( + markdown, input_format=f"{_MARKDOWN}+sourcepos", standalone=True + ) + found = [span for element in document.content for span in _spans(element, pf)] + return [ + Block(f"b{number}", kind, start, end) + for number, (kind, start, end) in enumerate(found, start=1) + ] + + +def _spans(element, pf): # type: ignore[no-untyped-def] + """The ``(type, start, end)`` triples 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. + """ + inner = _unwrapped(element, pf) + if isinstance(inner, (pf.BulletList, pf.OrderedList)): + return [ + ("list item", _range(item.content[0])[0], _range(item.content[-1])[1]) + for item in inner.content + ] + return [(_kind(inner, pf), *_range(element))] + + +def _unwrapped(element, pf): # type: ignore[no-untyped-def] + """What an element is, 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. + """ + if isinstance(element, pf.Div) and element.attributes.get("wrapper"): + return element.content[0] + return element + + +def _kind(inner, pf) -> str: # type: ignore[no-untyped-def] + """Which of the ticket's block types 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. + contents = [ + item + for element in inner.content + for item in (element.content if isinstance(element, pf.Span) else [element]) + if not isinstance(item, (pf.Space, pf.SoftBreak)) + ] + if len(contents) == 1: + if isinstance(contents[0], pf.Math) and contents[0].format == "DisplayMath": + return "display maths" + if isinstance(contents[0], pf.Image): + return "image" + return "paragraph" + return "other" + + +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 + line, which is how pandoc reports every block that ends in a newline. + """ + positions = _POSITION.findall(element.attributes["data-pos"]) + return ( + min(int(start) for start, _, _ in positions), + max( + int(end) - 1 if column == "1" else int(end) for _, end, column in positions + ), + ) + + +def add(file: str, start_over: bool = False) -> Path: + """Freezes a document and writes the draft of it beside the file. + + A .docx or .tex file is converted to markdown next to it; a markdown file is taken + as it is and nothing is copied. Either way the markdown is hashed and its blocks + written to ``draft.json``, so that whatever quotes the source by line range can tell + that the lines it was given still say what they said. + + Args: + file: The document to freeze, as .docx, .tex or markdown. + start_over: Freeze the file again, discarding whatever is already there. + + Returns: + The path of the ``draft.json`` that was written. + + Raises: + ConversionToolsMissing: pandoc or panflute is not installed. + DraftExists: the source has changed since it was frozen, or the markdown would + overwrite a file that no draft claims. Neither happens with `start_over`. + """ + _require_conversion_tools() + source = Path(file) + markdown = ( + source.read_bytes() + if file_type(file) == "markdown" + else _pandoc(file, _MARKDOWN).encode("utf-8") + ) + frozen = source if file_type(file) == "markdown" else source.with_suffix(".md") + draft = source.parent / DRAFT + digest = f"sha256:{hashlib.sha256(markdown).hexdigest()}" + + if not start_over: + if draft.is_file(): + if json.loads(draft.read_text(encoding="utf-8"))["hash"] != digest: + raise DraftExists( + f"{source.name} has changed since {DRAFT} was written from it. " + "Run in2lambda source add --start-over to freeze it again, which " + "invalidates every line range taken from the old draft." + ) + elif frozen != source and frozen.exists(): + raise DraftExists( + f"{frozen.name} is already there and no {DRAFT} claims it, so it is " + "not ours to overwrite. Move it aside, or run in2lambda source add " + "--start-over." + ) + + if frozen != source: + frozen.write_bytes(markdown) + draft.write_text( + json.dumps( + { + "source": frozen.name, + "hash": digest, + "blocks": [ + block.to_dict() for block in blocks(markdown.decode("utf-8")) + ], + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + return draft + + +def show(directory: str = ".") -> str: + """The frozen markdown of a draft, numbered, with block ids in the margin. + + Args: + directory: Where the ``draft.json`` to print is. + + Returns: + One line per line of the frozen markdown: the id of the block starting there, + where one does, then the line number and the line itself. + + Raises: + DraftMissing: there is no draft in that directory. + """ + draft_path = Path(directory) / DRAFT + if not draft_path.is_file(): + raise DraftMissing( + f"There is no {DRAFT} in {draft_path.parent.resolve()}. " + "Run in2lambda source add FILE first." + ) + draft = json.loads(draft_path.read_text(encoding="utf-8")) + + ids = {block["start"]: block["id"] for block in draft["blocks"]} + lines = ( + (draft_path.parent / draft["source"]).read_text(encoding="utf-8").splitlines() + ) + margin = max((len(block_id) for block_id in ids.values()), default=0) + numbers = len(str(len(lines))) + return "\n".join( + f"{ids.get(number, ''):>{margin}} {number:>{numbers}} {line}".rstrip() + for number, line in enumerate(lines, start=1) + ) diff --git a/tests/conftest.py b/tests/conftest.py index 7e50eba..189112d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,6 +19,12 @@ PROBLEM_SETS = sorted(path for path in PROBLEMS_DIR.iterdir() if path.is_dir()) """Every folder of the above, found the same way: covering a check means adding one.""" +SOURCES_DIR = Path(__file__).parent / "fixtures" / "sources" +"""One document per folder for `in2lambda source add`, beside the blocks it should find.""" + +SOURCES = sorted(path for path in SOURCES_DIR.iterdir() if path.is_dir()) +"""Every source folder, so that covering another construct is a folder and no code.""" + @pytest.fixture(scope="session") def filters_dir() -> str: diff --git a/tests/fixtures/sources/README.md b/tests/fixtures/sources/README.md new file mode 100644 index 0000000..08c59b8 --- /dev/null +++ b/tests/fixtures/sources/README.md @@ -0,0 +1,22 @@ +# Sources to freeze + +Each folder here is one document for `in2lambda source add` to freeze, beside the block list +it should write into `draft.json`. The three say the same thing in the three formats the +command takes, so that what a heading or a list item comes out as does not depend on which +format an author brought it in. + +To cover another construct, add a folder: one `source.md`, `source.tex` or `source.docx`, and +the `expected.json` the test compares the draft's `blocks` against. + +The line ranges of the `.tex` and `.docx` cases are ranges in the markdown pandoc writes, not +in the document itself, so they move if pandoc's `commonmark_x` writer changes. They were +produced with **pandoc 3.9.0.2**. + +`docx/source.docx` was made from `markdown/source.md` with `pandoc source.md -o source.docx`, +run beside a `figure.png` so that the image is embedded rather than dropped, and with the +image's alt text removed: pandoc turns a captioned image into a figure, which `commonmark_x` +can only write as raw HTML, and the block would then be `other` rather than `image`. A Word +image usually has no alt text, so this is also the ordinary case. + +The image the docx embeds is referenced as `media/rId9.png`, which is not extracted - nothing +here reads the image, only the lines around it. diff --git a/tests/fixtures/sources/docx/expected.json b/tests/fixtures/sources/docx/expected.json new file mode 100644 index 0000000..3b34c33 --- /dev/null +++ b/tests/fixtures/sources/docx/expected.json @@ -0,0 +1,44 @@ +[ + { + "end": 1, + "id": "b1", + "start": 1, + "type": "heading" + }, + { + "end": 4, + "id": "b2", + "start": 3, + "type": "paragraph" + }, + { + "end": 6, + "id": "b3", + "start": 6, + "type": "list item" + }, + { + "end": 7, + "id": "b4", + "start": 7, + "type": "list item" + }, + { + "end": 9, + "id": "b5", + "start": 9, + "type": "display maths" + }, + { + "end": 12, + "id": "b6", + "start": 11, + "type": "image" + }, + { + "end": 16, + "id": "b7", + "start": 14, + "type": "other" + } +] diff --git a/tests/fixtures/sources/docx/source.docx b/tests/fixtures/sources/docx/source.docx new file mode 100644 index 0000000000000000000000000000000000000000..b03ab274f83d51666e36759d4a33bb94d700e19a GIT binary patch literal 74214 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 None: cli, {}, "in2lambda", "_IN2LAMBDA_COMPLETE" ).get_completions(["./questions.tex"], "") - assert [candidate.value for candidate in completions] == ["convert"] + assert [candidate.value for candidate in completions] == ["convert", "source"] diff --git a/tests/test_conversion_tools.py b/tests/test_conversion_tools.py index 26c1bc4..2722f9c 100644 --- a/tests/test_conversion_tools.py +++ b/tests/test_conversion_tools.py @@ -46,3 +46,15 @@ def test_cli_exits_with_message(filters_dir: str, monkeypatch, tmp_path) -> None assert result.exit_code != 0 assert PANFLUTE_HINT in result.output assert isinstance(result.exception, SystemExit) + + +def test_source_add_exits_with_message(filters_dir: str, monkeypatch) -> None: + """Freezing a document needs the same tools, and says so the same way.""" + monkeypatch.setitem(sys.modules, "panflute", None) + example = os.path.join(filters_dir, "PartsSepSol", "example.tex") + + result = CliRunner().invoke(cli, ["source", "add", example]) + + assert result.exit_code != 0 + assert PANFLUTE_HINT in result.output + assert isinstance(result.exception, SystemExit) diff --git a/tests/test_source.py b/tests/test_source.py new file mode 100644 index 0000000..e5b9fe2 --- /dev/null +++ b/tests/test_source.py @@ -0,0 +1,100 @@ +"""Freezing a source document, and what happens when it changes afterwards. + +Each folder in ``fixtures/sources`` is one document beside the block list freezing it +should produce, so covering another construct means adding a folder rather than a test. +The rest is what the command line does - refusing a draft whose source has moved on, +and printing one - which is not something a fixture can say. +""" + +import hashlib +import json +import shutil +from pathlib import Path + +import pytest +from click.testing import CliRunner +from conftest import SOURCES, SOURCES_DIR + +from in2lambda.main import cli + +MARKDOWN = SOURCES_DIR / "markdown" +"""The case the tests below happen to use; what they check holds for any of them.""" + + +def _frozen(directory: Path) -> Path: + """The document a folder holds, whatever format it is in.""" + (source,) = (path for path in directory.iterdir() if path.stem == "source") + return source + + +@pytest.mark.parametrize("folder", SOURCES, ids=lambda path: path.name) +def test_source_add_finds_the_expected_blocks(folder: Path, tmp_path: Path) -> None: + """Each document freezes to the block list written beside it, hash and all.""" + shutil.copytree(folder, tmp_path, dirs_exist_ok=True) + + result = CliRunner().invoke(cli, ["source", "add", str(_frozen(tmp_path))]) + + assert result.exit_code == 0, result.output + draft = json.loads((tmp_path / "draft.json").read_text()) + assert draft["blocks"] == json.loads((folder / "expected.json").read_text()) + markdown = (tmp_path / draft["source"]).read_bytes() + assert draft["hash"] == f"sha256:{hashlib.sha256(markdown).hexdigest()}" + + +def test_freezing_again_is_refused_once_the_source_has_changed( + tmp_path: Path, monkeypatch +) -> None: + """A draft is only an address for line ranges while the lines have not moved.""" + monkeypatch.setenv("COLUMNS", "200") # So the message is not wrapped mid-sentence. + source = tmp_path / "source.md" + shutil.copy(_frozen(MARKDOWN), source) + runner = CliRunner() + assert runner.invoke(cli, ["source", "add", str(source)]).exit_code == 0 + frozen = (tmp_path / "draft.json").read_text() + + # Freezing the same file again changes nothing, so it is allowed. + assert runner.invoke(cli, ["source", "add", str(source)]).exit_code == 0 + assert (tmp_path / "draft.json").read_text() == frozen + + source.write_text(f"{source.read_text()}\nAn afterthought.\n") + result = runner.invoke(cli, ["source", "add", str(source)]) + + assert result.exit_code != 0 + assert "--start-over" in result.output + assert (tmp_path / "draft.json").read_text() == frozen + + assert ( + runner.invoke(cli, ["source", "add", str(source), "--start-over"]).exit_code + == 0 + ) + assert (tmp_path / "draft.json").read_text() != frozen + + +def test_source_show_numbers_the_lines_and_names_the_blocks( + tmp_path: Path, monkeypatch +) -> None: + """`source show` is how someone checks the ids the agent will be quoting.""" + shutil.copytree(MARKDOWN, tmp_path, dirs_exist_ok=True) + monkeypatch.chdir(tmp_path) + runner = CliRunner() + runner.invoke(cli, ["source", "add", str(_frozen(tmp_path))]) + + result = runner.invoke(cli, ["source", "show"]) + + assert result.exit_code == 0, result.output + lines = result.output.splitlines() + markdown = (tmp_path / "source.md").read_text().splitlines() + assert lines[0] == f"b1 1 {markdown[0]}" + assert lines[1] == " 2" # A blank line still gets its number, with no id. + assert len(lines) == len(markdown) + + +def test_source_show_without_a_draft_says_so(tmp_path: Path, monkeypatch) -> None: + """Running it in the wrong directory is a message, not a traceback.""" + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke(cli, ["source", "show"]) + + assert result.exit_code != 0 + assert "in2lambda source add" in result.output + assert isinstance(result.exception, SystemExit) From 878c10b332ed8f2fc8a4d57bd088299826175f3d Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Sun, 20 Sep 2026 10:25:55 +0100 Subject: [PATCH 2/4] implement: Freeze a source document into a draft (t22) --- in2lambda/source/__init__.py | 17 ++++++++++------- tests/fixtures/sources/README.md | 7 ++++--- .../sources/empty_list_item/expected.json | 8 ++++++++ .../fixtures/sources/empty_list_item/source.md | 2 ++ tests/test_source.py | 7 +++++++ 5 files changed, 31 insertions(+), 10 deletions(-) create mode 100644 tests/fixtures/sources/empty_list_item/expected.json create mode 100644 tests/fixtures/sources/empty_list_item/source.md diff --git a/in2lambda/source/__init__.py b/in2lambda/source/__init__.py index 70fab12..2a529fc 100644 --- a/in2lambda/source/__init__.py +++ b/in2lambda/source/__init__.py @@ -165,9 +165,13 @@ def _spans(element, pf): # type: ignore[no-untyped-def] """ 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. return [ ("list item", _range(item.content[0])[0], _range(item.content[-1])[1]) for item in inner.content + if len(item.content) ] return [(_kind(inner, pf), *_range(element))] @@ -266,17 +270,16 @@ def add(file: str, start_over: bool = False) -> Path: "--start-over." ) + # Before either file is written: a parse that fails half way through would + # otherwise leave the markdown there with no draft claiming it, and the next run + # would refuse to touch a file this one wrote. + found = [block.to_dict() for block in blocks(markdown.decode("utf-8"))] + if frozen != source: frozen.write_bytes(markdown) draft.write_text( json.dumps( - { - "source": frozen.name, - "hash": digest, - "blocks": [ - block.to_dict() for block in blocks(markdown.decode("utf-8")) - ], - }, + {"source": frozen.name, "hash": digest, "blocks": found}, indent=2, ) + "\n", diff --git a/tests/fixtures/sources/README.md b/tests/fixtures/sources/README.md index 08c59b8..b01f8bc 100644 --- a/tests/fixtures/sources/README.md +++ b/tests/fixtures/sources/README.md @@ -1,9 +1,10 @@ # Sources to freeze Each folder here is one document for `in2lambda source add` to freeze, beside the block list -it should write into `draft.json`. The three say the same thing in the three formats the -command takes, so that what a heading or a list item comes out as does not depend on which -format an author brought it in. +it should write into `draft.json`. `markdown`, `tex` and `docx` say the same thing in the three +formats the command takes, so that what a heading or a list item comes out as does not depend on +which format an author brought it in; `empty_list_item` is a bullet with nothing in it, which has +no position of its own and so no block. To cover another construct, add a folder: one `source.md`, `source.tex` or `source.docx`, and the `expected.json` the test compares the draft's `blocks` against. diff --git a/tests/fixtures/sources/empty_list_item/expected.json b/tests/fixtures/sources/empty_list_item/expected.json new file mode 100644 index 0000000..4b98e69 --- /dev/null +++ b/tests/fixtures/sources/empty_list_item/expected.json @@ -0,0 +1,8 @@ +[ + { + "end": 2, + "id": "b1", + "start": 2, + "type": "list item" + } +] diff --git a/tests/fixtures/sources/empty_list_item/source.md b/tests/fixtures/sources/empty_list_item/source.md new file mode 100644 index 0000000..33c7283 --- /dev/null +++ b/tests/fixtures/sources/empty_list_item/source.md @@ -0,0 +1,2 @@ +- +- The large piston has area $A$. diff --git a/tests/test_source.py b/tests/test_source.py index e5b9fe2..9b1754f 100644 --- a/tests/test_source.py +++ b/tests/test_source.py @@ -8,6 +8,7 @@ import hashlib import json +import re import shutil from pathlib import Path @@ -88,6 +89,12 @@ def test_source_show_numbers_the_lines_and_names_the_blocks( assert lines[1] == " 2" # A blank line still gets its number, with no id. assert len(lines) == len(markdown) + # Every block's id sits on the line it starts at, and nothing else carries one. + blocks = json.loads((tmp_path / "draft.json").read_text())["blocks"] + for block in blocks: + assert lines[block["start"] - 1].split()[0] == block["id"] + assert sum(bool(re.match(r" *b\d+ ", line)) for line in lines) == len(blocks) + def test_source_show_without_a_draft_says_so(tmp_path: Path, monkeypatch) -> None: """Running it in the wrong directory is a message, not a traceback.""" From 4278effe5f50aa2c9359f8528df8355dea7484be Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Sun, 20 Sep 2026 10:40:07 +0100 Subject: [PATCH 3/4] implement: Freeze a source document into a draft (t22) --- CHANGELOG.md | 2 +- in2lambda/main.py | 7 +- in2lambda/source/__init__.py | 133 +++++++++++++++--- tests/fixtures/sources/README.md | 4 +- .../sources/unseparated_list/expected.json | 26 ++++ .../sources/unseparated_list/source.md | 5 + tests/test_source.py | 78 +++++++++- 7 files changed, 227 insertions(+), 28 deletions(-) create mode 100644 tests/fixtures/sources/unseparated_list/expected.json create mode 100644 tests/fixtures/sources/unseparated_list/source.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 8afeec7..f95b615 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,5 +5,5 @@ - Converting a document is now `in2lambda convert FILE FILTER`, with the same options as before (`-o/--out`, `-a/--answers`). Scripts and Docker invocations that run `in2lambda FILE FILTER` need the extra word. - `in2lambda FILE FILTER` exits with an error naming the command to run instead, rather than printing its usage and exiting successfully. - beartype is now `^0.22`. At 0.20.0 and below its import hook leaves `cli` a plain function rather than a group, so the new command line either fails to import or runs `convert` whatever the arguments; 0.20.1 is the first version that works. -- `in2lambda source add FILE` freezes a document: it converts .docx and .tex to markdown beside the file, and writes a `draft.json` holding the markdown's hash and every block in it with the lines it spans, so that another tool can quote the source by line range. `in2lambda source show` prints that markdown numbered with the block ids. Freezing a file that has changed since is refused unless `--start-over` says to discard the draft. Both need pandoc and the `convert` extra, as `convert` does. +- `in2lambda source add FILE` freezes a document: it converts .docx and .tex to markdown beside the file, and writes a `draft.json` holding the markdown's hash and every block in it with the lines it spans, so that another tool can quote the source by line range. `in2lambda source show` prints that markdown numbered with the block ids. Freezing a file that has changed since is refused unless `--start-over` says to discard the draft, and so is showing one, since its block ids would name lines they are not the ids of. Both need pandoc and the `convert` extra, as `convert` does. - The Python API is unchanged: `in2lambda.main.runner` and everything under `in2lambda.api` take the same arguments and return the same objects. diff --git a/in2lambda/main.py b/in2lambda/main.py index 5e5eab7..c78f998 100644 --- a/in2lambda/main.py +++ b/in2lambda/main.py @@ -20,8 +20,7 @@ # people's scripts as in2lambda.main names. from in2lambda.source import ( ConversionToolsMissing, - DraftExists, - DraftMissing, + SourceError, _pandoc, _require_conversion_tools, file_type, @@ -212,7 +211,7 @@ def source_add(file: str, start_over: bool) -> None: """Converts FILE to markdown and records its blocks in draft.json beside it.""" try: draft = in2lambda.source.add(file, start_over) - except (ConversionToolsMissing, DraftExists) as error: + except SourceError as error: # Exit with what to do about it rather than a traceback. raise click.ClickException(str(error)) from None click.echo(f"Wrote {draft}") @@ -223,7 +222,7 @@ def source_show() -> None: """Prints the frozen markdown of the draft in this directory, numbered.""" try: click.echo(in2lambda.source.show()) - except DraftMissing as error: + except SourceError as error: raise click.ClickException(str(error)) from None diff --git a/in2lambda/source/__init__.py b/in2lambda/source/__init__.py index 2a529fc..bf0d433 100644 --- a/in2lambda/source/__init__.py +++ b/in2lambda/source/__init__.py @@ -18,10 +18,14 @@ import subprocess from dataclasses import dataclass from pathlib import Path +from typing import Any DRAFT = "draft.json" """What a frozen source is written to, beside the source itself.""" +_FIELDS = ("source", "hash", "blocks") +"""What a draft has in it, and so what one has to have for anything here to read it.""" + _MARKDOWN = "commonmark_x" """The dialect the frozen markdown is written in, and read back as. @@ -35,18 +39,36 @@ """One ``line:column-line:column`` of a ``data-pos``, which may name a file and repeat.""" -class ConversionToolsMissing(RuntimeError): +class SourceError(RuntimeError): + """Freezing or printing a source could not be done, 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. + """ + + +class ConversionToolsMissing(SourceError): """Document conversion was asked for without pandoc or panflute installed.""" -class DraftExists(RuntimeError): +class DraftExists(SourceError): """A draft is already there and was not made from this version of the source.""" -class DraftMissing(RuntimeError): +class DraftMissing(SourceError): """There is no draft to show in the directory asked about.""" +class DraftUnreadable(SourceError): + """There is a file where the draft goes, but it is not a draft.""" + + +class SourceUnreadable(SourceError): + """The markdown to read has moved, or is not text.""" + + def _require_conversion_tools() -> None: missing = [] if shutil.which("pandoc") is None: @@ -110,6 +132,64 @@ def _pandoc(file: str, to: str) -> str: return output.decode("utf-8") +def _digest(markdown: str) -> str: + """How a frozen markdown is named in its draft, so that a change to it shows up.""" + return f"sha256:{hashlib.sha256(markdown.encode('utf-8')).hexdigest()}" + + +def _text(path: Path) -> str: + """A markdown file, given it is still where it was and is still 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. + """ + try: + return path.read_text(encoding="utf-8") + except FileNotFoundError: + raise SourceUnreadable( + f"There is no {path}. Put it back, or freeze the document it came from " + "again with in2lambda source add --start-over." + ) from None + except UnicodeDecodeError: + raise SourceUnreadable( + f"{path} is not UTF-8 text, so it cannot be read as markdown. Save it as " + "UTF-8 and try again." + ) from None + + +def _draft(path: Path) -> dict[str, Any]: + """The draft at the given path, given that something here 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. + """ + if not path.is_file(): + raise DraftMissing( + f"There is no {DRAFT} in {path.parent.resolve()}. " + "Run in2lambda source add FILE first." + ) + advice = ( + "Move it aside and run in2lambda source add FILE, or pass --start-over to " + "write over it." + ) + try: + draft = json.loads(path.read_text(encoding="utf-8")) + fields = draft.keys() + except (ValueError, AttributeError) as error: + # AttributeError: valid JSON, but a list or a number rather than an object. + raise DraftUnreadable( + f"{path} cannot be read as a draft: {error}. {advice}" + ) 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}" + ) + return draft + + @dataclass class Block: """One top-level block of a frozen source, and the lines it spans. @@ -151,9 +231,14 @@ def blocks(markdown: str) -> list[Block]: markdown, input_format=f"{_MARKDOWN}+sourcepos", standalone=True ) 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. + limits = [start - 1 for _, start, _ in found[1:]] + [len(markdown.splitlines())] return [ - Block(f"b{number}", kind, start, end) - for number, (kind, start, end) in enumerate(found, start=1) + Block(f"b{number}", kind, start, min(end, limit)) + for number, ((kind, start, end), limit) in enumerate(zip(found, limits), 1) ] @@ -241,23 +326,24 @@ def add(file: str, start_over: bool = False) -> Path: Raises: ConversionToolsMissing: pandoc or panflute is not installed. + SourceUnreadable: the file is markdown, but not UTF-8 text. + DraftUnreadable: there is a draft.json beside the file that nothing here wrote, + so it is not ours to read a hash out of or to write over. DraftExists: the source has changed since it was frozen, or the markdown would overwrite a file that no draft claims. Neither happens with `start_over`. """ _require_conversion_tools() source = Path(file) markdown = ( - source.read_bytes() - if file_type(file) == "markdown" - else _pandoc(file, _MARKDOWN).encode("utf-8") + _text(source) if file_type(file) == "markdown" else _pandoc(file, _MARKDOWN) ) frozen = source if file_type(file) == "markdown" else source.with_suffix(".md") draft = source.parent / DRAFT - digest = f"sha256:{hashlib.sha256(markdown).hexdigest()}" + digest = _digest(markdown) if not start_over: if draft.is_file(): - if json.loads(draft.read_text(encoding="utf-8"))["hash"] != digest: + if _draft(draft)["hash"] != digest: raise DraftExists( f"{source.name} has changed since {DRAFT} was written from it. " "Run in2lambda source add --start-over to freeze it again, which " @@ -273,10 +359,10 @@ def add(file: str, start_over: bool = False) -> Path: # Before either file is written: a parse that fails half way through would # otherwise leave the markdown there with no draft claiming it, and the next run # would refuse to touch a file this one wrote. - found = [block.to_dict() for block in blocks(markdown.decode("utf-8"))] + found = [block.to_dict() for block in blocks(markdown)] if frozen != source: - frozen.write_bytes(markdown) + frozen.write_text(markdown, encoding="utf-8") draft.write_text( json.dumps( {"source": frozen.name, "hash": digest, "blocks": found}, @@ -300,19 +386,26 @@ def show(directory: str = ".") -> str: Raises: DraftMissing: there is no draft in that directory. + DraftUnreadable: what is there is not a draft anything here wrote. + SourceUnreadable: the markdown the draft names has moved, or is not text. + DraftExists: the markdown has changed since the draft was written from it, so + the ids would be printed against lines they are not the ids of. """ draft_path = Path(directory) / DRAFT - if not draft_path.is_file(): - raise DraftMissing( - f"There is no {DRAFT} in {draft_path.parent.resolve()}. " - "Run in2lambda source add FILE first." + draft = _draft(draft_path) + markdown = _text(draft_path.parent / draft["source"]) + # 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. + if _digest(markdown) != draft["hash"]: + raise DraftExists( + f"{draft['source']} has changed since {DRAFT} was written from it, so its " + "block ids no longer name the lines they were written against. Run " + "in2lambda source add --start-over to freeze the file as it now is." ) - draft = json.loads(draft_path.read_text(encoding="utf-8")) ids = {block["start"]: block["id"] for block in draft["blocks"]} - lines = ( - (draft_path.parent / draft["source"]).read_text(encoding="utf-8").splitlines() - ) + lines = markdown.splitlines() margin = max((len(block_id) for block_id in ids.values()), default=0) numbers = len(str(len(lines))) return "\n".join( diff --git a/tests/fixtures/sources/README.md b/tests/fixtures/sources/README.md index b01f8bc..16e4d2f 100644 --- a/tests/fixtures/sources/README.md +++ b/tests/fixtures/sources/README.md @@ -4,7 +4,9 @@ Each folder here is one document for `in2lambda source add` to freeze, beside th it should write into `draft.json`. `markdown`, `tex` and `docx` say the same thing in the three formats the command takes, so that what a heading or a list item comes out as does not depend on which format an author brought it in; `empty_list_item` is a bullet with nothing in it, which has -no position of its own and so no block. +no position of its own and so no block; `unseparated_list` is a list with no blank line before it, +which pandoc reports as part of the paragraph above, so that paragraph's block has to stop where +the list starts rather than where pandoc says it ends. To cover another construct, add a folder: one `source.md`, `source.tex` or `source.docx`, and the `expected.json` the test compares the draft's `blocks` against. diff --git a/tests/fixtures/sources/unseparated_list/expected.json b/tests/fixtures/sources/unseparated_list/expected.json new file mode 100644 index 0000000..3f96095 --- /dev/null +++ b/tests/fixtures/sources/unseparated_list/expected.json @@ -0,0 +1,26 @@ +[ + { + "end": 1, + "id": "b1", + "start": 1, + "type": "heading" + }, + { + "end": 3, + "id": "b2", + "start": 3, + "type": "paragraph" + }, + { + "end": 4, + "id": "b3", + "start": 4, + "type": "list item" + }, + { + "end": 5, + "id": "b4", + "start": 5, + "type": "list item" + } +] diff --git a/tests/fixtures/sources/unseparated_list/source.md b/tests/fixtures/sources/unseparated_list/source.md new file mode 100644 index 0000000..4e26a55 --- /dev/null +++ b/tests/fixtures/sources/unseparated_list/source.md @@ -0,0 +1,5 @@ +# Hydraulic scale + +A hydraulic scale has two pistons joined by oil. +- The small piston has area $a$. +- The large piston has area $A$. diff --git a/tests/test_source.py b/tests/test_source.py index 9b1754f..50ead84 100644 --- a/tests/test_source.py +++ b/tests/test_source.py @@ -2,8 +2,8 @@ Each folder in ``fixtures/sources`` is one document beside the block list freezing it should produce, so covering another construct means adding a folder rather than a test. -The rest is what the command line does - refusing a draft whose source has moved on, -and printing one - which is not something a fixture can say. +The rest is what the command line does - printing a draft, and refusing one whose source +has moved on or which nothing here wrote - which is not something a fixture can say. """ import hashlib @@ -105,3 +105,77 @@ def test_source_show_without_a_draft_says_so(tmp_path: Path, monkeypatch) -> Non assert result.exit_code != 0 assert "in2lambda source add" in result.output assert isinstance(result.exception, SystemExit) + + +def test_source_show_refuses_once_the_source_has_changed( + tmp_path: Path, monkeypatch +) -> None: + """Ids printed against lines they are not the ids of would look right and be wrong.""" + monkeypatch.setenv("COLUMNS", "200") + shutil.copytree(MARKDOWN, tmp_path, dirs_exist_ok=True) + monkeypatch.chdir(tmp_path) + runner = CliRunner() + assert runner.invoke(cli, ["source", "add", "source.md"]).exit_code == 0 + + source = tmp_path / "source.md" + source.write_text(f"An afterthought.\n\n{source.read_text()}") + result = runner.invoke(cli, ["source", "show"]) + + assert result.exit_code != 0 + assert "--start-over" in result.output + assert isinstance(result.exception, SystemExit) + + +@pytest.mark.parametrize( + "content", ["{ not json at all", '{"blocks": []}'], ids=["not-json", "foreign"] +) +@pytest.mark.parametrize( + "arguments", + [["source", "add", "source.md"], ["source", "show"]], + ids=["add", "show"], +) +def test_a_draft_from_somewhere_else_is_refused( + content: str, arguments: list[str], tmp_path: Path, monkeypatch +) -> None: + """A draft.json nothing here wrote is neither read from nor written over.""" + monkeypatch.setenv("COLUMNS", "200") + shutil.copy(_frozen(MARKDOWN), tmp_path / "source.md") + (tmp_path / "draft.json").write_text(content) + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke(cli, arguments) + + assert result.exit_code != 0 + assert "--start-over" in result.output + assert isinstance(result.exception, SystemExit) + assert (tmp_path / "draft.json").read_text() == content + + +def test_a_frozen_file_that_has_gone_is_a_message(tmp_path: Path, monkeypatch) -> None: + """The draft names the markdown, and someone may well have moved it since.""" + monkeypatch.setenv("COLUMNS", "200") + shutil.copytree(MARKDOWN, tmp_path, dirs_exist_ok=True) + monkeypatch.chdir(tmp_path) + runner = CliRunner() + assert runner.invoke(cli, ["source", "add", "source.md"]).exit_code == 0 + (tmp_path / "source.md").unlink() + + result = runner.invoke(cli, ["source", "show"]) + + assert result.exit_code != 0 + assert "source.md" in result.output + assert isinstance(result.exception, SystemExit) + + +def test_a_source_that_is_not_text_is_a_message(tmp_path: Path, monkeypatch) -> None: + """A .md saved in some other encoding cannot be read as markdown, and says so.""" + monkeypatch.setenv("COLUMNS", "200") + source = tmp_path / "source.md" + source.write_bytes(b"\xff\xfe# Hydraulic scale\n") + + result = CliRunner().invoke(cli, ["source", "add", str(source)]) + + assert result.exit_code != 0 + assert "UTF-8" in result.output + assert isinstance(result.exception, SystemExit) + assert not (tmp_path / "draft.json").exists() From 9ebaaf15c75eba16d3b6672ac4ad3ba53435c039 Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Sun, 20 Sep 2026 10:55:11 +0100 Subject: [PATCH 4/4] implement: Freeze a source document into a draft (t22) --- .gitattributes | 2 + in2lambda/main.py | 2 +- in2lambda/source/__init__.py | 55 +++++++++++++++-------- tests/fixtures/sources/README.md | 5 ++- tests/fixtures/sources/crlf/expected.json | 44 ++++++++++++++++++ tests/fixtures/sources/crlf/source.md | 16 +++++++ tests/test_source.py | 27 +++++++++++ 7 files changed, 130 insertions(+), 21 deletions(-) create mode 100644 .gitattributes create mode 100644 tests/fixtures/sources/crlf/expected.json create mode 100644 tests/fixtures/sources/crlf/source.md diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2749c66 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# The fixture is CRLF because that is the thing it is for; nothing may rewrite it. +tests/fixtures/sources/crlf/source.md -text diff --git a/in2lambda/main.py b/in2lambda/main.py index c78f998..9ed757c 100644 --- a/in2lambda/main.py +++ b/in2lambda/main.py @@ -36,7 +36,7 @@ def docx_to_md(docx_file: str) -> str: Returns: the contents of the .docx file in markdown formatting """ - return _pandoc(docx_file, "markdown") + return _pandoc(docx_file, "markdown").decode("utf-8") def runner( diff --git a/in2lambda/source/__init__.py b/in2lambda/source/__init__.py index bf0d433..33aef4f 100644 --- a/in2lambda/source/__init__.py +++ b/in2lambda/source/__init__.py @@ -126,30 +126,42 @@ def file_type(file: str) -> str: raise RuntimeError(f"Unsupported file extension: .{extension}") -def _pandoc(file: str, to: str) -> str: - """The given file, as pandoc writes it in the `to` format.""" - output = subprocess.check_output(["pandoc", file, "-f", file_type(file), "-t", to]) - return output.decode("utf-8") +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. + """ + 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. -def _digest(markdown: str) -> str: - """How a frozen markdown is named in its draft, so that a change to it shows up.""" - return f"sha256:{hashlib.sha256(markdown.encode('utf-8')).hexdigest()}" + 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. + """ + return f"sha256:{hashlib.sha256(data).hexdigest()}" -def _text(path: Path) -> str: - """A markdown file, given it is still where it was and is still text. +def _source(path: Path) -> tuple[bytes, str]: + """A markdown file as bytes and as text, given it is still there and still 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. + 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. """ try: - return path.read_text(encoding="utf-8") + raw = path.read_bytes() except FileNotFoundError: raise SourceUnreadable( f"There is no {path}. Put it back, or freeze the document it came from " "again with in2lambda source add --start-over." ) from None + try: + return raw, raw.decode("utf-8") except UnicodeDecodeError: raise SourceUnreadable( f"{path} is not UTF-8 text, so it cannot be read as markdown. Save it as " @@ -334,12 +346,15 @@ def add(file: str, start_over: bool = False) -> Path: """ _require_conversion_tools() source = Path(file) - markdown = ( - _text(source) if file_type(file) == "markdown" else _pandoc(file, _MARKDOWN) - ) - frozen = source if file_type(file) == "markdown" else source.with_suffix(".md") + if file_type(file) == "markdown": + raw, markdown = _source(source) + frozen = source + else: + raw = _pandoc(file, _MARKDOWN) + markdown = raw.decode("utf-8") + frozen = source.with_suffix(".md") draft = source.parent / DRAFT - digest = _digest(markdown) + digest = _digest(raw) if not start_over: if draft.is_file(): @@ -362,7 +377,9 @@ def add(file: str, start_over: bool = False) -> Path: found = [block.to_dict() for block in blocks(markdown)] if frozen != source: - frozen.write_text(markdown, encoding="utf-8") + # The bytes pandoc wrote, so that the file on disk is what `digest` is of; + # writing text would rewrite the line endings on Windows and it would not be. + frozen.write_bytes(raw) draft.write_text( json.dumps( {"source": frozen.name, "hash": digest, "blocks": found}, @@ -393,11 +410,11 @@ def show(directory: str = ".") -> str: """ draft_path = Path(directory) / DRAFT draft = _draft(draft_path) - markdown = _text(draft_path.parent / draft["source"]) + raw, markdown = _source(draft_path.parent / draft["source"]) # 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. - if _digest(markdown) != draft["hash"]: + if _digest(raw) != draft["hash"]: raise DraftExists( f"{draft['source']} has changed since {DRAFT} was written from it, so its " "block ids no longer name the lines they were written against. Run " diff --git a/tests/fixtures/sources/README.md b/tests/fixtures/sources/README.md index 16e4d2f..0d17e9a 100644 --- a/tests/fixtures/sources/README.md +++ b/tests/fixtures/sources/README.md @@ -6,7 +6,10 @@ formats the command takes, so that what a heading or a list item comes out as do which format an author brought it in; `empty_list_item` is a bullet with nothing in it, which has no position of its own and so no block; `unseparated_list` is a list with no blank line before it, which pandoc reports as part of the paragraph above, so that paragraph's block has to stop where -the list starts rather than where pandoc says it ends. +the list starts rather than where pandoc says it ends; `crlf` is the `markdown` case saved with +Windows line endings, which is what a document off a teacher's machine usually has, and it has to +freeze to the same blocks and to a hash that `sha256sum source.md` reproduces. The `.gitattributes` +at the top of the repository is what stops a checkout rewriting those endings away. To cover another construct, add a folder: one `source.md`, `source.tex` or `source.docx`, and the `expected.json` the test compares the draft's `blocks` against. diff --git a/tests/fixtures/sources/crlf/expected.json b/tests/fixtures/sources/crlf/expected.json new file mode 100644 index 0000000..f190bf3 --- /dev/null +++ b/tests/fixtures/sources/crlf/expected.json @@ -0,0 +1,44 @@ +[ + { + "end": 1, + "id": "b1", + "start": 1, + "type": "heading" + }, + { + "end": 3, + "id": "b2", + "start": 3, + "type": "paragraph" + }, + { + "end": 5, + "id": "b3", + "start": 5, + "type": "list item" + }, + { + "end": 6, + "id": "b4", + "start": 6, + "type": "list item" + }, + { + "end": 10, + "id": "b5", + "start": 8, + "type": "display maths" + }, + { + "end": 12, + "id": "b6", + "start": 12, + "type": "image" + }, + { + "end": 16, + "id": "b7", + "start": 14, + "type": "other" + } +] diff --git a/tests/fixtures/sources/crlf/source.md b/tests/fixtures/sources/crlf/source.md new file mode 100644 index 0000000..a5c40ba --- /dev/null +++ b/tests/fixtures/sources/crlf/source.md @@ -0,0 +1,16 @@ +# Hydraulic scale + +A hydraulic scale has two pistons joined by oil. Find the load the large one carries. + +- The small piston has area $a$. +- The large piston has area $A$. + +$$ +F = p A +$$ + +![The two pistons](figure.png) + +| Quantity | Symbol | +|----------|--------| +| Area | $A$ | diff --git a/tests/test_source.py b/tests/test_source.py index 50ead84..14fe82a 100644 --- a/tests/test_source.py +++ b/tests/test_source.py @@ -71,6 +71,33 @@ def test_freezing_again_is_refused_once_the_source_has_changed( assert (tmp_path / "draft.json").read_text() != frozen +def test_a_markdown_file_no_draft_claims_is_not_overwritten( + tmp_path: Path, monkeypatch +) -> None: + """Freezing a .tex writes a .md beside it, which may be someone else's work.""" + monkeypatch.setenv("COLUMNS", "200") + shutil.copy(_frozen(SOURCES_DIR / "tex"), tmp_path / "source.tex") + theirs = "# Notes I wrote by hand\n" + (tmp_path / "source.md").write_text(theirs) + runner = CliRunner() + + result = runner.invoke(cli, ["source", "add", str(tmp_path / "source.tex")]) + + assert result.exit_code != 0 + assert "--start-over" in result.output + assert (tmp_path / "source.md").read_text() == theirs + assert not (tmp_path / "draft.json").exists() + + # Saying to start over is saying to overwrite it. + result = runner.invoke( + cli, ["source", "add", str(tmp_path / "source.tex"), "--start-over"] + ) + + assert result.exit_code == 0, result.output + assert (tmp_path / "source.md").read_text() != theirs + assert json.loads((tmp_path / "draft.json").read_text())["source"] == "source.md" + + def test_source_show_numbers_the_lines_and_names_the_blocks( tmp_path: Path, monkeypatch ) -> None: