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
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 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"]
93 changes: 90 additions & 3 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 Down Expand Up @@ -96,12 +100,14 @@ def converter(
ListQuestions[i].parts[j].worked_solution
)

# Output file
# 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 +151,84 @@ 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.

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)},
)
)
return question_set
1 change: 0 additions & 1 deletion in2lambda/json_convert/minimal_template_question.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
"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
7 changes: 7 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
"""Shared pytest fixtures for the in2lambda test suite."""

import os
from pathlib import Path

import pytest

import in2lambda

EXPORTS_DIR = Path(__file__).parent / "fixtures" / "exports"
"""Real Lambda Feedback exports, one set per folder, exactly as the platform wrote them."""

EXPORTS = sorted(path for path in EXPORTS_DIR.iterdir() if path.is_dir())
"""Every export folder, found rather than listed so that adding one needs no code."""


@pytest.fixture(scope="session")
def filters_dir() -> str:
Expand Down
116 changes: 116 additions & 0 deletions tests/test_exports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""Round-trips every real Lambda Feedback export through the in2lambda model.

Each folder in ``fixtures/exports`` is loaded with
:meth:`~in2lambda.api.set.Set.from_json`, written back with
:meth:`~in2lambda.api.set.Set.to_json` and compared with the original. The model holds
far less than an export, so the comparison covers what it does hold, the file names
written, and that the writer emits no key Lambda Feedback does not.
"""

import json
import re
from pathlib import Path

import pytest
from conftest import EXPORTS

from in2lambda.api.set import Set

each_export = pytest.mark.parametrize("export_dir", EXPORTS, ids=lambda path: path.name)


def _write_back(question_set: Set, tmp_path: Path) -> Path:
question_set.to_json(str(tmp_path / "out"))
return tmp_path / "out" / question_set._name


def _relative_files(directory: Path) -> list[str]:
# Finder leaves .DS_Store beside files it has shown; it is not part of an export.
return sorted(
str(path.relative_to(directory))
for path in directory.rglob("*")
if path.is_file() and not path.name.startswith(".")
)


def _modelled(question_set: Set) -> dict:
# Visibility controllers have no equality, and image paths differ by where the
# set was read from, so compare their values and file names.
return {
"name": question_set._name,
"description": question_set._description,
"visibility": [
str(question_set._finalAnswerVisibility),
str(question_set._workedSolutionVisibility),
str(question_set._structuredTutorialVisibility),
],
"questions": [
(q.title, q.main_text, q.parts, [Path(image).name for image in q.images])
for q in question_set.questions
],
}


def _key_paths(value, path: str = "") -> set[str]:
if isinstance(value, dict):
paths = set()
for key, item in value.items():
paths |= {f"{path}.{key}"} | _key_paths(item, f"{path}.{key}")
return paths
if isinstance(value, list):
return set().union(
*(_key_paths(item, f"{path}[{i}]") for i, item in enumerate(value))
)
return set()


def _unexported_keys(written: dict, exported: dict) -> list[str]:
missing = _key_paths(written) - _key_paths(exported)
# Lambda Feedback leaves a part's workedSolution out of its export when the part
# has none, but the writer always emits one, so only then may it be absent.
for i, part in enumerate(written.get("parts", [])):
if not part["workedSolution"]["content"]:
prefix = f".parts[{i}].workedSolution"
missing = {key for key in missing if not key.startswith(prefix)}
return sorted(missing)


@each_export
def test_export_round_trips(export_dir: Path, tmp_path: Path) -> None:
"""Writing a loaded export reproduces its file names and reloads to the same set."""
loaded = Set.from_json(str(export_dir))
assert loaded.questions
assert all(question.main_text or question.parts for question in loaded.questions)

written = _write_back(loaded, tmp_path)

assert _relative_files(written) == _relative_files(export_dir)
assert _modelled(Set.from_json(str(written))) == _modelled(loaded)
assert _modelled(Set.from_json(f"{written}.zip")) == _modelled(loaded)

# Text added to a loaded question is a new part, not a rewrite of the first.
question = Set.from_json(str(export_dir)).questions[0]
texts_before = [part.text for part in question.parts]
question.add_part_text("added")
assert [part.text for part in question.parts] == texts_before + ["added"]


@each_export
def test_written_keys_exist_in_export(export_dir: Path, tmp_path: Path) -> None:
"""The writer emits no key, at any depth, that Lambda Feedback never exports there."""
written = _write_back(Set.from_json(str(export_dir)), tmp_path)

missing = {}
for file in written.glob("*.json"):
exported = json.loads((export_dir / file.name).read_text())
keys = _unexported_keys(json.loads(file.read_text()), exported)
if keys:
missing[file.name] = keys
assert not missing, missing


def test_from_json_rejects_folder_without_set(tmp_path: Path) -> None:
"""A folder with no set file is refused with an error that says where it looked."""
(tmp_path / "question_000_Q.json").write_text("{}")
with pytest.raises(ValueError, match=re.escape(str(tmp_path))):
Set.from_json(str(tmp_path))
Loading
Loading