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
9 changes: 1 addition & 8 deletions docs/source/filters.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import importlib
import os
import pkgutil
import shutil
import subprocess
from pathlib import Path
Expand All @@ -19,13 +18,7 @@ def generate_filters_docs():
autosummary_directory.mkdir(exist_ok=True, parents=True)
static_pdf_directory.mkdir(exist_ok=True)

filters = (
i.name
for i in pkgutil.iter_modules(in2lambda.filters.__path__)
if i.name != "markdown"
)

for filter_name in filters:
for filter_name in in2lambda.filters.builtin_filters():
filter_module = importlib.import_module(
f"in2lambda.filters.{filter_name}.filter"
)
Expand Down
14 changes: 12 additions & 2 deletions in2lambda/api/part.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
"""A part of a question."""

from dataclasses import dataclass
from dataclasses import dataclass, field

from in2lambda.api.response_area import ResponseArea


@dataclass
class Part:
"""A part of a question as represented on Lambda Feedback."""
"""A part of a question as represented on Lambda Feedback.

``worked_solution`` is markdown; a line holding only ``---`` (or ``***``) splits it
into the steps students go through one at a time. ``answer`` is the final answer
shown to students, and ``response_areas`` the boxes, in order, that mark what they
type.
"""

text: str = ""
worked_solution: str = ""
answer: str = ""
response_areas: list[ResponseArea] = field(default_factory=list)
20 changes: 10 additions & 10 deletions in2lambda/api/question.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,20 +89,20 @@ def add_solution(self, elem: Union[pf.Element, str]) -> None:
>>> question.add_part_text("part a")
>>> question.add_solution("part a solution")
>>> question
Question(title='', parts=[Part(text='part a', worked_solution='part a solution')], images=[], main_text='')
Question(title='', parts=[Part(text='part a', worked_solution='part a solution', answer='', response_areas=[])], images=[], main_text='')
>>> question.add_part_text("part b")
>>> question.add_part_text("part c")
>>> question.add_solution("Solution for b")
>>> # Note that since c doesn't have a solution, it's set to b's solution
>>> question
Question(title='', parts=[Part(text='part a', worked_solution='part a solution'), \
Part(text='part b', worked_solution='Solution for b'), \
Part(text='part c', worked_solution='Solution for b')], images=[], main_text='')
Question(title='', parts=[Part(text='part a', worked_solution='part a solution', answer='', response_areas=[]), \
Part(text='part b', worked_solution='Solution for b', answer='', response_areas=[]), \
Part(text='part c', worked_solution='Solution for b', answer='', response_areas=[])], images=[], main_text='')
>>> question.add_solution("We now have a solution for c!")
>>> question
Question(title='', parts=[Part(text='part a', worked_solution='part a solution'), \
Part(text='part b', worked_solution='Solution for b'), \
Part(text='part c', worked_solution='We now have a solution for c!')], images=[], main_text='')
Question(title='', parts=[Part(text='part a', worked_solution='part a solution', answer='', response_areas=[]), \
Part(text='part b', worked_solution='Solution for b', answer='', response_areas=[]), \
Part(text='part c', worked_solution='We now have a solution for c!', answer='', response_areas=[])], images=[], main_text='')
"""
elem_text = elem if isinstance(elem, str) else pf.stringify(elem)

Expand Down Expand Up @@ -131,13 +131,13 @@ def add_part_text(self, elem: Union[pf.Element, str]) -> None:
>>> question.add_part_text("part a")
>>> question.add_solution("part a solution")
>>> question
Question(title='', parts=[Part(text='part a', worked_solution='part a solution')], images=[], main_text='')
Question(title='', parts=[Part(text='part a', worked_solution='part a solution', answer='', response_areas=[])], images=[], main_text='')
>>> # Supports adding the answer first.
>>> question.add_solution("part b solution")
>>> question.add_part_text("part b")
>>> question
Question(title='', parts=[Part(text='part a', worked_solution='part a solution'), \
Part(text='part b', worked_solution='part b solution')], images=[], main_text='')
Question(title='', parts=[Part(text='part a', worked_solution='part a solution', answer='', response_areas=[]), \
Part(text='part b', worked_solution='part b solution', answer='', response_areas=[])], images=[], main_text='')
"""
elem_text = elem if isinstance(elem, str) else pf.stringify(elem)

Expand Down
95 changes: 95 additions & 0 deletions in2lambda/api/response_area.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""An answer box in a part, with how Lambda Feedback marks what is typed into it."""

import uuid
from dataclasses import dataclass, field
from typing import Any


def _new_id() -> str:
return str(uuid.uuid4())


@dataclass
class InputSymbol:
"""A symbol students may type, and what the evaluation function reads it as.

``symbol`` is what students see, ``code`` what the evaluator reads, and ``aliases``
other spellings accepted for it.
"""

symbol: str
code: str
aliases: list[str] = field(default_factory=list)
is_visible: bool = True


@dataclass
class Test:
"""An author's check of the marking: a response and whether it should be correct."""

# Its name would otherwise make pytest try to collect it wherever it is imported.
__test__ = False

payload: str
is_correct: bool
id: str = field(default_factory=_new_id)


@dataclass
class Case:
"""A response that is shown tailored ``feedback``, and may be marked correct."""

answer: str
feedback: str
is_correct: bool
params: Any = None
id: str = field(default_factory=_new_id)


@dataclass
class ResponseArea:
"""An answer box as represented on Lambda Feedback.

Its position among a part's areas is its order, so it holds no order number.
``config`` and ``grade_params`` depend on ``response_type`` and are kept as Lambda
Feedback writes them. The feedback colours and prefixes default to what Lambda
Feedback fills in.

Examples:
>>> from in2lambda.api.response_area import ResponseArea, Test
>>> area = ResponseArea(
... response_type="NUMERIC_UNITS",
... answer="30 N",
... evaluation_function="comparePhysicalQuantities",
... grade_params={"rtol": 0.05},
... pre_text="$F=$",
... tests=[Test("30 N", True)],
... )
>>> area.tests[0].payload, area.tests[0].is_correct
('30 N', True)
"""

response_type: str = "MATH_SINGLE_LINE"
"""``MATH_SINGLE_LINE``, ``NUMERIC_UNITS`` or ``MULTIPLE_CHOICE``."""
answer: str | list[bool] = ""
"""The correct answer; for multiple choice, one boolean per option."""
config: dict[str, Any] | None = None
evaluation_function: str = "symbolicEqual"
grade_params: dict[str, Any] | None = None
pre_text: str = ""
post_text: str = ""
content_after: str = ""
"""Markdown shown after the box, before the next one."""
input_symbols: list[InputSymbol] = field(default_factory=list)
display_input_symbols: bool = False
live_preview: bool = False
include_in_pdf: bool = False
save_allowed: bool = False
separate_feedback: bool = True
common_feedback_color: str = "#C4CDD5"
correct_feedback_color: str = "#22C55E"
correct_feedback_prefix: str = "Correct"
incorrect_feedback_color: str = "#ff5630"
incorrect_feedback_prefix: str = "Incorrect"
tests: list[Test] = field(default_factory=list)
cases: list[Case] = field(default_factory=list)
40 changes: 38 additions & 2 deletions in2lambda/api/set.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,8 @@ def increment_current_question(self) -> None:
>>> s.increment_current_question()
>>> s.current_question.add_solution("Question 2 answer")
>>> s.questions
[Question(title='Question 1', parts=[Part(text='', worked_solution='Question 1 answer')], images=[], main_text=''),\
Question(title='Question 2', parts=[Part(text='', worked_solution='Question 2 answer')], images=[], main_text='')]
[Question(title='Question 1', parts=[Part(text='', worked_solution='Question 1 answer', answer='', response_areas=[])], images=[], main_text=''),\
Question(title='Question 2', parts=[Part(text='', worked_solution='Question 2 answer', answer='', response_areas=[])], images=[], main_text='')]
"""
self._current_question_index += 1

Expand Down Expand Up @@ -141,6 +141,42 @@ def to_json(self, output_dir: str) -> None:

json_convert.main(self, output_dir)

@classmethod
def from_json(cls, path: str) -> "Set":
"""Loads a Lambda Feedback export, as a folder or a zip, into a Set.

Only what the Set holds is read: the name, description, visibilities, and each
question's title, main text, parts, worked solutions and images. A zip is
extracted to a temporary directory that is not removed afterwards, because the
loaded images point into it.

Args:
path: The exported set's folder or zip.

Returns:
The loaded set.

Raises:
ValueError: If the export does not hold exactly one ``set_*.json``.

Examples:
>>> import tempfile
>>> s = Set()
>>> s.add_question("Question 1")
>>> s.add_question("Question 2")
>>> with tempfile.TemporaryDirectory() as temp_dir:
... s.to_json(temp_dir)
... from_folder = Set.from_json(f"{temp_dir}/set")
... from_zip = Set.from_json(f"{temp_dir}/set.zip")
>>> [question.title for question in from_folder.questions]
['Question 1', 'Question 2']
>>> [question.title for question in from_zip.questions]
['Question 1', 'Question 2']
"""
from in2lambda.json_convert import json_convert

return json_convert.load(path)

def set_name(self, name: str) -> None:
"""Sets the name of the set.

Expand Down
18 changes: 18 additions & 0 deletions in2lambda/filters/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,19 @@
"""Subject specific panflute filters for parsing LaTeX documents."""

import pkgutil


def builtin_filters() -> list[str]:
"""Lists the filters shipped with in2lambda.

Each filter is a subpackage of this one; ``markdown`` is a helper module they share.

Returns:
The filter names, as accepted by :func:`in2lambda.main.runner`.

Examples:
>>> from in2lambda.filters import builtin_filters
>>> "PartsSepSol" in builtin_filters()
True
"""
return [i.name for i in pkgutil.iter_modules(__path__) if i.name != "markdown"]
Loading
Loading