Skip to content
Merged
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
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
87 changes: 86 additions & 1 deletion 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 @@ -147,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
64 changes: 0 additions & 64 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
"""Shared pytest fixtures for the in2lambda test suite."""

import json
import os
from pathlib import Path

import pytest

import in2lambda
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

EXPORTS_DIR = Path(__file__).parent / "fixtures" / "exports"
"""Real Lambda Feedback exports, one set per folder, exactly as the platform wrote them."""
Expand All @@ -26,62 +21,3 @@ def filters_dir() -> str:
Each filter ships a self-contained ``example.tex`` used by the end-to-end tests.
"""
return os.path.join(os.path.dirname(in2lambda.__file__), "filters")


def load_export(export_dir: Path) -> Set:
"""Reads an exported set into the in2lambda model, keeping only what the model holds.

The layout followed is the one described in ``fixtures/exports/README.md``.

Args:
export_dir: A folder holding one exported set.

Returns:
The set, with each question's images as absolute paths into ``media/``.
"""
(set_file,) = export_dir.glob("set_*.json")
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 path: json.loads(path.read_text())["orderNumber"],
)
media = sorted((export_dir / "media").glob("*"))
for question_file in question_files:
question_json = json.loads(question_file.read_text())
question_set.questions.append(
Question(
title=question_json["title"],
main_text=question_json["masterContent"],
parts=[
Part(
text=part["content"],
worked_solution=(
part["workedSolution"]["content"]
if "workedSolution" in part
else ""
),
)
for part in question_json["parts"]
],
images=[
str(image)
for image in media
if image.name.startswith(f"{question_file.stem}_")
],
)
)
return question_set
37 changes: 27 additions & 10 deletions tests/test_exports.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
"""Round-trips every real Lambda Feedback export through the in2lambda model.

Each folder in ``fixtures/exports`` is loaded into a :class:`~in2lambda.api.set.Set`,
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.
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, load_export
from conftest import EXPORTS

from in2lambda.api.set import Set

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


def _write_back(question_set: Set, tmp_path: Path) -> Path:
Expand Down Expand Up @@ -74,21 +75,30 @@ def _unexported_keys(written: dict, exported: dict) -> list[str]:
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 = load_export(export_dir)
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(load_export(written)) == _modelled(loaded)
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(load_export(export_dir), tmp_path)
written = _write_back(Set.from_json(str(export_dir)), tmp_path)

missing = {}
for file in written.glob("*.json"):
Expand All @@ -97,3 +107,10 @@ def test_written_keys_exist_in_export(export_dir: Path, tmp_path: Path) -> None:
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