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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 34 additions & 12 deletions docs/source/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,27 +37,57 @@ 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,
)

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)}
Expand All @@ -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}
Expand Down
6 changes: 6 additions & 0 deletions docs/source/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions in2lambda/filters/Markdown/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Filter for question sets hand-authored (or wizard-generated) in plain markdown."""
35 changes: 35 additions & 0 deletions in2lambda/filters/Markdown/example.md
Original file line number Diff line number Diff line change
@@ -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$.
119 changes: 119 additions & 0 deletions in2lambda/filters/Markdown/filter.py
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions in2lambda/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion in2lambda/validation/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion in2lambda/validation/delimiters.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
72 changes: 72 additions & 0 deletions tests/test_markdown_filter.py
Original file line number Diff line number Diff line change
@@ -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."
Loading