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."