diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d2e798f..f879ff4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.10', '3.11'] + python-version: ['3.11', '3.12'] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} diff --git a/docs/source/quickstart.md b/docs/source/quickstart.md index 34fd175..6d22bd6 100644 --- a/docs/source/quickstart.md +++ b/docs/source/quickstart.md @@ -80,3 +80,28 @@ 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! 🎉 ![Importing Question from file in Teacher Mode](_static/images/import-teacher.png) + +## Changing the Defaults + +A setting the document doesn't give, such as whether a question is published, takes in2lambda's default. To change some of the defaults, write a TOML file holding only those, laid out like in2lambda's own (below), and pass it with `--defaults`: + +```toml +# ours.toml +[question] +publish = false + +[response_area.MATH_MULTI_LINES] +evaluation_function = "evaluateProof (experimental)" +``` + +```bash +$ in2lambda questions.tex PartSolPartSol --defaults ours.toml +``` + +From Python, call {func}`in2lambda.defaults.override` before creating the set. A file that sets something in2lambda has no default for, or a value of the wrong type, is refused. + +:::{dropdown} in2lambda's defaults +```{literalinclude} ../../in2lambda/defaults.toml +:language: toml +``` +::: diff --git a/in2lambda/api/question.py b/in2lambda/api/question.py index cf3f108..31343a7 100644 --- a/in2lambda/api/question.py +++ b/in2lambda/api/question.py @@ -5,7 +5,9 @@ import panflute as pf +from in2lambda import defaults from in2lambda.api.part import Part +from in2lambda.defaults import UNSET @dataclass @@ -39,7 +41,8 @@ class Question: text.""" # The settings below are left out of the repr, so that printing a question shows its - # content rather than nine lines of configuration. + # content rather than nine lines of configuration. Those set to UNSET default to + # what in2lambda's defaults.toml gives them. # The skill level may be an int as well as a float, because Lambda Feedback writes # its exports in JavaScript, which writes the levels 0.0 and 1.0 as 0 and 1. @@ -47,11 +50,15 @@ class Question: 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=False, 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=False, repr=False) + publish: bool = field(default=UNSET, repr=False) + display_final_answer: bool = field(default=UNSET, repr=False) + display_worked_solution: bool = field(default=UNSET, repr=False) + display_structured_tutorial: bool = field(default=UNSET, repr=False) + display_chatbot: bool = field(default=UNSET, repr=False) + + def __post_init__(self) -> None: + """Gives each setting left unset its default.""" + defaults.fill(self, "question") @property def master_content(self) -> str: diff --git a/in2lambda/api/response_area.py b/in2lambda/api/response_area.py index b74de86..430dcac 100644 --- a/in2lambda/api/response_area.py +++ b/in2lambda/api/response_area.py @@ -5,6 +5,9 @@ from enum import Enum from typing import Any, ClassVar, Optional, Union +from in2lambda import defaults +from in2lambda.defaults import UNSET + def _new_id() -> str: return str(uuid.uuid4()) @@ -83,7 +86,7 @@ class ResponseArea: response_type: ClassVar[str] answer: Any = None - evaluation_function: str + evaluation_function: str = UNSET grade_params: Optional[dict[str, Any]] = None pre_text: str = "" post_text: str = "" @@ -92,19 +95,23 @@ class ResponseArea: check_button_text: str = "" """Replaces the label of the check button; left out of the JSON when empty.""" input_symbols: list[InputSymbol] = field(default_factory=list) - display_input_symbols: bool = False - live_preview: bool = False - include_in_pdf: bool = False - save_allowed: bool = False - separate_feedback: bool = True - common_feedback_color: str = "#C4CDD5" - correct_feedback_color: str = "#22C55E" - correct_feedback_prefix: str = "Correct" - incorrect_feedback_color: str = "#ff5630" - incorrect_feedback_prefix: str = "Incorrect" + display_input_symbols: bool = UNSET + live_preview: bool = UNSET + include_in_pdf: bool = UNSET + save_allowed: bool = UNSET + separate_feedback: bool = UNSET + common_feedback_color: str = UNSET + correct_feedback_color: str = UNSET + correct_feedback_prefix: str = UNSET + incorrect_feedback_color: str = UNSET + incorrect_feedback_prefix: str = UNSET tests: list[Test] = field(default_factory=list) cases: list[Case] = field(default_factory=list) + def __post_init__(self) -> None: + """Gives each setting left unset its default.""" + defaults.fill(self, "response_area", self.response_type) + def _config(self) -> Optional[dict[str, Any]]: """The response type's settings, as Lambda Feedback writes them.""" return None @@ -117,9 +124,9 @@ def _grade_params(self) -> Optional[dict[str, Any]]: @dataclass(kw_only=True) class _Maths(ResponseArea): answer: str = "" - allow_handwrite: bool = True - allow_photo: bool = True - enable_refinement: bool = True + allow_handwrite: bool = UNSET + allow_photo: bool = UNSET + enable_refinement: bool = UNSET def _config(self) -> Optional[dict[str, Any]]: return { @@ -134,7 +141,6 @@ class MathSingleLine(_Maths): """A single symbolic expression.""" response_type: ClassVar[str] = "MATH_SINGLE_LINE" - evaluation_function: str = "compareExpressions" @dataclass(kw_only=True) @@ -150,7 +156,6 @@ class Number(ResponseArea): response_type: ClassVar[str] = "NUMBER" answer: Optional[Union[int, float]] = None - evaluation_function: str = "isSimilar" @dataclass(kw_only=True) @@ -159,7 +164,6 @@ class NumericUnits(ResponseArea): response_type: ClassVar[str] = "NUMERIC_UNITS" answer: str = "" - evaluation_function: str = "compareExpressions" def _grade_params(self) -> Optional[dict[str, Any]]: # compareExpressions compares units only when told to. @@ -180,7 +184,6 @@ class Boolean(ResponseArea): response_type: ClassVar[str] = "BOOLEAN" answer: bool = False - evaluation_function: str = "isExactEqual" @dataclass(kw_only=True) @@ -189,10 +192,9 @@ class MultipleChoice(ResponseArea): response_type: ClassVar[str] = "MULTIPLE_CHOICE" answer: list[bool] = field(default_factory=list) - evaluation_function: str = "arrayEqual" options: list[str] = field(default_factory=list) - single: bool = True - randomise: bool = False + single: bool = UNSET + randomise: bool = UNSET def _config(self) -> Optional[dict[str, Any]]: return { @@ -208,7 +210,6 @@ class Table(ResponseArea): response_type: ClassVar[str] = "TABLE" answer: list[list[str]] = field(default_factory=list) - evaluation_function: str = "arraySymbolicEqual" row_names: list[str] = field(default_factory=list) col_names: list[str] = field(default_factory=list) @@ -227,7 +228,6 @@ class Matrix(ResponseArea): response_type: ClassVar[str] = "MATRIX" answer: list[list[str]] = field(default_factory=list) - evaluation_function: str = "arraySymbolicEqual" def _config(self) -> Optional[dict[str, Any]]: return { @@ -242,8 +242,7 @@ class Code(ResponseArea): response_type: ClassVar[str] = "CODE" answer: str = "" - evaluation_function: str = "evaluatePython" - language: str = "python" + language: str = UNSET def _config(self) -> Optional[dict[str, Any]]: return {"language": self.language} @@ -255,10 +254,10 @@ class Essay(ResponseArea): response_type: ClassVar[str] = "ESSAY" answer: Union[str, dict[str, str]] = "" - allow_draw: bool = False - allow_scan: bool = False - repeat_for_subjects: bool = False - allow_empty_submission: bool = False + allow_draw: bool = UNSET + allow_scan: bool = UNSET + repeat_for_subjects: bool = UNSET + allow_empty_submission: bool = UNSET def _config(self) -> Optional[dict[str, Any]]: return { @@ -281,8 +280,8 @@ class Likert(ResponseArea): ] = None statements: list[str] = field(default_factory=list) options: Optional[list[str]] = None - repeat_for_subjects: bool = False - allow_empty_submission: bool = False + repeat_for_subjects: bool = UNSET + allow_empty_submission: bool = UNSET def _config(self) -> Optional[dict[str, Any]]: config: dict[str, Any] = { @@ -300,12 +299,10 @@ class Images(ResponseArea): """Upload images.""" response_type: ClassVar[str] = "IMAGES" - max_images: int = 3 - allowed_types: list[str] = field( - default_factory=lambda: ["image/jpg", "image/jpeg", "image/png"] - ) - max_size_mb: int = 10 - resize_max_side: int = 0 + max_images: int = UNSET + allowed_types: list[str] = UNSET + max_size_mb: int = UNSET + resize_max_side: int = UNSET """The longest side images are resized to; 0 leaves them as they are.""" def _config(self) -> Optional[dict[str, Any]]: @@ -315,3 +312,24 @@ def _config(self) -> Optional[dict[str, Any]]: "maxSizeMb": self.max_size_mb, "resizeMaxSide": self.resize_max_side, } + + +RESPONSE_TYPES: dict[str, type[ResponseArea]] = { + cls.response_type: cls + for cls in ( + MathSingleLine, + MathMultiLines, + Number, + NumericUnits, + Text, + Boolean, + MultipleChoice, + Table, + Matrix, + Code, + Essay, + Likert, + Images, + ) +} +"""Each response type's class, by the name Lambda Feedback gives the type.""" diff --git a/in2lambda/api/set.py b/in2lambda/api/set.py index 784f6cc..0d0a445 100644 --- a/in2lambda/api/set.py +++ b/in2lambda/api/set.py @@ -5,8 +5,10 @@ import panflute as pf +from in2lambda import defaults from in2lambda.api.question import Question -from in2lambda.api.visibility_status import VisibilityController, VisibilityStatus +from in2lambda.api.visibility_status import VisibilityController +from in2lambda.defaults import UNSET @dataclass @@ -19,30 +21,23 @@ class Set: _name: str = field(default="set") _description: str = field(default="") - _finalAnswerVisibility: VisibilityController = field( - default_factory=lambda: VisibilityController( - VisibilityStatus.OPEN_WITH_WARNINGS - ) - ) - _workedSolutionVisibility: VisibilityController = field( - default_factory=lambda: VisibilityController( - VisibilityStatus.OPEN_WITH_WARNINGS - ) - ) - _structuredTutorialVisibility: VisibilityController = field( - default_factory=lambda: VisibilityController(VisibilityStatus.OPEN) - ) - _chatbotVisibility: VisibilityController = field( - default_factory=lambda: VisibilityController(VisibilityStatus.HIDE) - ) - is_survey: bool = False + # The settings below default to what in2lambda's defaults.toml gives them. + _finalAnswerVisibility: VisibilityController = UNSET + _workedSolutionVisibility: VisibilityController = UNSET + _structuredTutorialVisibility: VisibilityController = UNSET + _chatbotVisibility: VisibilityController = UNSET + is_survey: bool = UNSET released_at: Optional[str] = None hidden_at: Optional[str] = None - manually_hidden: bool = True + manually_hidden: bool = UNSET questions: list[Question] = field(default_factory=list) _current_question_index = -1 + def __post_init__(self) -> None: + """Gives each setting left unset its default.""" + defaults.fill(self, "set") + @property def current_question(self) -> Question: """The current question being modified, or Question("INVALID") if there are no questions. diff --git a/in2lambda/defaults.py b/in2lambda/defaults.py new file mode 100644 index 0000000..9877ab2 --- /dev/null +++ b/in2lambda/defaults.py @@ -0,0 +1,116 @@ +"""The defaults in2lambda writes for settings left unset, kept in ``defaults.toml``.""" + +import copy +import re +import tomllib +from dataclasses import fields +from pathlib import Path +from typing import Any, Optional + +from beartype.door import is_bearable + +from in2lambda.api.visibility_status import VisibilityController, VisibilityStatus + + +class _Unset: + def __repr__(self) -> str: + return "" + + +UNSET: Any = _Unset() +"""Marks a field whose value comes from the defaults.""" + +_DEFAULTS = tomllib.loads(Path(__file__).with_name("defaults.toml").read_text()) + + +def _key(name: str) -> str: + """A field's key in the defaults, e.g. ``_chatbotVisibility`` -> ``chatbot_visibility``.""" + return re.sub(r"(? None: + """Replaces in2lambda's defaults with those set in a TOML file. + + The file is laid out as in2lambda's own ``defaults.toml`` and need hold only the + values to change. Objects created afterwards take the new defaults. + + Args: + path: The TOML file. + + Raises: + ValueError: The file is not valid TOML, or sets something in2lambda has no + default for, or a value of the wrong type. The defaults are then left as they were. + """ + from in2lambda.api.question import Question + from in2lambda.api.response_area import RESPONSE_TYPES, ResponseArea + from in2lambda.api.set import Set + + classes: dict[str, Any] = { + "set": Set, + "question": Question, + "response_area": ResponseArea, + } + try: + new = tomllib.loads(Path(path).read_text()) + except tomllib.TOMLDecodeError as error: + raise ValueError(f"{path} is not valid TOML: {error}") from error + # Changed on a copy, so that a file with a mistake in it changes nothing. + staged = copy.deepcopy(_DEFAULTS) + for section, values in new.items(): + if section not in classes: + raise ValueError(f"{path}: in2lambda has no defaults for [{section}]") + for key, value in values.items(): + if section == "response_area" and isinstance(value, dict): + if key not in RESPONSE_TYPES: + raise ValueError(f"{path}: there is no response type {key}") + _check(path, RESPONSE_TYPES[key], value) + staged[section].setdefault(key, {}).update(value) + else: + _check(path, classes[section], {key: value}) + staged[section][key] = value + _DEFAULTS.clear() + _DEFAULTS.update(staged) + + +def _check(path: str, cls: Any, values: dict[str, Any]) -> None: + settable = {_key(f.name): f.type for f in fields(cls) if f.default is UNSET} + for key, value in values.items(): + if key not in settable: + raise ValueError(f"{path}: {cls.__name__} has no default {key}") + if settable[key] is VisibilityController: + VisibilityStatus(value) + elif not is_bearable(value, settable[key]): + raise ValueError(f"{path}: {key} = {value!r} is not a {settable[key]}") + + +def fill(obj: Any, section: str, response_type: Optional[str] = None) -> None: + """Sets each of an object's unset fields to its default. + + Args: + obj: A dataclass instance. + section: Its table in the defaults. + response_type: For a response area, its type, whose own table comes first. + + Raises: + TypeError: A field has no default, or the defaults give it a value of the + wrong type. + """ + general = _DEFAULTS[section] + specific = general.get(response_type, {}) if response_type else {} + for f in fields(obj): + if getattr(obj, f.name) is not UNSET: + continue + key = _key(f.name) + if key in specific: + value = specific[key] + elif key in general and not isinstance(general[key], dict): + value = general[key] + else: + raise TypeError(f"{type(obj).__name__} needs a value for {f.name}") + if f.type is VisibilityController: + value = VisibilityController(VisibilityStatus(value)) + elif not is_bearable(value, f.type): + raise TypeError( + f"the default {section} {key} = {value!r} is not a {f.type}" + ) + setattr(obj, f.name, copy.deepcopy(value)) diff --git a/in2lambda/defaults.toml b/in2lambda/defaults.toml new file mode 100644 index 0000000..c8df652 --- /dev/null +++ b/in2lambda/defaults.toml @@ -0,0 +1,106 @@ +# in2lambda's defaults: what it writes for a setting the author leaves unset. +# +# Override any of them with a TOML file of your own laid out the same way, holding +# only the values you want to change, via `in2lambda.defaults.override(path)` or +# `in2lambda --defaults path`. + +[set] # Visibility to students, at the set level (overrides the defaults). +# For strings, the options are OPEN, OPEN_WITH_WARNINGS (students confirm first) or HIDE. +final_answer_visibility = "OPEN_WITH_WARNINGS" +worked_solution_visibility = "OPEN_WITH_WARNINGS" +structured_tutorial_visibility = "OPEN" +chatbot_visibility = "OPEN" +is_survey = false # For module evaluation questionnaires (MEQ) +manually_hidden = false # Visible to students when uploaded to the platform + +[question] # Visibility to students, at the question level (overrides the set-level visibility) +publish = true +display_final_answer = true +display_worked_solution = true +display_structured_tutorial = true +display_chatbot = true + +# Every response area, unless its own type's table below says otherwise. +[response_area] +display_input_symbols = false +live_preview = true +include_in_pdf = true +save_allowed = false +separate_feedback = true +common_feedback_color = "#C4CDD5" # any hex colour +correct_feedback_color = "#22C55E" +correct_feedback_prefix = "Correct" # any text +incorrect_feedback_color = "#ff5630" +incorrect_feedback_prefix = "Incorrect" + +# evaluation_function is the name of an evaluation function, e.g. "compareExpressions". +# A type with none here needs one from the author. + +[response_area.MATH_SINGLE_LINE] +evaluation_function = "compareExpressions" +live_preview = true +display_input_symbols = true +allow_handwrite = true +allow_photo = true +enable_refinement = true +include_in_pdf = true + +[response_area.MATH_MULTI_LINES] +live_preview = true +allow_handwrite = true +allow_photo = true +enable_refinement = true +include_in_pdf = false + +[response_area.NUMBER] +evaluation_function = "isSimilar" +include_in_pdf = true + +[response_area.NUMERIC_UNITS] +evaluation_function = "compareExpressions" +include_in_pdf = true + +[response_area.TEXT] +include_in_pdf = true + +[response_area.BOOLEAN] +evaluation_function = "isExactEqual" +include_in_pdf = true + +[response_area.MULTIPLE_CHOICE] +evaluation_function = "arrayEqual" +include_in_pdf = true +single = true +randomise = false + +[response_area.TABLE] +evaluation_function = "arraySymbolicEqual" +include_in_pdf = true + +[response_area.MATRIX] +evaluation_function = "arraySymbolicEqual" +include_in_pdf = true + +[response_area.CODE] +evaluation_function = "evaluatePython" +language = "python" # the language of the code students write +include_in_pdf = true + +[response_area.ESSAY] +allow_draw = false +allow_scan = false +repeat_for_subjects = false +allow_empty_submission = false +include_in_pdf = true + +[response_area.LIKERT] +repeat_for_subjects = false +allow_empty_submission = false +include_in_pdf = true + +[response_area.IMAGES] +max_images = 3 # 1 to 10 +allowed_types = ["image/jpg", "image/jpeg", "image/png"] # file types, as MIME types +max_size_mb = 10 # 1 to 20 +resize_max_side = 0 # 0 to 4096 pixels; 0 keeps images as uploaded +include_in_pdf = true diff --git a/in2lambda/json_convert/json_convert.py b/in2lambda/json_convert/json_convert.py index 3895b0d..270ef38 100644 --- a/in2lambda/json_convert/json_convert.py +++ b/in2lambda/json_convert/json_convert.py @@ -5,17 +5,12 @@ import re import shutil import zipfile -from copy import deepcopy -from pathlib import Path from typing import Any from in2lambda.api.part import Branch, Part from in2lambda.api.response_area import Case, ResponseArea from in2lambda.api.set import Set -MINIMAL_QUESTION_TEMPLATE = "minimal_template_question.json" -MINIMAL_SET_TEMPLATE = "minimal_template_set.json" - def _zip_sorted_folder(folder_path, zip_path): """Zips the contents of a folder, preserving the directory structure. @@ -126,17 +121,10 @@ def _part_to_json(part: Part, order: int) -> dict[str, Any]: return output -def converter( - question_template: dict[str, Any], - set_template: dict[str, Any], - SetQuestions: Set, - output_dir: str, -) -> None: +def converter(SetQuestions: Set, output_dir: str) -> None: """Turns a set of question objects into Lambda Feedback JSON. Args: - question_template: The loaded JSON from the minimal question template (it needs to be in sync). - set_template: The loaded JSON from the minimal set template (it needs to be in sync). SetQuestions: A Set object containing questions. output_dir: The absolute path for where to produced the final JSON/zip files. """ @@ -149,49 +137,38 @@ def converter( output_question = os.path.join(output_dir, set_name) os.makedirs(output_question, exist_ok=True) - set_template["name"] = set_name - set_template["description"] = set_description - set_template["finalAnswerVisibility"] = str( - SetQuestions._finalAnswerVisibility.status - ) - set_template["workedSolutionVisibility"] = str( - SetQuestions._workedSolutionVisibility.status - ) - set_template["structuredTutorialVisibility"] = str( - SetQuestions._structuredTutorialVisibility.status - ) - set_template["chatbotVisibility"] = str(SetQuestions._chatbotVisibility.status) - set_template["isSurvey"] = SetQuestions.is_survey - set_template["manuallyHidden"] = SetQuestions.manually_hidden + set_json = { + "name": set_name, + "description": set_description, + "isSurvey": SetQuestions.is_survey, + "manuallyHidden": SetQuestions.manually_hidden, + "finalAnswerVisibility": str(SetQuestions._finalAnswerVisibility), + "workedSolutionVisibility": str(SetQuestions._workedSolutionVisibility), + "structuredTutorialVisibility": str(SetQuestions._structuredTutorialVisibility), + "chatbotVisibility": str(SetQuestions._chatbotVisibility), + } for key, value in { "releasedAt": SetQuestions.released_at, "hiddenAt": SetQuestions.hidden_at, }.items(): if value is not None: - set_template[key] = value + set_json[key] = value # create the set file with open(f"{output_question}/set_{set_name}.json", "w") as file: - json.dump(set_template, file) + json.dump(set_json, 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].master_content - question = ListQuestions[i] - 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 + output: dict[str, Any] = { + "orderNumber": i, + "title": question.title or f"Question {i + 1}", + "masterContent": question.master_content, + "publish": question.publish, + "displayFinalAnswer": question.display_final_answer, + "displayStructuredTutorial": question.display_structured_tutorial, + "displayWorkedSolution": question.display_worked_solution, + "displayChatbot": question.display_chatbot, + } # An unset optional setting is left out of the JSON, so that in2lambda writes no # value the author did not choose. for key, value in { @@ -203,10 +180,9 @@ def converter( if value is not None: output[key] = value - if question.parts: - output["parts"] = [ - _part_to_json(part, j) for j, part in enumerate(question.parts) - ] + output["parts"] = [ + _part_to_json(part, j) for j, part in enumerate(question.parts) + ] # Output file filename = ( @@ -243,17 +219,10 @@ def main(set_questions: Set, output_dir: str) -> None: set_questions: A Set object containing questions. output_dir: Where to output the final Lambda Feedback JSON/ZIP files. """ - # Use path so minimal template can be found regardless of where the user is running python from. - with open(Path(__file__).with_name(MINIMAL_QUESTION_TEMPLATE), "r") as file: - question_template = json.load(file) - - with open(Path(__file__).with_name(MINIMAL_SET_TEMPLATE), "r") as file: - set_template = json.load(file) - # check if directory exists in file if os.path.isdir(output_dir): try: shutil.rmtree(output_dir) except OSError as e: print("Error: %s : %s" % (output_dir, e.strerror)) - converter(question_template, set_template, set_questions, output_dir) + converter(set_questions, output_dir) diff --git a/in2lambda/json_convert/minimal_template_question.json b/in2lambda/json_convert/minimal_template_question.json deleted file mode 100644 index db97cf9..0000000 --- a/in2lambda/json_convert/minimal_template_question.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "orderNumber": 0, - "title": "Question title here", - "masterContent": "Top level question here", - "publish": false, - "displayFinalAnswer": true, - "displayStructuredTutorial": true, - "displayWorkedSolution": true, - "displayChatbot": false, - "parts": [ - { - "orderNumber": 0, - "content": "Part text here", - "answerContent": "", - "responseAreas": [], - "workedSolution": { - "content": "Part worked solution here", - "children": [] - } - } - ] -} diff --git a/in2lambda/json_convert/minimal_template_set.json b/in2lambda/json_convert/minimal_template_set.json deleted file mode 100644 index f7b4655..0000000 --- a/in2lambda/json_convert/minimal_template_set.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "a simple example", - "description": "description here", - "manuallyHidden": true, - "finalAnswerVisibility": "OPEN_WITH_WARNINGS", - "workedSolutionVisibility": "OPEN_WITH_WARNINGS", - "structuredTutorialVisibility": "OPEN", - "chatbotVisibility": "HIDE" -} \ No newline at end of file diff --git a/in2lambda/main.py b/in2lambda/main.py index 76f0c63..2e275dd 100644 --- a/in2lambda/main.py +++ b/in2lambda/main.py @@ -15,6 +15,7 @@ import rich_click as click import in2lambda.filters +from in2lambda import defaults from in2lambda.api.set import Set @@ -100,9 +101,9 @@ def runner( >>> from in2lambda.main import runner >>> # Retrieve an example TeX file and run the given filter. >>> runner(f"{os.path.dirname(in2lambda.__file__)}/filters/PartsSepSol/example.tex", "PartsSepSol") # doctest: +ELLIPSIS - Set(_name='set', _description='', _finalAnswerVisibility='OPEN_WITH_WARNINGS', _workedSolutionVisibility='OPEN_WITH_WARNINGS', _structuredTutorialVisibility='OPEN', _chatbotVisibility='HIDE', is_survey=False, released_at=None, hidden_at=None, manually_hidden=True, questions=[Question(title='', parts=[Part(content=..., response_areas=[], tutorial='', tutorial_branches=[], answer='', worked_solution='', worked_solution_branches=[]), ...], images=[], master_content='This is a sample question\n\n'), ...]) + Set(_name='set', _description='', _finalAnswerVisibility='OPEN_WITH_WARNINGS', _workedSolutionVisibility='OPEN_WITH_WARNINGS', _structuredTutorialVisibility='OPEN', _chatbotVisibility='OPEN', is_survey=False, released_at=None, hidden_at=None, manually_hidden=False, questions=[Question(title='', parts=[Part(content=..., response_areas=[], tutorial='', tutorial_branches=[], answer='', worked_solution='', worked_solution_branches=[]), ...], images=[], master_content='This is a sample question\n\n'), ...]) >>> runner(f"{os.path.dirname(in2lambda.__file__)}/filters/PartsOneSol/example.tex", "PartsOneSol") # doctest: +ELLIPSIS - Set(_name='set', _description='', _finalAnswerVisibility='OPEN_WITH_WARNINGS', _workedSolutionVisibility='OPEN_WITH_WARNINGS', _structuredTutorialVisibility='OPEN', _chatbotVisibility='HIDE', is_survey=False, released_at=None, hidden_at=None, manually_hidden=True, questions=[Question(title='', parts=[Part(content=..., response_areas=[], tutorial='', tutorial_branches=[], answer='', worked_solution='', worked_solution_branches=[]), ...], images=[], master_content='Here is some preliminary question information that might be useful.'), ...]) + Set(_name='set', _description='', _finalAnswerVisibility='OPEN_WITH_WARNINGS', _workedSolutionVisibility='OPEN_WITH_WARNINGS', _structuredTutorialVisibility='OPEN', _chatbotVisibility='OPEN', is_survey=False, released_at=None, hidden_at=None, manually_hidden=False, questions=[Question(title='', parts=[Part(content=..., response_areas=[], tutorial='', tutorial_branches=[], answer='', worked_solution='', worked_solution_branches=[]), ...], images=[], master_content='Here is some preliminary question information that might be useful.'), ...]) """ # The list of questions for Lambda Feedback as a Python API. set_obj = Set() @@ -194,10 +195,23 @@ def runner( help="File containing solutions for QUESTION_FILE.", type=click.Path(resolve_path=True, exists=True, dir_okay=False), ) +@click.option( + "--defaults", + "defaults_file", + default=None, + help="TOML file overriding in2lambda's defaults.", + type=click.Path(resolve_path=True, exists=True, dir_okay=False), +) def cli( - question_file: str, chosen_filter: str, output_dir: str, answer_file: Optional[str] + question_file: str, + chosen_filter: str, + output_dir: str, + answer_file: Optional[str], + defaults_file: Optional[str], ) -> None: """Takes in a QUESTION_FILE for a given SUBJECT and produces Lambda Feedback compatible json/zip files.""" + if defaults_file: + defaults.override(defaults_file) # main() is made separate from click() so that it can be easily imported as part of a library. runner(question_file, chosen_filter, output_dir, answer_file) diff --git a/poetry.lock b/poetry.lock index c480e8d..33708a0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.4.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.0 and should not be changed by hand. [[package]] name = "alabaster" @@ -113,8 +113,6 @@ packaging = ">=22.0" pathspec = ">=1.0.0" platformdirs = ">=2" pytokens = ">=0.4.0,<0.5.0" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} [package.extras] colorama = ["colorama (>=0.4.3)"] @@ -484,9 +482,6 @@ files = [ {file = "coverage-7.16.1.tar.gz", hash = "sha256:f83981779bcf9dfa06fa0a8d4cb43e0faec1706328ce07aa3e7b665b4ac0f210"}, ] -[package.dependencies] -tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} - [package.extras] toml = ["tomli ; python_full_version <= \"3.11.0a6\""] @@ -514,25 +509,6 @@ files = [ {file = "docutils-0.20.1.tar.gz", hash = "sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b"}, ] -[[package]] -name = "exceptiongroup" -version = "1.3.1" -description = "Backport of PEP 654 (exception groups)" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -markers = "python_version == \"3.10\"" -files = [ - {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, - {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, -] - -[package.dependencies] -typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} - -[package.extras] -test = ["pytest (>=6)"] - [[package]] name = "filelock" version = "3.32.6" @@ -561,7 +537,7 @@ files = [ beautifulsoup4 = "*" pygments = ">=2.7" sphinx = ">=6.0,<9.0" -sphinx-basic-ng = ">=1.0.0b2" +sphinx-basic-ng = ">=1.0.0.beta2" [[package]] name = "identify" @@ -1012,7 +988,6 @@ files = [ librt = {version = ">=0.8.0", markers = "platform_python_implementation != \"PyPy\""} mypy_extensions = ">=1.0.0" pathspec = ">=1.0.0" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} typing_extensions = [ {version = ">=4.6.0", markers = "python_version < \"3.15\""}, {version = ">=4.14.0", markers = "python_version >= \"3.15\""}, @@ -1061,7 +1036,7 @@ sphinx = ">=6,<8" [package.extras] code-style = ["pre-commit (>=3.0,<4.0)"] linkify = ["linkify-it-py (>=2.0,<3.0)"] -rtd = ["ipython", "pydata-sphinx-theme (==0.13.0rc4)", "sphinx-autodoc2 (>=0.4.2,<0.5.0)", "sphinx-book-theme (==1.0.0rc2)", "sphinx-copybutton", "sphinx-design2", "sphinx-pyscript", "sphinx-tippy (>=0.3.1)", "sphinx-togglebutton", "sphinxext-opengraph (>=0.8.2,<0.9.0)", "sphinxext-rediraffe (>=0.2.7,<0.3.0)"] +rtd = ["ipython", "pydata-sphinx-theme (==v0.13.0rc4)", "sphinx-autodoc2 (>=0.4.2,<0.5.0)", "sphinx-book-theme (==1.0.0rc2)", "sphinx-copybutton", "sphinx-design2", "sphinx-pyscript", "sphinx-tippy (>=0.3.1)", "sphinx-togglebutton", "sphinxext-opengraph (>=0.8.2,<0.9.0)", "sphinxext-rediraffe (>=0.2.7,<0.3.0)"] testing = ["beautifulsoup4", "coverage[toml]", "pytest (>=7,<8)", "pytest-cov", "pytest-param-files (>=0.3.4,<0.4.0)", "pytest-regressions", "sphinx-pytest"] testing-docutils = ["pygments", "pytest (>=7,<8)", "pytest-param-files (>=0.3.4,<0.4.0)"] @@ -1221,12 +1196,10 @@ files = [ [package.dependencies] colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} iniconfig = ">=1.0.1" packaging = ">=22" pluggy = ">=1.5,<2" pygments = ">=2.7.2" -tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] @@ -1479,7 +1452,6 @@ files = [ click = ">=8" colorama = {version = "*", markers = "platform_system == \"Windows\""} rich = ">=12" -typing-extensions = {version = ">=4", markers = "python_version < \"3.11\""} [[package]] name = "setuptools" @@ -1555,7 +1527,6 @@ sphinxcontrib-htmlhelp = ">=2.0.0" sphinxcontrib-jsmath = "*" sphinxcontrib-qthelp = "*" sphinxcontrib-serializinghtml = ">=1.1.9" -tomli = {version = ">=2", markers = "python_version < \"3.11\""} [package.extras] docs = ["sphinxcontrib-websupport"] @@ -1825,76 +1796,17 @@ files = [ [package.extras] tests = ["pytest", "pytest-cov"] -[[package]] -name = "tomli" -version = "2.4.1" -description = "A lil' TOML parser" -optional = false -python-versions = ">=3.8" -groups = ["dev", "docs"] -files = [ - {file = "tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30"}, - {file = "tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a"}, - {file = "tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076"}, - {file = "tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9"}, - {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c"}, - {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc"}, - {file = "tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049"}, - {file = "tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e"}, - {file = "tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece"}, - {file = "tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a"}, - {file = "tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085"}, - {file = "tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9"}, - {file = "tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5"}, - {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585"}, - {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1"}, - {file = "tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917"}, - {file = "tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9"}, - {file = "tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257"}, - {file = "tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54"}, - {file = "tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a"}, - {file = "tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897"}, - {file = "tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f"}, - {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d"}, - {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5"}, - {file = "tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd"}, - {file = "tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36"}, - {file = "tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd"}, - {file = "tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf"}, - {file = "tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac"}, - {file = "tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662"}, - {file = "tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853"}, - {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15"}, - {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba"}, - {file = "tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6"}, - {file = "tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7"}, - {file = "tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232"}, - {file = "tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4"}, - {file = "tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c"}, - {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d"}, - {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41"}, - {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c"}, - {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f"}, - {file = "tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8"}, - {file = "tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26"}, - {file = "tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396"}, - {file = "tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe"}, - {file = "tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f"}, -] -markers = {dev = "python_full_version <= \"3.11.0a6\"", docs = "python_version == \"3.10\""} - [[package]] name = "typing-extensions" version = "4.16.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" -groups = ["main", "dev", "docs"] +groups = ["dev", "docs"] files = [ {file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"}, {file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"}, ] -markers = {main = "python_version == \"3.10\""} [[package]] name = "urllib3" @@ -1931,7 +1843,6 @@ distlib = ">=0.3.7,<1" filelock = {version = ">=3.24.2,<4", markers = "python_version >= \"3.10\""} platformdirs = ">=3.9.1,<5" python-discovery = ">=1.6" -typing-extensions = {version = ">=4.13.2", markers = "python_version < \"3.11\""} [[package]] name = "wheel" @@ -1950,5 +1861,5 @@ packaging = ">=24.0" [metadata] lock-version = "2.1" -python-versions = "^3.10" -content-hash = "df980786fb1dc46f21802c0347b960faac783f7746f546f8591f610b6ebd1ee2" +python-versions = "^3.11" +content-hash = "da9d25d2456be9c7ed1b7030130ddedc8ee52cb6c06c8d10882c2aef1863b8d7" diff --git a/pyproject.toml b/pyproject.toml index ccaa2b9..4b51b0c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ documentation = "https://lambda-feedback.github.io/in2lambda" "Bug Tracker" = "https://github.com/lambda-feedback/in2lambda/issues" [tool.poetry.dependencies] -python = "^3.10" +python = "^3.11" panflute = "^2.3.1" rich-click = "^1.7.4" beartype = "^0.17.2" diff --git a/tests/test_defaults.py b/tests/test_defaults.py new file mode 100644 index 0000000..8547a19 --- /dev/null +++ b/tests/test_defaults.py @@ -0,0 +1,59 @@ +"""A user's TOML file overrides in2lambda's defaults, and a mistake in it is refused.""" + +import copy + +import pytest + +from in2lambda import defaults +from in2lambda.api.question import Question +from in2lambda.api.response_area import MathMultiLines, MathSingleLine + + +@pytest.fixture(autouse=True) +def packaged_defaults(monkeypatch) -> None: + monkeypatch.setattr(defaults, "_DEFAULTS", copy.deepcopy(defaults._DEFAULTS)) + + +def test_a_users_file_overrides_only_what_it_sets(tmp_path) -> None: + ours = tmp_path / "ours.toml" + ours.write_text( + "[question]\npublish = false\n" + '[response_area.MATH_MULTI_LINES]\nevaluation_function = "evaluateProof"\n' + ) + defaults.override(str(ours)) + + assert Question().publish is False + assert Question().display_chatbot is True + assert MathMultiLines().evaluation_function == "evaluateProof" + assert MathSingleLine().evaluation_function == "compareExpressions" + + +@pytest.mark.parametrize( + "text", + [ + "[question]\npublsh = false\n", + "[response_area.GRAPH]\nlive_preview = true\n", + '[question]\npublish = "no"\n', + '[set]\nchatbot_visibility = "SHOWN"\n', + "[question]\npublish = true\npublish = false\n", + ], + ids=["unknown key", "unknown type", "wrong type", "unknown visibility", "repeat"], +) +def test_a_users_file_with_a_mistake_is_refused(tmp_path, text: str) -> None: + ours = tmp_path / "ours.toml" + ours.write_text(text) + with pytest.raises(ValueError): + defaults.override(str(ours)) + + +def test_a_refused_file_changes_nothing(tmp_path) -> None: + ours = tmp_path / "ours.toml" + ours.write_text("[question]\npublish = false\npublsh = false\n") + with pytest.raises(ValueError): + defaults.override(str(ours)) + assert Question().publish is True + + +def test_a_type_without_a_default_evaluation_function_needs_one() -> None: + with pytest.raises(TypeError): + MathMultiLines() diff --git a/tests/test_export_keys.py b/tests/test_export_keys.py index c87e924..eaadb08 100644 --- a/tests/test_export_keys.py +++ b/tests/test_export_keys.py @@ -9,10 +9,16 @@ import json from pathlib import Path -from in2lambda.api import response_area from in2lambda.api.part import Part from in2lambda.api.question import Question -from in2lambda.api.response_area import Case, Color, InputSymbol, MathSingleLine, Test +from in2lambda.api.response_area import ( + RESPONSE_TYPES, + Case, + Color, + InputSymbol, + MathSingleLine, + Test, +) from in2lambda.api.set import Set EXPORTS = sorted((Path(__file__).parent / "fixtures" / "exports").glob("*/")) @@ -79,17 +85,11 @@ def test_a_set_using_every_field_writes_the_keys_of_a_real_export(tmp_path) -> N def test_each_response_type_writes_the_settings_of_a_real_export() -> None: - def subclasses(cls): - return [s for c in cls.__subclasses__() for s in [c, *subclasses(c)]] - - types = { - cls.response_type: cls - for cls in subclasses(response_area.ResponseArea) - if "response_type" in vars(cls) - } for question in _exported("question_*.json"): for part in question["parts"]: for area in part["responseAreas"]: response = area["response"]["responseInput"] - written = types[response["responseType"]](evaluation_function="") + written = RESPONSE_TYPES[response["responseType"]]( + evaluation_function="" + ) assert set(written._config() or {}) == set(response["config"] or {})