From 4d8dfff265c18483f316fdc8d1e6f817ef1a3c2e Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Tue, 15 Sep 2026 21:54:15 +0100 Subject: [PATCH] implement: Load existing sets and questions from exports (t3) --- in2lambda/api/set.py | 36 +++++++++++ in2lambda/json_convert/json_convert.py | 87 +++++++++++++++++++++++++- tests/conftest.py | 64 ------------------- tests/test_exports.py | 37 ++++++++--- 4 files changed, 149 insertions(+), 75 deletions(-) diff --git a/in2lambda/api/set.py b/in2lambda/api/set.py index 58c7a52..a2dd974 100644 --- a/in2lambda/api/set.py +++ b/in2lambda/api/set.py @@ -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. diff --git a/in2lambda/json_convert/json_convert.py b/in2lambda/json_convert/json_convert.py index 9c3dd29..1fe9364 100644 --- a/in2lambda/json_convert/json_convert.py +++ b/in2lambda/json_convert/json_convert.py @@ -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" @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py index 9a0eef1..ac0da68 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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.""" @@ -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 diff --git a/tests/test_exports.py b/tests/test_exports.py index 9d8f229..0d12833 100644 --- a/tests/test_exports.py +++ b/tests/test_exports.py @@ -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: @@ -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"): @@ -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))