From 4c3d20679d3936a04ac3aeb2c7334c1485dbc101 Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Tue, 15 Sep 2026 19:35:03 +0100 Subject: [PATCH 1/6] implement: Test against real Lambda Feedback exports (t19) --- docs/source/filters.py | 9 +- in2lambda/filters/__init__.py | 18 ++++ in2lambda/json_convert/json_convert.py | 5 +- .../minimal_template_question.json | 1 - in2lambda/main.py | 10 +- tests/conftest.py | 71 ++++++++++++++ tests/test_exports.py | 96 +++++++++++++++++++ tests/test_runner.py | 25 ++--- 8 files changed, 203 insertions(+), 32 deletions(-) create mode 100644 tests/test_exports.py diff --git a/docs/source/filters.py b/docs/source/filters.py index 2002768..483896f 100644 --- a/docs/source/filters.py +++ b/docs/source/filters.py @@ -1,6 +1,5 @@ import importlib import os -import pkgutil import shutil import subprocess from pathlib import Path @@ -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" ) diff --git a/in2lambda/filters/__init__.py b/in2lambda/filters/__init__.py index 830e77d..9519688 100644 --- a/in2lambda/filters/__init__.py +++ b/in2lambda/filters/__init__.py @@ -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"] diff --git a/in2lambda/json_convert/json_convert.py b/in2lambda/json_convert/json_convert.py index dd85f23..0733454 100644 --- a/in2lambda/json_convert/json_convert.py +++ b/in2lambda/json_convert/json_convert.py @@ -96,12 +96,13 @@ 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. filename = ( "question_" + str(i).zfill(3) + "_" - + re.sub(r"[^\w\-_.]", "_", output["title"].strip()) + + re.sub(r"[\s/\\]", "_", output["title"].strip()) ) # write questions into directory diff --git a/in2lambda/json_convert/minimal_template_question.json b/in2lambda/json_convert/minimal_template_question.json index 1f96fc8..db97cf9 100644 --- a/in2lambda/json_convert/minimal_template_question.json +++ b/in2lambda/json_convert/minimal_template_question.json @@ -14,7 +14,6 @@ "answerContent": "", "responseAreas": [], "workedSolution": { - "title": "", "content": "Part worked solution here", "children": [] } diff --git a/in2lambda/main.py b/in2lambda/main.py index d5df1e2..261a18c 100644 --- a/in2lambda/main.py +++ b/in2lambda/main.py @@ -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 @@ -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", diff --git a/tests/conftest.py b/tests/conftest.py index 095b418..9a0eef1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,22 @@ """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.""" + +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") @@ -14,3 +26,62 @@ 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 new file mode 100644 index 0000000..c460236 --- /dev/null +++ b/tests/test_exports.py @@ -0,0 +1,96 @@ +"""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. +""" + +import json +from pathlib import Path + +import pytest +from conftest import EXPORTS, load_export + +from in2lambda.api.set import Set + +pytestmark = 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}[]") for item in value)) + return set() + + +def _keys_by_kind(directory: Path) -> dict[str, set[str]]: + # Pooled over every set_ or question_ file rather than matched file to file: + # exports leave out components with no content, such as a part's worked solution. + keys: dict[str, set[str]] = {} + for file in directory.glob("*.json"): + kind = file.name.split("_")[0] + keys.setdefault(kind, set()).update(_key_paths(json.loads(file.read_text()))) + return keys + + +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) + 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) + + +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 = _keys_by_kind(_write_back(load_export(export_dir), tmp_path)) + exported = _keys_by_kind(export_dir) + + missing = { + kind: sorted(keys - exported[kind]) + for kind, keys in written.items() + if keys - exported[kind] + } + assert not missing, missing diff --git a/tests/test_runner.py b/tests/test_runner.py index df31429..7c39d51 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -1,9 +1,9 @@ """End-to-end tests for :func:`in2lambda.main.runner` across the built-in filters. Each built-in filter ships a self-contained ``example.tex`` that exercises the -document structure it targets. These tests run every filter over its own example -and check both the in-memory :class:`~in2lambda.api.set.Set` and the JSON/ZIP -files written to disk. +document structure it targets. These tests find every filter in the package, run +it over its own example and check both the in-memory :class:`~in2lambda.api.set.Set` +and the JSON/ZIP files written to disk, so a filter shipped without an example fails. """ import json @@ -12,15 +12,20 @@ import pytest from in2lambda.api.set import Set +from in2lambda.filters import builtin_filters from in2lambda.main import runner -BUILTIN_FILTERS = ["PartsSepSol", "PartsOneSol", "PartPartSolSol", "PartSolPartSol"] +def _example(filters_dir: str, filter_name: str) -> str: + path = os.path.join(filters_dir, filter_name, "example.tex") + assert os.path.isfile(path), f"{filter_name} ships no example.tex to test it with" + return path -@pytest.mark.parametrize("filter_name", BUILTIN_FILTERS) + +@pytest.mark.parametrize("filter_name", builtin_filters()) def test_runner_returns_populated_set(filter_name: str, filters_dir: str) -> None: """Every filter turns its example into a Set with at least one usable question.""" - result = runner(os.path.join(filters_dir, filter_name, "example.tex"), filter_name) + result = runner(_example(filters_dir, filter_name), filter_name) assert isinstance(result, Set) assert result.questions, f"{filter_name} produced no questions" @@ -29,17 +34,13 @@ def test_runner_returns_populated_set(filter_name: str, filters_dir: str) -> Non assert question.main_text or question.parts -@pytest.mark.parametrize("filter_name", BUILTIN_FILTERS) +@pytest.mark.parametrize("filter_name", builtin_filters()) def test_runner_writes_importable_json( filter_name: str, filters_dir: str, tmp_path ) -> None: """Passing an output directory produces the Lambda Feedback set/ dir and zip.""" out_dir = tmp_path / "out" - result = runner( - os.path.join(filters_dir, filter_name, "example.tex"), - filter_name, - str(out_dir), - ) + result = runner(_example(filters_dir, filter_name), filter_name, str(out_dir)) set_dir = out_dir / "set" assert set_dir.is_dir() From 8d9d6ca4e6bde26ef37ebf2d097cafbc25609ae1 Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Tue, 15 Sep 2026 21:31:52 +0100 Subject: [PATCH 2/6] implement: Test against real Lambda Feedback exports (t19) --- in2lambda/json_convert/json_convert.py | 5 ++-- tests/test_exports.py | 37 ++++++++++++++------------ 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/in2lambda/json_convert/json_convert.py b/in2lambda/json_convert/json_convert.py index 0733454..9c3dd29 100644 --- a/in2lambda/json_convert/json_convert.py +++ b/in2lambda/json_convert/json_convert.py @@ -97,12 +97,13 @@ def converter( ) # 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. + # 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"[\s/\\]", "_", output["title"].strip()) + + re.sub(r'[\s/\\<>:"|?*]', "_", output["title"].strip()) ) # write questions into directory diff --git a/tests/test_exports.py b/tests/test_exports.py index c460236..9d8f229 100644 --- a/tests/test_exports.py +++ b/tests/test_exports.py @@ -57,18 +57,21 @@ def _key_paths(value, path: str = "") -> set[str]: paths |= {f"{path}.{key}"} | _key_paths(item, f"{path}.{key}") return paths if isinstance(value, list): - return set().union(*(_key_paths(item, f"{path}[]") for item in value)) + return set().union( + *(_key_paths(item, f"{path}[{i}]") for i, item in enumerate(value)) + ) return set() -def _keys_by_kind(directory: Path) -> dict[str, set[str]]: - # Pooled over every set_ or question_ file rather than matched file to file: - # exports leave out components with no content, such as a part's worked solution. - keys: dict[str, set[str]] = {} - for file in directory.glob("*.json"): - kind = file.name.split("_")[0] - keys.setdefault(kind, set()).update(_key_paths(json.loads(file.read_text()))) - return keys +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) def test_export_round_trips(export_dir: Path, tmp_path: Path) -> None: @@ -85,12 +88,12 @@ def test_export_round_trips(export_dir: Path, tmp_path: Path) -> None: 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 = _keys_by_kind(_write_back(load_export(export_dir), tmp_path)) - exported = _keys_by_kind(export_dir) - - missing = { - kind: sorted(keys - exported[kind]) - for kind, keys in written.items() - if keys - exported[kind] - } + written = _write_back(load_export(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 From 4d8dfff265c18483f316fdc8d1e6f817ef1a3c2e Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Tue, 15 Sep 2026 21:54:15 +0100 Subject: [PATCH 3/6] 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)) From 2fe3566e4d524f07c24a4363798a594e05970db1 Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Tue, 15 Sep 2026 22:06:46 +0100 Subject: [PATCH 4/6] implement: Carry question metadata through to JSON (t4) --- docs/source/quickstart.md | 2 + in2lambda/api/question.py | 21 ++++- in2lambda/api/set.py | 6 +- in2lambda/json_convert/json_convert.py | 79 +++++++++++++------ .../minimal_template_question.json | 4 +- tests/test_exports.py | 54 ++++++++++++- 6 files changed, 133 insertions(+), 33 deletions(-) diff --git a/docs/source/quickstart.md b/docs/source/quickstart.md index 34fd175..f6cd80f 100644 --- a/docs/source/quickstart.md +++ b/docs/source/quickstart.md @@ -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 the final answer, worked solution, structured tutorial and chatbot shown to students. The Python API can set each of these per question. + ![Importing Question from file in Teacher Mode](_static/images/import-teacher.png) diff --git a/in2lambda/api/question.py b/in2lambda/api/question.py index 6ee42d4..6cc2b4b 100644 --- a/in2lambda/api/question.py +++ b/in2lambda/api/question.py @@ -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 @@ -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 = "" @@ -36,6 +43,18 @@ 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. + skill: Optional[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. diff --git a/in2lambda/api/set.py b/in2lambda/api/set.py index a2dd974..ce9b817 100644 --- a/in2lambda/api/set.py +++ b/in2lambda/api/set.py @@ -146,9 +146,9 @@ 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. + 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. diff --git a/in2lambda/json_convert/json_convert.py b/in2lambda/json_convert/json_convert.py index 1fe9364..48d5474 100644 --- a/in2lambda/json_convert/json_convert.py +++ b/in2lambda/json_convert/json_convert.py @@ -35,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], @@ -74,31 +115,7 @@ 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 = _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, @@ -156,6 +173,9 @@ def main(set_questions: Set, output_dir: str) -> None: 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. @@ -229,6 +249,15 @@ def load(path: str) -> Set: # 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 diff --git a/in2lambda/json_convert/minimal_template_question.json b/in2lambda/json_convert/minimal_template_question.json index db97cf9..aea20ad 100644 --- a/in2lambda/json_convert/minimal_template_question.json +++ b/in2lambda/json_convert/minimal_template_question.json @@ -2,11 +2,11 @@ "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, diff --git a/tests/test_exports.py b/tests/test_exports.py index 0d12833..0c9f66c 100644 --- a/tests/test_exports.py +++ b/tests/test_exports.py @@ -9,11 +9,13 @@ import json import re +from dataclasses import replace from pathlib import Path import pytest from conftest import EXPORTS +from in2lambda.api.question import Question from in2lambda.api.set import Set each_export = pytest.mark.parametrize("export_dir", EXPORTS, ids=lambda path: path.name) @@ -35,7 +37,8 @@ def _relative_files(directory: Path) -> list[str]: 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. + # set was read from, so compare their values and file names. Questions are + # compared whole, so a field added to Question is compared without editing this. return { "name": question_set._name, "description": question_set._description, @@ -45,7 +48,7 @@ def _modelled(question_set: Set) -> dict: str(question_set._structuredTutorialVisibility), ], "questions": [ - (q.title, q.main_text, q.parts, [Path(image).name for image in q.images]) + replace(q, images=[Path(image).name for image in q.images]) for q in question_set.questions ], } @@ -109,6 +112,53 @@ def test_written_keys_exist_in_export(export_dir: Path, tmp_path: Path) -> None: assert not missing, missing +def test_question_settings_are_written(tmp_path: Path) -> None: + """A question's settings reach its JSON, and unset optional ones are left out.""" + question_set = Set(_name="Settings") + question_set.questions = [ + Question( + title="Configured", + skill=1 / 3, + guidance="Try part a first.", + duration_lower_bound=5, + duration_upper_bound=10, + publish=False, + display_chatbot=False, + ), + Question(title="Default"), + ] + written = _write_back(question_set, tmp_path) + + configured = json.loads((written / "question_000_Configured.json").read_text()) + assert { + key: configured[key] + for key in [ + "skill", + "guidance", + "durationLowerBound", + "durationUpperBound", + "publish", + "displayFinalAnswer", + "displayChatbot", + ] + } == { + "skill": 1 / 3, + "guidance": "Try part a first.", + "durationLowerBound": 5, + "durationUpperBound": 10, + "publish": False, + "displayFinalAnswer": True, + "displayChatbot": False, + } + + default = json.loads((written / "question_001_Default.json").read_text()) + assert default["publish"] is True + assert default["displayChatbot"] is True + assert not {"skill", "guidance", "durationLowerBound", "durationUpperBound"} & set( + default + ) + + 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("{}") From edbcb2e9496e0ec30d5d637b7a58df9780dd8718 Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Tue, 15 Sep 2026 22:10:57 +0100 Subject: [PATCH 5/6] implement: Carry question metadata through to JSON (t4) --- docs/source/quickstart.md | 2 +- tests/test_exports.py | 31 +++++++++++++++++++++++++++++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/docs/source/quickstart.md b/docs/source/quickstart.md index f6cd80f..6cbb31a 100644 --- a/docs/source/quickstart.md +++ b/docs/source/quickstart.md @@ -79,6 +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 the final answer, worked solution, structured tutorial and chatbot shown to students. The Python API can set each of these per question. +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) diff --git a/tests/test_exports.py b/tests/test_exports.py index 0c9f66c..8dbac4d 100644 --- a/tests/test_exports.py +++ b/tests/test_exports.py @@ -113,7 +113,7 @@ def test_written_keys_exist_in_export(export_dir: Path, tmp_path: Path) -> None: def test_question_settings_are_written(tmp_path: Path) -> None: - """A question's settings reach its JSON, and unset optional ones are left out.""" + """A question's settings reach its JSON, are left out when unset, and reload.""" question_set = Set(_name="Settings") question_set.questions = [ Question( @@ -123,6 +123,9 @@ def test_question_settings_are_written(tmp_path: Path) -> None: duration_lower_bound=5, duration_upper_bound=10, publish=False, + display_final_answer=False, + display_worked_solution=False, + display_structured_tutorial=False, display_chatbot=False, ), Question(title="Default"), @@ -139,6 +142,8 @@ def test_question_settings_are_written(tmp_path: Path) -> None: "durationUpperBound", "publish", "displayFinalAnswer", + "displayWorkedSolution", + "displayStructuredTutorial", "displayChatbot", ] } == { @@ -147,7 +152,9 @@ def test_question_settings_are_written(tmp_path: Path) -> None: "durationLowerBound": 5, "durationUpperBound": 10, "publish": False, - "displayFinalAnswer": True, + "displayFinalAnswer": False, + "displayWorkedSolution": False, + "displayStructuredTutorial": False, "displayChatbot": False, } @@ -158,6 +165,26 @@ def test_question_settings_are_written(tmp_path: Path) -> None: default ) + # Only the settings are compared: a question written without parts reloads with + # the template's placeholder part. + def settings(question: Question) -> list: + return [ + question.skill, + question.guidance, + question.duration_lower_bound, + question.duration_upper_bound, + question.publish, + question.display_final_answer, + question.display_worked_solution, + question.display_structured_tutorial, + question.display_chatbot, + ] + + reloaded = Set.from_json(str(written)).questions + assert [settings(q) for q in reloaded] == [ + settings(q) for q in question_set.questions + ] + 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.""" From de8b16cf11fcf43341e257e60a9afe6c36b75ca7 Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Tue, 15 Sep 2026 22:14:14 +0100 Subject: [PATCH 6/6] implement: Carry question metadata through to JSON (t4) --- in2lambda/api/question.py | 4 +++- tests/test_exports.py | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/in2lambda/api/question.py b/in2lambda/api/question.py index 6cc2b4b..29d112f 100644 --- a/in2lambda/api/question.py +++ b/in2lambda/api/question.py @@ -45,7 +45,9 @@ class Question: # Settings are left out of the repr so that printing a question still shows its # content rather than nine lines of configuration. - skill: Optional[float] = field(default=None, repr=False) + # 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) diff --git a/tests/test_exports.py b/tests/test_exports.py index 8dbac4d..b3d5aee 100644 --- a/tests/test_exports.py +++ b/tests/test_exports.py @@ -118,7 +118,9 @@ def test_question_settings_are_written(tmp_path: Path) -> None: question_set.questions = [ Question( title="Configured", - skill=1 / 3, + # A whole number, as an export holds the highest skill level; the + # fixture's questions cover fractional ones. + skill=1, guidance="Try part a first.", duration_lower_bound=5, duration_upper_bound=10, @@ -147,7 +149,7 @@ def test_question_settings_are_written(tmp_path: Path) -> None: "displayChatbot", ] } == { - "skill": 1 / 3, + "skill": 1, "guidance": "Try part a first.", "durationLowerBound": 5, "durationUpperBound": 10,