From dfff12ffe6589bf7b4f118143e3c274004757f53 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Mon, 31 Aug 2026 11:39:36 +0100 Subject: [PATCH 1/3] feat: add Markdown filter for hand-authored question sets A new "Markdown" filter parses a plain #/## markdown document into the Set/Question/Part model: - `#` -> new question (heading is the title) - `##` -> new part - `## Solution` -> worked solution for the current part (or the whole question if it has no parts) - `-a answers.md` -> `#` advances to the next question, body blocks become worked solutions This is the intermediate contract the wizard will emit and hand back for review. runner() now runs in2lambda.validation.check_markdown over any markdown input and echoes a warning per math-delimiter problem (non-fatal). docs/source/filters.py learns to document a filter whose example is example.md (shown inline) instead of example.tex (rendered to a PDF), so the new filter is picked up by the existing autosummary generation. Structure adapted from the Markdown2Lambda branch; the elaborate \st/\fa/\ws/*** syntax there is dropped since the Set/Question/Part model only has question text, part text and one worked solution per part. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017VXb8aZqgFBjoeuuddjW6r --- docs/source/filters.py | 46 +++++++--- docs/source/quickstart.md | 6 ++ in2lambda/filters/Markdown/__init__.py | 1 + in2lambda/filters/Markdown/example.md | 35 ++++++++ in2lambda/filters/Markdown/filter.py | 119 +++++++++++++++++++++++++ in2lambda/main.py | 13 +++ in2lambda/validation/__init__.py | 2 +- in2lambda/validation/delimiters.py | 2 +- tests/test_markdown_filter.py | 72 +++++++++++++++ 9 files changed, 282 insertions(+), 14 deletions(-) create mode 100644 in2lambda/filters/Markdown/__init__.py create mode 100644 in2lambda/filters/Markdown/example.md create mode 100644 in2lambda/filters/Markdown/filter.py create mode 100644 tests/test_markdown_filter.py diff --git a/docs/source/filters.py b/docs/source/filters.py index 2002768..3879d61 100644 --- a/docs/source/filters.py +++ b/docs/source/filters.py @@ -37,20 +37,31 @@ def generate_filters_docs(): If absolute were needed: f"{os.path.dirname(filter_module.__file__)}/filename" """ filter_file = f"{relative_directory}/filter.py" - tex_file = f"{relative_directory}/example.tex" + + # Filters ship either a LaTeX example (rendered to an embedded PDF) or, + # for the plain-markdown filter, a markdown example shown inline. + source_directory = Path(os.path.dirname(filter_module.__file__)) + if (source_directory / "example.tex").is_file(): + example_file = f"{relative_directory}/example.tex" + example_language = "LaTeX" + example_is_latex = True + else: + example_file = f"{relative_directory}/example.md" + example_language = "markdown" + example_is_latex = False # Different path likely needed since GitHub Actions builds with dirhtml builder. # This is relative to the auto-generated filter file. pdf_file = f"../../{'../' if os.getenv('GITHUB_ACTIONS') == 'true' else './'}{static_pdf_directory}/{filter_name}.pdf" - if shutil.which("pdflatex"): + if example_is_latex and shutil.which("pdflatex"): subprocess.run( [ "pdflatex", f"-output-directory={static_pdf_directory}", f"-jobname={filter_name}", "-interaction=nonstopmode", - tex_file, + example_file, ], check=True, ) @@ -58,6 +69,25 @@ def generate_filters_docs(): if not os.path.exists(f"{static_pdf_directory}/{filter_name}.pdf"): raise RuntimeError("PDF output not found") + if example_is_latex: + example_rst = f"""\ +A PDF which this filter parses correctly is shown below: + +.. dropdown:: ๐Ÿ“„ LaTeX Code + + .. literalinclude:: {example_file} + :language: {example_language} + +:pdfembed:`src: {pdf_file}, height:700, width:100%, align:middle` +""" + else: + example_rst = f"""\ +A markdown document which this filter parses correctly is shown below: + +.. literalinclude:: {example_file} + :language: {example_language} +""" + rst_content = f"""\ {filter_name} {'*' * len(filter_name)} @@ -67,15 +97,7 @@ def generate_filters_docs(): Minimal Example ---------------- -A PDF which this filter parses correctly is shown below: - -.. dropdown:: ๐Ÿ“„ LaTeX Code - - .. literalinclude:: {tex_file} - :language: LaTeX - -:pdfembed:`src: {pdf_file}, height:700, width:100%, align:middle` - +{example_rst} .. dropdown:: ๐Ÿ Python Filter .. literalinclude:: {filter_file} diff --git a/docs/source/quickstart.md b/docs/source/quickstart.md index 34fd175..194f94e 100644 --- a/docs/source/quickstart.md +++ b/docs/source/quickstart.md @@ -69,6 +69,12 @@ Another filter might be used if [the answers are in a separate file](filters/_au $ in2lambda questions.tex -a solutions.tex PartsSepSol ``` +If you would rather write the questions yourself, the [`Markdown` filter](filters/_autosummary/Markdown) reads a plain markdown file where `#` starts a question, `##` starts a part, and `## Solution` gives a worked solution: + +```bash +$ in2lambda questions.md Markdown +``` + By default, this generates an `out` directory in the same place that the command was run in. It contains the zipped question files. Check the [command line tool reference](reference/command-line) for more information. diff --git a/in2lambda/filters/Markdown/__init__.py b/in2lambda/filters/Markdown/__init__.py new file mode 100644 index 0000000..adcf12c --- /dev/null +++ b/in2lambda/filters/Markdown/__init__.py @@ -0,0 +1 @@ +"""Filter for question sets hand-authored (or wizard-generated) in plain markdown.""" diff --git a/in2lambda/filters/Markdown/example.md b/in2lambda/filters/Markdown/example.md new file mode 100644 index 0000000..6492f79 --- /dev/null +++ b/in2lambda/filters/Markdown/example.md @@ -0,0 +1,35 @@ +# Projectile motion + +A ball is thrown horizontally from a height of $h = 20\,\text{m}$ with speed +$v_0 = 15\,\text{m/s}$. Take $g = 9.8\,\text{m/s}^2$. + +## Time of flight + +How long does the ball take to reach the ground? + +## Solution + +Vertical motion is independent of the horizontal throw: + +$$ +h = \frac{1}{2} g t^2 \implies t = \sqrt{\frac{2h}{g}} +$$ + +So $t \approx 2.0\,\text{s}$. + +## Horizontal range + +How far from the launch point does the ball land? + +## Solution + +$x = v_0 t \approx 30\,\text{m}$. + +# Newton's second law + +State Newton's second law of motion and give its equation. + +## Solution + +The net force on a body equals the rate of change of its momentum; for constant +mass this is $F = m a$. diff --git a/in2lambda/filters/Markdown/filter.py b/in2lambda/filters/Markdown/filter.py new file mode 100644 index 0000000..21fec5b --- /dev/null +++ b/in2lambda/filters/Markdown/filter.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 + +r"""Questions written directly in markdown, with a ``#``/``##`` structure. + +The document is a flat sequence of headings and body blocks: + +* A level-1 heading (``#``) starts a **new question**. Its text becomes the + question title; the blocks that follow it (until the next heading) become the + top-level question text. +* A level-2 heading (``##``) whose text is not ``Solution`` starts a **new part** + of the current question. The blocks that follow become the part text. +* A level-2 heading (``##``) whose text is ``Solution`` (case-insensitive) marks + the blocks that follow as the **worked solution** for the current part, or for + the whole question if it has no parts yet. + +When a separate answers file is supplied via ``-a``, a level-1 heading advances +to the next question and every body block is added as a worked solution +(:meth:`~in2lambda.api.question.Question.add_solution` spreads it across the +question's parts). + +This is the format the ``in2lambda wizard`` command emits, and the validator in +:mod:`in2lambda.validation` checks it before conversion. +""" + +from typing import Optional + +import panflute as pf + +from in2lambda.api.part import Part +from in2lambda.api.set import Set +from in2lambda.filters.markdown import filter + +_SOLUTION_HEADING = "solution" + + +class _State: + """Where the next body block should go, tracked while walking one document.""" + + def __init__(self) -> None: + self.target = "main" # "main" | "part" | "solution" + self.part: Optional[Part] = None + + +def _state_for(doc: pf.Doc) -> _State: + """Return the parser state for ``doc``, resetting it when a new document starts. + + panflute has no per-run hook, so state is kept on the function object and + refreshed whenever the document object identity changes (e.g. the question + file followed by a separate answers file). + """ + if getattr(pandoc_filter, "_doc", None) is not doc: + pandoc_filter._doc = doc + pandoc_filter._state = _State() + return pandoc_filter._state + + +def _append(current: str, addition: str) -> str: + """Join two blocks of text with a blank line, ignoring empty additions.""" + addition = addition.strip() + if not addition: + return current + return f"{current}\n\n{addition}" if current else addition + + +@filter +def pandoc_filter( + elem: pf.Element, + doc: pf.elements.Doc, + set: Set, + parsing_answers: bool, +) -> Optional[pf.Str]: + """Turn a ``#``/``##`` markdown document into questions, parts and solutions. + + Args: + elem: The current element being processed. + doc: The Pandoc document container. + set: The Python API used to store the parsed result. + parsing_answers: Whether an answers-only document is being parsed. + + Returns: + Always ``None`` - this filter records into ``set`` rather than rewriting + the AST (inline rewriting is handled by the shared markdown decorator). + """ + # Only act on top-level blocks; inline elements are handled by @filter. + if not isinstance(elem, pf.Block) or not isinstance(elem.parent, pf.Doc): + return None + + state = _state_for(doc) + is_heading = isinstance(elem, pf.Header) + text = pf.stringify(elem).strip() + + if parsing_answers: + if is_heading and elem.level == 1: + set.increment_current_question() + elif not is_heading and text: + set.current_question.add_solution(text) + return None + + if is_heading and elem.level == 1: + set.add_question(title=text) + state.target, state.part = "main", None + elif is_heading and elem.level == 2 and text.lower() == _SOLUTION_HEADING: + if state.part is None: + state.part = Part() + set.current_question.parts.append(state.part) + state.target = "solution" + elif is_heading and elem.level == 2: + state.part = Part() + set.current_question.parts.append(state.part) + state.target = "part" + elif not is_heading and text: + if state.target == "main": + set.current_question.main_text = text + elif state.target == "part" and state.part is not None: + state.part.text = _append(state.part.text, text) + elif state.target == "solution" and state.part is not None: + state.part.worked_solution = _append(state.part.worked_solution, text) + + return None diff --git a/in2lambda/main.py b/in2lambda/main.py index d5df1e2..0aeb664 100644 --- a/in2lambda/main.py +++ b/in2lambda/main.py @@ -16,6 +16,13 @@ import in2lambda.filters from in2lambda.api.set import Set +from in2lambda.validation import check_markdown + + +def _warn_markdown_issues(text: str, source: str) -> None: + """Echo a warning for each math-delimiter problem found in a markdown source.""" + for problem in check_markdown(text): + click.echo(f"Warning: {source}: {problem.value}") def docx_to_md(docx_file: str) -> str: @@ -120,6 +127,9 @@ def runner( input_format = file_type(question_file) + if input_format == "markdown": + _warn_markdown_issues(text, question_file) + # Parse the Pandoc AST using the relevant panflute filter. pf.run_filter( filter_module.pandoc_filter, @@ -141,6 +151,9 @@ def runner( answer_text = file.read() answer_format = file_type(answer_file) + if answer_format == "markdown": + _warn_markdown_issues(answer_text, answer_file) + pf.run_filter( filter_module.pandoc_filter, doc=pf.convert_text( diff --git a/in2lambda/validation/__init__.py b/in2lambda/validation/__init__.py index 2cdfba6..f7aad90 100644 --- a/in2lambda/validation/__init__.py +++ b/in2lambda/validation/__init__.py @@ -1,4 +1,4 @@ -"""Pre-flight checks for the ``#``/``##`` markdown that flows through in2lambda. +"""Pre-flight checks for the markdown that flows through in2lambda. The markdown produced by the wizard (and hand-written by users) is the shared contract between the wizard, the ``Markdown`` filter and Lambda Feedback. These diff --git a/in2lambda/validation/delimiters.py b/in2lambda/validation/delimiters.py index 6824b99..7d6521c 100644 --- a/in2lambda/validation/delimiters.py +++ b/in2lambda/validation/delimiters.py @@ -1,4 +1,4 @@ -"""Checks that ``$ ... $`` and ``$$ ... $$`` math delimiters are balanced and placed correctly. +"""Check that inline and display 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 diff --git a/tests/test_markdown_filter.py b/tests/test_markdown_filter.py new file mode 100644 index 0000000..0b78f20 --- /dev/null +++ b/tests/test_markdown_filter.py @@ -0,0 +1,72 @@ +"""Tests for the ``Markdown`` filter (hand-authored ``#``/``##`` question sets).""" + +import json +import os + +from in2lambda.main import runner + + +def _example(filters_dir: str) -> str: + return os.path.join(filters_dir, "Markdown", "example.md") + + +def test_example_parses_into_questions_parts_and_solutions(filters_dir: str) -> None: + result = runner(_example(filters_dir), "Markdown") + + assert [q.title for q in result.questions] == [ + "Projectile motion", + "Newtonโ€™s second law", + ] + + projectile = result.questions[0] + assert projectile.main_text.startswith("A ball is thrown horizontally") + assert [p.text for p in projectile.parts] == [ + "How long does the ball take to reach the ground?", + "How far from the launch point does the ball land?", + ] + assert projectile.parts[0].worked_solution.startswith("Vertical motion is") + assert "v_0 t" in projectile.parts[1].worked_solution + + # A question with no ``##`` parts keeps its solution on a single empty part. + newton = result.questions[1] + assert newton.parts[0].text == "" + assert "F = m a" in newton.parts[0].worked_solution + + +def test_markdown_filter_writes_importable_json(filters_dir: str, tmp_path) -> None: + out_dir = tmp_path / "out" + runner(_example(filters_dir), "Markdown", str(out_dir)) + + question_files = sorted((out_dir / "set").glob("question_*.json")) + assert len(question_files) == 2 + first = json.loads(question_files[0].read_text()) + assert first["title"] == "Projectile motion" + assert ( + first["parts"][0]["content"] + == "How long does the ball take to reach the ground?" + ) + assert first["parts"][0]["workedSolution"]["content"].startswith( + "Vertical motion is" + ) + + +def test_bad_math_delimiters_warn_but_do_not_fail(tmp_path, capsys) -> None: + bad = tmp_path / "bad.md" + bad.write_text("# Q\n\nText with a stray $ sign and no closing delimiter") + + result = runner(str(bad), "Markdown") + + assert result.questions[0].title == "Q" + assert "unclosed inline" in capsys.readouterr().out + + +def test_separate_answers_file_fills_worked_solutions(tmp_path) -> None: + questions = tmp_path / "q.md" + questions.write_text("# Q1\n\nFirst question.\n\n# Q2\n\nSecond question.\n") + answers = tmp_path / "a.md" + answers.write_text("# Q1\n\nAnswer to one.\n\n# Q2\n\nAnswer to two.\n") + + result = runner(str(questions), "Markdown", answer_file=str(answers)) + + assert result.questions[0].parts[0].worked_solution == "Answer to one." + assert result.questions[1].parts[0].worked_solution == "Answer to two." From 33a3d2ef52c4e5a276d56315a5c87bfc631e3aa7 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Tue, 15 Sep 2026 15:55:12 +0100 Subject: [PATCH 2/3] feat: support horizontal rules and enhanced Markdown serialization - Preserve `---` horizontal rules in Markdown question sets as solution step separators. - Improve Markdown processing of inline elements, allowing lists, tables, and other markup to remain intact. - Replace `pf.Str` with `pf.RawInline` for better fidelity when serializing inline Markdown elements (e.g., bold/italic text, images, math expressions). - Update test cases to verify new behavior with horizontal rules and enhanced content parsing. --- in2lambda/filters/Markdown/example.md | 7 ++++++- in2lambda/filters/Markdown/filter.py | 23 +++++++++++++++++++---- in2lambda/filters/markdown.py | 17 +++++++++-------- tests/test_markdown_filter.py | 18 +++++++++++------- 4 files changed, 45 insertions(+), 20 deletions(-) diff --git a/in2lambda/filters/Markdown/example.md b/in2lambda/filters/Markdown/example.md index 6492f79..0ec4a3c 100644 --- a/in2lambda/filters/Markdown/example.md +++ b/in2lambda/filters/Markdown/example.md @@ -5,7 +5,10 @@ $v_0 = 15\,\text{m/s}$. Take $g = 9.8\,\text{m/s}^2$. ## Time of flight -How long does the ball take to reach the ground? +How long does the ball take to reach the ground, using: + +- the vertical motion equation +- the given height and gravity ## Solution @@ -15,6 +18,8 @@ $$ h = \frac{1}{2} g t^2 \implies t = \sqrt{\frac{2h}{g}} $$ +--- + So $t \approx 2.0\,\text{s}$. ## Horizontal range diff --git a/in2lambda/filters/Markdown/filter.py b/in2lambda/filters/Markdown/filter.py index 21fec5b..660cb82 100644 --- a/in2lambda/filters/Markdown/filter.py +++ b/in2lambda/filters/Markdown/filter.py @@ -68,7 +68,7 @@ def pandoc_filter( doc: pf.elements.Doc, set: Set, parsing_answers: bool, -) -> Optional[pf.Str]: +) -> Optional[pf.Inline]: """Turn a ``#``/``##`` markdown document into questions, parts and solutions. Args: @@ -87,12 +87,27 @@ def pandoc_filter( state = _state_for(doc) is_heading = isinstance(elem, pf.Header) - text = pf.stringify(elem).strip() + is_rule = isinstance(elem, pf.HorizontalRule) + + if is_heading: + text = pf.stringify(elem).strip() + elif is_rule: + # HorizontalRule blocks (``---``) stringify to nothing, so they're matched + # separately and kept as literal text: in a Lambda Feedback worked solution + # they mark the boundary between the steps a student clicks through. + text = "---" + else: + # Serialized back to markdown (rather than flattened with pf.stringify) so + # that lists, tables and other markup survive into the question/part/ + # solution text verbatim. + text = pf.convert_text( + elem, input_format="panflute", output_format="markdown" + ).strip() if parsing_answers: if is_heading and elem.level == 1: set.increment_current_question() - elif not is_heading and text: + elif (is_rule or not is_heading) and text: set.current_question.add_solution(text) return None @@ -108,7 +123,7 @@ def pandoc_filter( state.part = Part() set.current_question.parts.append(state.part) state.target = "part" - elif not is_heading and text: + elif (is_rule or not is_heading) and text: if state.target == "main": set.current_question.main_text = text elif state.target == "part" and state.part is not None: diff --git a/in2lambda/filters/markdown.py b/in2lambda/filters/markdown.py index 789aabd..a3b1421 100644 --- a/in2lambda/filters/markdown.py +++ b/in2lambda/filters/markdown.py @@ -118,11 +118,11 @@ def image_path(image_name: str, tex_file: str) -> Optional[str]: def filter( func: Callable[ [pf.Element, pf.elements.Doc, Set, bool], - Optional[pf.Str], + Optional[pf.Inline], ] ) -> Callable[ [pf.Element, pf.elements.Doc, Set, str, bool], - Optional[pf.Str], + Optional[pf.Inline], ]: """Python decorator to make generic LaTeX elements markdown readable. @@ -139,7 +139,7 @@ def markdown_converter( set: Set, tex_file: str, parsing_answers: bool, - ) -> Optional[pf.Str]: + ) -> Optional[pf.Inline]: """Handles LaTeX elements within the filter, before calling the original function. N.B. tex_file is required to determine where the relative image directory is. @@ -163,10 +163,11 @@ def markdown_converter( expression = latex_to_katex(elem.text) except Exception: expression = elem.text - return pf.Str( + return pf.RawInline( f"${expression}$" if elem.format == "InlineMath" - else f"\n\n$$\n{expression}\n$$\n\n" + else f"\n\n$$\n{expression}\n$$\n\n", + format="markdown", ) case pf.Image: @@ -176,13 +177,13 @@ def markdown_converter( echo(f"Warning: Couldn't find {elem.url}") else: set.current_question.images.append(path) - return pf.Str(f"![pictureTag]({elem.url})") + return pf.RawInline(f"![pictureTag]({elem.url})", format="markdown") case pf.Strong: - return pf.Str(f"**{pf.stringify(elem)}**") + return pf.RawInline(f"**{pf.stringify(elem)}**", format="markdown") case pf.Emph: - return pf.Str(f"*{pf.stringify(elem)}*") + return pf.RawInline(f"*{pf.stringify(elem)}*", format="markdown") # Replace siunitx no-break space with narrow no-break space # This should be the space between the number and the units diff --git a/tests/test_markdown_filter.py b/tests/test_markdown_filter.py index 0b78f20..50ca445 100644 --- a/tests/test_markdown_filter.py +++ b/tests/test_markdown_filter.py @@ -20,11 +20,14 @@ def test_example_parses_into_questions_parts_and_solutions(filters_dir: str) -> projectile = result.questions[0] assert projectile.main_text.startswith("A ball is thrown horizontally") - assert [p.text for p in projectile.parts] == [ - "How long does the ball take to reach the ground?", - "How far from the launch point does the ball land?", - ] + assert projectile.parts[0].text == ( + "How long does the ball take to reach the ground, using:\n\n" + "- the vertical motion equation\n- the given height and gravity" + ) + assert projectile.parts[1].text == "How far from the launch point does the ball land?" assert projectile.parts[0].worked_solution.startswith("Vertical motion is") + # The ``---`` separator survives as a literal step boundary. + assert "\n\n---\n\n" in projectile.parts[0].worked_solution assert "v_0 t" in projectile.parts[1].worked_solution # A question with no ``##`` parts keeps its solution on a single empty part. @@ -41,13 +44,14 @@ def test_markdown_filter_writes_importable_json(filters_dir: str, tmp_path) -> N assert len(question_files) == 2 first = json.loads(question_files[0].read_text()) assert first["title"] == "Projectile motion" - assert ( - first["parts"][0]["content"] - == "How long does the ball take to reach the ground?" + assert first["parts"][0]["content"].startswith( + "How long does the ball take to reach the ground, using:" ) + assert "- the vertical motion equation" in first["parts"][0]["content"] assert first["parts"][0]["workedSolution"]["content"].startswith( "Vertical motion is" ) + assert "\n\n---\n\n" in first["parts"][0]["workedSolution"]["content"] def test_bad_math_delimiters_warn_but_do_not_fail(tmp_path, capsys) -> None: From e67207cb2dddff6f3839d8ed9836efc0eae65e18 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Tue, 15 Sep 2026 21:53:26 +0100 Subject: [PATCH 3/3] fix: reformat markdown filter files and fix stale MathDelimiterProblem attribute black --check was failing on markdown.py and test_markdown_filter.py, which were never reformatted after being added. That masked a second bug: _warn_markdown_issues still read problem.value, left over from before check_markdown() started returning MathDelimiterProblem dataclasses (f08f4df) instead of bare MathDelimiterError enums. Co-Authored-By: Claude Sonnet 5 --- in2lambda/filters/markdown.py | 8 +++++--- in2lambda/main.py | 2 +- tests/test_markdown_filter.py | 4 +++- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/in2lambda/filters/markdown.py b/in2lambda/filters/markdown.py index a3b1421..4237263 100644 --- a/in2lambda/filters/markdown.py +++ b/in2lambda/filters/markdown.py @@ -164,9 +164,11 @@ def markdown_converter( except Exception: expression = elem.text return pf.RawInline( - f"${expression}$" - if elem.format == "InlineMath" - else f"\n\n$$\n{expression}\n$$\n\n", + ( + f"${expression}$" + if elem.format == "InlineMath" + else f"\n\n$$\n{expression}\n$$\n\n" + ), format="markdown", ) diff --git a/in2lambda/main.py b/in2lambda/main.py index 0aeb664..c148e1e 100644 --- a/in2lambda/main.py +++ b/in2lambda/main.py @@ -22,7 +22,7 @@ def _warn_markdown_issues(text: str, source: str) -> None: """Echo a warning for each math-delimiter problem found in a markdown source.""" for problem in check_markdown(text): - click.echo(f"Warning: {source}: {problem.value}") + click.echo(f"Warning: {source}: {problem}") def docx_to_md(docx_file: str) -> str: diff --git a/tests/test_markdown_filter.py b/tests/test_markdown_filter.py index 50ca445..c5b5550 100644 --- a/tests/test_markdown_filter.py +++ b/tests/test_markdown_filter.py @@ -24,7 +24,9 @@ def test_example_parses_into_questions_parts_and_solutions(filters_dir: str) -> "How long does the ball take to reach the ground, using:\n\n" "- the vertical motion equation\n- the given height and gravity" ) - assert projectile.parts[1].text == "How far from the launch point does the ball land?" + assert ( + projectile.parts[1].text == "How far from the launch point does the ball land?" + ) assert projectile.parts[0].worked_solution.startswith("Vertical motion is") # The ``---`` separator survives as a literal step boundary. assert "\n\n---\n\n" in projectile.parts[0].worked_solution