From 562f2a4e070c7fe6ea7e28eee7a6319e6b2afa4f Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Sun, 25 Oct 2026 17:22:49 +0000 Subject: [PATCH 1/4] Require Python 3.11 Python 3.10 reached its end of life in October 2026, and 3.11 reads TOML without a further dependency. The tests run on 3.11 and 3.12. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test.yml | 2 +- poetry.lock | 101 +++---------------------------------- pyproject.toml | 2 +- 3 files changed, 8 insertions(+), 97 deletions(-) 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/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" From 3e973696de83acac4de250e7a60d04e82095666d Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Sun, 25 Oct 2026 17:22:58 +0000 Subject: [PATCH 2/4] Write set and question JSON without templates The two JSON templates held defaults of their own, which every write then overwrote, and a placeholder part ("Part text here") that a question without parts was written with. The writer now builds the JSON from the set and its questions alone, and a question without parts is written with none. Co-Authored-By: Claude Opus 5.5 --- in2lambda/json_convert/json_convert.py | 85 ++++++------------- .../minimal_template_question.json | 22 ----- .../json_convert/minimal_template_set.json | 9 -- 3 files changed, 27 insertions(+), 89 deletions(-) delete mode 100644 in2lambda/json_convert/minimal_template_question.json delete mode 100644 in2lambda/json_convert/minimal_template_set.json 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 From a70afef2f7f2c6fdcbb6f53058c51db9a021e88e Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Sun, 25 Oct 2026 17:23:36 +0000 Subject: [PATCH 3/4] Keep every default in one file, defaults.toml The defaults of the set, the question, every response area and each response type were spread across the classes and the JSON templates. They are now in in2lambda/defaults.toml, which fills in each setting an author leaves unset; a response type's own table comes before the one for every response area. A response type without a default evaluation function needs one from the author. The defaults change: questions are published with the chatbot shown, the set's chatbot is open, and sets are no longer hidden on import. Response areas show a live preview and are included in the PDF, multi-line maths aside, and single-line maths shows its input symbols. Co-Authored-By: Claude Opus 5.5 --- in2lambda/api/question.py | 19 ++++-- in2lambda/api/response_area.py | 92 +++++++++++++++++------------ in2lambda/api/set.py | 33 +++++------ in2lambda/defaults.py | 61 ++++++++++++++++++++ in2lambda/defaults.toml | 102 +++++++++++++++++++++++++++++++++ in2lambda/main.py | 4 +- tests/test_defaults.py | 10 ++++ tests/test_export_keys.py | 22 +++---- 8 files changed, 268 insertions(+), 75 deletions(-) create mode 100644 in2lambda/defaults.py create mode 100644 in2lambda/defaults.toml create mode 100644 tests/test_defaults.py 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..14802ee --- /dev/null +++ b/in2lambda/defaults.py @@ -0,0 +1,61 @@ +"""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: + """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..07f482a --- /dev/null +++ b/in2lambda/defaults.toml @@ -0,0 +1,102 @@ +# in2lambda's defaults: what it writes for a setting the author leaves unset. + +[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/main.py b/in2lambda/main.py index 76f0c63..82ea948 100644 --- a/in2lambda/main.py +++ b/in2lambda/main.py @@ -100,9 +100,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() diff --git a/tests/test_defaults.py b/tests/test_defaults.py new file mode 100644 index 0000000..cb21877 --- /dev/null +++ b/tests/test_defaults.py @@ -0,0 +1,10 @@ +"""in2lambda's defaults fill in the settings an author leaves unset.""" + +import pytest + +from in2lambda.api.response_area import MathMultiLines + + +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 {}) From 0f7e8f0f93597bfd60da829c34fed9567a7c3d0d Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Sun, 25 Oct 2026 17:23:55 +0000 Subject: [PATCH 4/4] Let users override the defaults with their own TOML file in2lambda.defaults.override(path), or `in2lambda --defaults path`, lays a TOML file holding only the values to change over in2lambda's defaults. A file that is not valid TOML, or that sets something in2lambda has no default for or a value of the wrong type, is refused and changes nothing. The quickstart says how, and shows in2lambda's defaults.toml as it stands. Co-Authored-By: Claude Opus 5.5 --- docs/source/quickstart.md | 25 ++++++++++++++++++ in2lambda/defaults.py | 55 +++++++++++++++++++++++++++++++++++++++ in2lambda/defaults.toml | 4 +++ in2lambda/main.py | 16 +++++++++++- tests/test_defaults.py | 53 +++++++++++++++++++++++++++++++++++-- 5 files changed, 150 insertions(+), 3 deletions(-) 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/defaults.py b/in2lambda/defaults.py index 14802ee..9877ab2 100644 --- a/in2lambda/defaults.py +++ b/in2lambda/defaults.py @@ -28,6 +28,61 @@ def _key(name: str) -> str: 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. diff --git a/in2lambda/defaults.toml b/in2lambda/defaults.toml index 07f482a..c8df652 100644 --- a/in2lambda/defaults.toml +++ b/in2lambda/defaults.toml @@ -1,4 +1,8 @@ # 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. diff --git a/in2lambda/main.py b/in2lambda/main.py index 82ea948..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 @@ -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/tests/test_defaults.py b/tests/test_defaults.py index cb21877..8547a19 100644 --- a/tests/test_defaults.py +++ b/tests/test_defaults.py @@ -1,8 +1,57 @@ -"""in2lambda's defaults fill in the settings an author leaves unset.""" +"""A user's TOML file overrides in2lambda's defaults, and a mistake in it is refused.""" + +import copy import pytest -from in2lambda.api.response_area import MathMultiLines +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: