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
2 changes: 2 additions & 0 deletions docs/source/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,6 @@ Click on a set in teacher mode. The arrow next to the "Add Question" button allo

Choose the zip file you wish to upload, and the question should appear! 🎉

Imported questions arrive published with every display setting on, and the set's own visibility settings still apply. The Python API can set each of these per question.

![Importing Question from file in Teacher Mode](_static/images/import-teacher.png)
23 changes: 22 additions & 1 deletion in2lambda/api/question.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""A full question with optional parts that's contained in a set."""

from dataclasses import dataclass, field
from typing import Union
from typing import Optional, Union

import panflute as pf

Expand All @@ -14,10 +14,17 @@ class Question:

Each question has a title and is composed of a list of parts.

It also carries the settings Lambda Feedback keeps per question: its skill level,
guidance for students, expected duration in minutes, whether it is published, and
whether students may see the final answer, worked solution, structured tutorial
and chatbot. Unset skill, guidance and durations are left out of the JSON.

Examples:
>>> from in2lambda.api.question import Question
>>> Question(title="Some title", main_text="Some text")
Question(title='Some title', parts=[], images=[], main_text='Some text')
>>> Question(title="Some title", publish=False).publish
False
"""

title: str = ""
Expand All @@ -36,6 +43,20 @@ class Question:
"""Keeps track of the last question part that contains a solution /
text."""

# Settings are left out of the repr so that printing a question still shows its
# content rather than nine lines of configuration.
# An int too: Lambda Feedback's export is written by JavaScript, which writes the
# lowest and highest skill levels as 0 and 1.
skill: Optional[Union[int, float]] = field(default=None, repr=False)
guidance: Optional[str] = field(default=None, repr=False)
duration_lower_bound: Optional[int] = field(default=None, repr=False)
duration_upper_bound: Optional[int] = field(default=None, repr=False)
publish: bool = field(default=True, repr=False)
display_final_answer: bool = field(default=True, repr=False)
display_worked_solution: bool = field(default=True, repr=False)
display_structured_tutorial: bool = field(default=True, repr=False)
display_chatbot: bool = field(default=True, repr=False)

@property
def main_text(self) -> str:
r"""Main top-level question text.
Expand Down
36 changes: 36 additions & 0 deletions in2lambda/api/set.py
Original file line number Diff line number Diff line change
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, images and settings. 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"]
174 changes: 145 additions & 29 deletions in2lambda/json_convert/json_convert.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
"""Converts questions from a Python set object into Lambda Feedback JSON."""
"""Converts questions between a Python set object and Lambda Feedback JSON."""

import json
import os
import re
import shutil
import tempfile
import zipfile
from copy import deepcopy
from pathlib import Path
from typing import Any

from in2lambda.api.part import Part
from in2lambda.api.question import Question
from in2lambda.api.set import Set
from in2lambda.api.visibility_status import VisibilityController, VisibilityStatus

MINIMAL_QUESTION_TEMPLATE = "minimal_template_question.json"
MINIMAL_SET_TEMPLATE = "minimal_template_set.json"
Expand All @@ -31,6 +35,47 @@ def _zip_sorted_folder(folder_path, zip_path):
zf.write(abs_path, arcname=rel_path)


def _question_json(
question: Question, i: int, template: dict[str, Any]
) -> dict[str, Any]:
output = deepcopy(template)

output["orderNumber"] = i # order number starts at 0
output["title"] = question.title if question.title != "" else f"Question {i + 1}"
output["masterContent"] = question.main_text

output["publish"] = question.publish
output["displayFinalAnswer"] = question.display_final_answer
output["displayWorkedSolution"] = question.display_worked_solution
output["displayStructuredTutorial"] = question.display_structured_tutorial
output["displayChatbot"] = question.display_chatbot
# Unset optional settings are omitted rather than given a value Lambda Feedback
# never chose.
for key, value in {
"skill": question.skill,
"guidance": question.guidance,
"durationLowerBound": question.duration_lower_bound,
"durationUpperBound": question.duration_upper_bound,
}.items():
if value is not None:
output[key] = value

if question.parts:
output["parts"][0]["content"] = question.parts[0].text
output["parts"][0]["workedSolution"]["content"] = question.parts[
0
].worked_solution
for j in range(1, len(question.parts)):
output["parts"].append(deepcopy(template["parts"][0]))
output["parts"][j]["content"] = question.parts[j].text
output["parts"][j]["orderNumber"] = j
output["parts"][j]["workedSolution"]["content"] = question.parts[
j
].worked_solution

return output


def converter(
question_template: dict[str, Any],
set_template: dict[str, Any],
Expand Down Expand Up @@ -70,38 +115,16 @@ def converter(
json.dump(set_template, file)

for i in range(len(ListQuestions)):
output = deepcopy(question_template)

output["orderNumber"] = i # order number starts at 0
# add title to the question file
if ListQuestions[i].title != "":
output["title"] = ListQuestions[i].title
else:
output["title"] = "Question " + str(i + 1)

# add main text to the question file
output["masterContent"] = ListQuestions[i].main_text

# add parts to the question file
if ListQuestions[i].parts:
output["parts"][0]["content"] = ListQuestions[i].parts[0].text
output["parts"][0]["workedSolution"]["content"] = (
ListQuestions[i].parts[0].worked_solution
)
for j in range(1, len(ListQuestions[i].parts)):
output["parts"].append(deepcopy(question_template["parts"][0]))
output["parts"][j]["content"] = ListQuestions[i].parts[j].text
output["parts"][j]["orderNumber"] = j
output["parts"][j]["workedSolution"]["content"] = (
ListQuestions[i].parts[j].worked_solution
)

# Output file
output = _question_json(ListQuestions[i], i, question_template)

# Lambda Feedback names the file after the title with only spaces made
# underscores. Path separators go too, so a title cannot leave the set folder,
# and so do the characters Windows forbids in file names.
filename = (
"question_"
+ str(i).zfill(3)
+ "_"
+ re.sub(r"[^\w\-_.]", "_", output["title"].strip())
+ re.sub(r'[\s/\\<>:"|?*]', "_", output["title"].strip())
)

# write questions into directory
Expand Down Expand Up @@ -145,3 +168,96 @@ def main(set_questions: Set, output_dir: str) -> None:
except OSError as e:
print("Error: %s : %s" % (output_dir, e.strerror))
converter(question_template, set_template, set_questions, output_dir)


def load(path: str) -> Set:
"""Reads a Lambda Feedback export into a Set, keeping only what the model holds.

That is the set's name, description and visibilities, and each question's title,
main text, parts, worked solutions, images and settings.

A zip is extracted to a new temporary directory, which is left for the operating
system to clear: the loaded images point into it and must still exist when the
set is written out.

Args:
path: An exported set, as a folder or a zip, with or without a top-level folder.

Returns:
The set, with each question's images as absolute paths into ``media/``.

Raises:
ValueError: If the export does not hold exactly one ``set_*.json``.
"""
root = Path(path)
if root.suffix == ".zip":
extracted = tempfile.mkdtemp(prefix="in2lambda-")
with zipfile.ZipFile(root) as zf:
zf.extractall(extracted)
root = Path(extracted)

set_files = list(root.rglob("set_*.json"))
if len(set_files) != 1:
raise ValueError(f"Expected one set_*.json in {path}, found {len(set_files)}")
(set_file,) = set_files
export_dir = set_file.parent

set_json = json.loads(set_file.read_text())
question_set = Set(
_name=set_json["name"],
_description=set_json["description"],
_finalAnswerVisibility=VisibilityController(
VisibilityStatus(set_json["finalAnswerVisibility"])
),
_workedSolutionVisibility=VisibilityController(
VisibilityStatus(set_json["workedSolutionVisibility"])
),
_structuredTutorialVisibility=VisibilityController(
VisibilityStatus(set_json["structuredTutorialVisibility"])
),
)

question_files = sorted(
export_dir.glob("question_*.json"),
key=lambda file: json.loads(file.read_text())["orderNumber"],
)
media = sorted((export_dir / "media").glob("*"))
for question_file in question_files:
question_json = json.loads(question_file.read_text())
parts = [
Part(
text=part["content"],
worked_solution=(
part["workedSolution"]["content"]
if "workedSolution" in part
else ""
),
)
for part in question_json["parts"]
]
question_set.questions.append(
Question(
title=question_json["title"],
main_text=question_json["masterContent"],
parts=parts,
images=[
str(image)
for image in media
if image.name.startswith(f"{question_file.stem}_")
],
# Every loaded part already has its text and solution, so further
# add_part_text/add_solution calls must add parts after them rather
# than overwrite the first.
_last_part={"solution": len(parts), "text": len(parts)},
skill=question_json.get("skill"),
guidance=question_json.get("guidance"),
duration_lower_bound=question_json.get("durationLowerBound"),
duration_upper_bound=question_json.get("durationUpperBound"),
publish=question_json["publish"],
display_final_answer=question_json["displayFinalAnswer"],
display_worked_solution=question_json["displayWorkedSolution"],
display_structured_tutorial=question_json["displayStructuredTutorial"],
display_chatbot=question_json["displayChatbot"],
)
)
return question_set
5 changes: 2 additions & 3 deletions in2lambda/json_convert/minimal_template_question.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,18 @@
"orderNumber": 0,
"title": "Question title here",
"masterContent": "Top level question here",
"publish": false,
"publish": true,
"displayFinalAnswer": true,
"displayStructuredTutorial": true,
"displayWorkedSolution": true,
"displayChatbot": false,
"displayChatbot": true,
"parts": [
{
"orderNumber": 0,
"content": "Part text here",
"answerContent": "",
"responseAreas": [],
"workedSolution": {
"title": "",
"content": "Part worked solution here",
"children": []
}
Expand Down
10 changes: 1 addition & 9 deletions in2lambda/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
# sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))

import importlib
import pkgutil
import subprocess
from typing import Optional

Expand Down Expand Up @@ -168,14 +167,7 @@ def runner(
# Python files in the subjects directory
@click.argument(
"chosen_filter",
type=click.Choice(
[
i.name
for i in pkgutil.iter_modules(in2lambda.filters.__path__)
if i.name != "markdown"
],
case_sensitive=False,
),
type=click.Choice(in2lambda.filters.builtin_filters(), case_sensitive=False),
)
@click.option(
"--out",
Expand Down
Loading
Loading