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/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/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..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" @@ -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 @@ -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 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..ac0da68 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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: diff --git a/tests/test_exports.py b/tests/test_exports.py new file mode 100644 index 0000000..0d12833 --- /dev/null +++ b/tests/test_exports.py @@ -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)) 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()