From 7d1909a75c77cb8204c6b64467bce05236358300 Mon Sep 17 00:00:00 2001 From: Maximilian Haye Date: Thu, 30 May 2024 18:19:19 +0200 Subject: [PATCH] feat!: new interface / wrapper object api --- .../python/local/full_example/__init__.py | 8 +- .../local/full_example/question_type.py | 23 +- examples/full/qpy_config.yml | 4 +- .../python/local/minimal_example/__init__.py | 7 +- .../local/minimal_example/question_type.py | 29 +-- .../local/static_files_example/__init__.py | 8 +- .../static_files_example/question_type.py | 25 +- poetry.lock | 165 ++++++++------ pyproject.toml | 2 +- questionpy/__init__.py | 31 ++- questionpy/_attempt.py | 127 ++++++++--- questionpy/_qtype.py | 215 ++++++++---------- questionpy/_ui.py | 18 +- questionpy/_wrappers/__init__.py | 8 + questionpy/_wrappers/_qtype.py | 37 +++ questionpy/_wrappers/_question.py | 121 ++++++++++ questionpy_sdk/webserver/attempt.py | 5 +- tests/conftest.py | 30 ++- tests/questionpy/__init__.py | 0 tests/questionpy/wrappers/__init__.py | 3 + tests/questionpy/wrappers/conftest.py | 105 +++++++++ tests/questionpy/wrappers/test_qtype.py | 62 +++++ tests/questionpy/wrappers/test_question.py | 105 +++++++++ tests/test_qtype.py | 163 ------------- tests/webserver/test_page.py | 28 +-- 25 files changed, 827 insertions(+), 502 deletions(-) create mode 100644 questionpy/_wrappers/__init__.py create mode 100644 questionpy/_wrappers/_qtype.py create mode 100644 questionpy/_wrappers/_question.py create mode 100644 tests/questionpy/__init__.py create mode 100644 tests/questionpy/wrappers/__init__.py create mode 100644 tests/questionpy/wrappers/conftest.py create mode 100644 tests/questionpy/wrappers/test_qtype.py create mode 100644 tests/questionpy/wrappers/test_question.py delete mode 100644 tests/test_qtype.py diff --git a/examples/full/python/local/full_example/__init__.py b/examples/full/python/local/full_example/__init__.py index b288c695..1c1d685b 100644 --- a/examples/full/python/local/full_example/__init__.py +++ b/examples/full/python/local/full_example/__init__.py @@ -1,5 +1,7 @@ -from .question_type import ExampleQuestionType +from questionpy import Package, QuestionTypeWrapper +from .question_type import ExampleQuestion -def init() -> ExampleQuestionType: - return ExampleQuestionType() + +def init(package: Package) -> QuestionTypeWrapper: + return QuestionTypeWrapper(ExampleQuestion, package) diff --git a/examples/full/python/local/full_example/question_type.py b/examples/full/python/local/full_example/question_type.py index ff0c1bed..d6335f18 100644 --- a/examples/full/python/local/full_example/question_type.py +++ b/examples/full/python/local/full_example/question_type.py @@ -1,29 +1,18 @@ -from questionpy import Attempt, AttemptUiPart, Question, QuestionType -from questionpy_common.api.attempt import ScoreModel, ScoringCode -from questionpy_common.api.question import QuestionModel, ScoringMethod +from questionpy import Attempt, Question from .form import MyModel class ExampleAttempt(Attempt): - def export_score(self) -> ScoreModel: - return ScoreModel(scoring_code=ScoringCode.AUTOMATICALLY_SCORED, score=0) + def _compute_score(self) -> float: + return 0 - def render_formulation(self) -> AttemptUiPart: - ui_part = AttemptUiPart(content=self.jinja2.get_template("local.full_example/formulation.xhtml.j2").render()) - # TODO: implement call_js method - # ui_part.call_js("main", "init") # noqa: ERA001 - return ui_part # noqa: RET504 + @property + def formulation(self) -> str: + return self.jinja2.get_template("local.full_example/formulation.xhtml.j2").render() class ExampleQuestion(Question): attempt_class = ExampleAttempt options: MyModel - - def export(self) -> QuestionModel: - return QuestionModel(scoring_method=ScoringMethod.AUTOMATICALLY_SCORABLE) - - -class ExampleQuestionType(QuestionType): - question_class = ExampleQuestion diff --git a/examples/full/qpy_config.yml b/examples/full/qpy_config.yml index 525825de..16154274 100644 --- a/examples/full/qpy_config.yml +++ b/examples/full/qpy_config.yml @@ -8,4 +8,6 @@ name: en: Full Example languages: [de, en] build_hooks: - pre: npm run build + pre: + - npm ci + - npm run build diff --git a/examples/minimal/python/local/minimal_example/__init__.py b/examples/minimal/python/local/minimal_example/__init__.py index b8b89a1a..1dd139e9 100644 --- a/examples/minimal/python/local/minimal_example/__init__.py +++ b/examples/minimal/python/local/minimal_example/__init__.py @@ -1,9 +1,10 @@ # This file is part of the QuestionPy SDK. (https://questionpy.org) # The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md. # (c) Technische Universität Berlin, innoCampus +from questionpy import Package, QuestionTypeWrapper -from .question_type import ExampleQuestionType +from .question_type import ExampleQuestion -def init() -> ExampleQuestionType: - return ExampleQuestionType() +def init(package: Package) -> QuestionTypeWrapper: + return QuestionTypeWrapper(ExampleQuestion, package) diff --git a/examples/minimal/python/local/minimal_example/question_type.py b/examples/minimal/python/local/minimal_example/question_type.py index 3eb6df40..8b4c97db 100644 --- a/examples/minimal/python/local/minimal_example/question_type.py +++ b/examples/minimal/python/local/minimal_example/question_type.py @@ -1,35 +1,26 @@ -from questionpy import Attempt, AttemptUiPart, Question, QuestionType, ScoringCode -from questionpy_common.api.attempt import ScoreModel -from questionpy_common.api.question import QuestionModel, ScoringMethod +from questionpy import Attempt, Question, ResponseNotScorableError from .form import MyModel class ExampleAttempt(Attempt): - def export_score(self) -> ScoreModel: + def _compute_score(self) -> float: if not self.response or "choice" not in self.response: - return ScoreModel(scoring_code=ScoringCode.RESPONSE_NOT_SCORABLE, score=None) + msg = "'choice' is missing" + raise ResponseNotScorableError(msg) if self.response["choice"] == "B": - return ScoreModel(scoring_code=ScoringCode.AUTOMATICALLY_SCORED, score=1) + return 1 - return ScoreModel(scoring_code=ScoringCode.AUTOMATICALLY_SCORED, score=0) + return 0 - def render_formulation(self) -> AttemptUiPart: - return AttemptUiPart( - content=self.jinja2.get_template("local.minimal_example/formulation.xhtml.j2").render(), - placeholders={"description": "Welcher ist der zweite Buchstabe im deutschen Alphabet?"}, - ) + @property + def formulation(self) -> str: + self.placeholders["description"] = "Welcher ist der zweite Buchstabe im deutschen Alphabet?" + return self.jinja2.get_template("local.minimal_example/formulation.xhtml.j2").render() class ExampleQuestion(Question): attempt_class = ExampleAttempt options: MyModel - - def export(self) -> QuestionModel: - return QuestionModel(scoring_method=ScoringMethod.AUTOMATICALLY_SCORABLE) - - -class ExampleQuestionType(QuestionType): - question_class = ExampleQuestion diff --git a/examples/static-files/python/local/static_files_example/__init__.py b/examples/static-files/python/local/static_files_example/__init__.py index b288c695..1c1d685b 100644 --- a/examples/static-files/python/local/static_files_example/__init__.py +++ b/examples/static-files/python/local/static_files_example/__init__.py @@ -1,5 +1,7 @@ -from .question_type import ExampleQuestionType +from questionpy import Package, QuestionTypeWrapper +from .question_type import ExampleQuestion -def init() -> ExampleQuestionType: - return ExampleQuestionType() + +def init(package: Package) -> QuestionTypeWrapper: + return QuestionTypeWrapper(ExampleQuestion, package) diff --git a/examples/static-files/python/local/static_files_example/question_type.py b/examples/static-files/python/local/static_files_example/question_type.py index 414d515e..b8c40c36 100644 --- a/examples/static-files/python/local/static_files_example/question_type.py +++ b/examples/static-files/python/local/static_files_example/question_type.py @@ -1,31 +1,18 @@ -from questionpy import Attempt, AttemptUiPart, Question, QuestionType -from questionpy_common.api.attempt import ScoreModel, ScoringCode -from questionpy_common.api.question import QuestionModel, ScoringMethod +from questionpy import Attempt, Question from .form import MyModel class ExampleAttempt(Attempt): - def export_score(self) -> ScoreModel: - return ScoreModel(scoring_code=ScoringCode.AUTOMATICALLY_SCORED, score=0) + def _compute_score(self) -> float: + return 0 - def render_formulation(self) -> AttemptUiPart: - ui_part = AttemptUiPart( - content=self.jinja2.get_template("local.static_files_example/formulation.xhtml.j2").render() - ) - # TODO: implement call_js method - # ui_part.call_js("test", "init", {"data": "Hello world!"}) # noqa: ERA001 - return ui_part # noqa: RET504 + @property + def formulation(self) -> str: + return self.jinja2.get_template("local.static_files_example/formulation.xhtml.j2").render() class ExampleQuestion(Question): attempt_class = ExampleAttempt options: MyModel - - def export(self) -> QuestionModel: - return QuestionModel(scoring_method=ScoringMethod.AUTOMATICALLY_SCORABLE) - - -class ExampleQuestionType(QuestionType): - question_class = ExampleQuestion diff --git a/poetry.lock b/poetry.lock index 6bed258f..14ec09cd 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. [[package]] name = "aiohttp" @@ -256,63 +256,63 @@ files = [ [[package]] name = "coverage" -version = "7.5.3" +version = "7.5.4" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.8" files = [ - {file = "coverage-7.5.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a6519d917abb15e12380406d721e37613e2a67d166f9fb7e5a8ce0375744cd45"}, - {file = "coverage-7.5.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:aea7da970f1feccf48be7335f8b2ca64baf9b589d79e05b9397a06696ce1a1ec"}, - {file = "coverage-7.5.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:923b7b1c717bd0f0f92d862d1ff51d9b2b55dbbd133e05680204465f454bb286"}, - {file = "coverage-7.5.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62bda40da1e68898186f274f832ef3e759ce929da9a9fd9fcf265956de269dbc"}, - {file = "coverage-7.5.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8b7339180d00de83e930358223c617cc343dd08e1aa5ec7b06c3a121aec4e1d"}, - {file = "coverage-7.5.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:25a5caf742c6195e08002d3b6c2dd6947e50efc5fc2c2205f61ecb47592d2d83"}, - {file = "coverage-7.5.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:05ac5f60faa0c704c0f7e6a5cbfd6f02101ed05e0aee4d2822637a9e672c998d"}, - {file = "coverage-7.5.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:239a4e75e09c2b12ea478d28815acf83334d32e722e7433471fbf641c606344c"}, - {file = "coverage-7.5.3-cp310-cp310-win32.whl", hash = "sha256:a5812840d1d00eafae6585aba38021f90a705a25b8216ec7f66aebe5b619fb84"}, - {file = "coverage-7.5.3-cp310-cp310-win_amd64.whl", hash = "sha256:33ca90a0eb29225f195e30684ba4a6db05dbef03c2ccd50b9077714c48153cac"}, - {file = "coverage-7.5.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f81bc26d609bf0fbc622c7122ba6307993c83c795d2d6f6f6fd8c000a770d974"}, - {file = "coverage-7.5.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7cec2af81f9e7569280822be68bd57e51b86d42e59ea30d10ebdbb22d2cb7232"}, - {file = "coverage-7.5.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55f689f846661e3f26efa535071775d0483388a1ccfab899df72924805e9e7cd"}, - {file = "coverage-7.5.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50084d3516aa263791198913a17354bd1dc627d3c1639209640b9cac3fef5807"}, - {file = "coverage-7.5.3-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:341dd8f61c26337c37988345ca5c8ccabeff33093a26953a1ac72e7d0103c4fb"}, - {file = "coverage-7.5.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ab0b028165eea880af12f66086694768f2c3139b2c31ad5e032c8edbafca6ffc"}, - {file = "coverage-7.5.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:5bc5a8c87714b0c67cfeb4c7caa82b2d71e8864d1a46aa990b5588fa953673b8"}, - {file = "coverage-7.5.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:38a3b98dae8a7c9057bd91fbf3415c05e700a5114c5f1b5b0ea5f8f429ba6614"}, - {file = "coverage-7.5.3-cp311-cp311-win32.whl", hash = "sha256:fcf7d1d6f5da887ca04302db8e0e0cf56ce9a5e05f202720e49b3e8157ddb9a9"}, - {file = "coverage-7.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:8c836309931839cca658a78a888dab9676b5c988d0dd34ca247f5f3e679f4e7a"}, - {file = "coverage-7.5.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:296a7d9bbc598e8744c00f7a6cecf1da9b30ae9ad51c566291ff1314e6cbbed8"}, - {file = "coverage-7.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:34d6d21d8795a97b14d503dcaf74226ae51eb1f2bd41015d3ef332a24d0a17b3"}, - {file = "coverage-7.5.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e317953bb4c074c06c798a11dbdd2cf9979dbcaa8ccc0fa4701d80042d4ebf1"}, - {file = "coverage-7.5.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:705f3d7c2b098c40f5b81790a5fedb274113373d4d1a69e65f8b68b0cc26f6db"}, - {file = "coverage-7.5.3-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1196e13c45e327d6cd0b6e471530a1882f1017eb83c6229fc613cd1a11b53cd"}, - {file = "coverage-7.5.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:015eddc5ccd5364dcb902eaecf9515636806fa1e0d5bef5769d06d0f31b54523"}, - {file = "coverage-7.5.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:fd27d8b49e574e50caa65196d908f80e4dff64d7e592d0c59788b45aad7e8b35"}, - {file = "coverage-7.5.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:33fc65740267222fc02975c061eb7167185fef4cc8f2770267ee8bf7d6a42f84"}, - {file = "coverage-7.5.3-cp312-cp312-win32.whl", hash = "sha256:7b2a19e13dfb5c8e145c7a6ea959485ee8e2204699903c88c7d25283584bfc08"}, - {file = "coverage-7.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0bbddc54bbacfc09b3edaec644d4ac90c08ee8ed4844b0f86227dcda2d428fcb"}, - {file = "coverage-7.5.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f78300789a708ac1f17e134593f577407d52d0417305435b134805c4fb135adb"}, - {file = "coverage-7.5.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b368e1aee1b9b75757942d44d7598dcd22a9dbb126affcbba82d15917f0cc155"}, - {file = "coverage-7.5.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f836c174c3a7f639bded48ec913f348c4761cbf49de4a20a956d3431a7c9cb24"}, - {file = "coverage-7.5.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:244f509f126dc71369393ce5fea17c0592c40ee44e607b6d855e9c4ac57aac98"}, - {file = "coverage-7.5.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c4c2872b3c91f9baa836147ca33650dc5c172e9273c808c3c3199c75490e709d"}, - {file = "coverage-7.5.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:dd4b3355b01273a56b20c219e74e7549e14370b31a4ffe42706a8cda91f19f6d"}, - {file = "coverage-7.5.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:f542287b1489c7a860d43a7d8883e27ca62ab84ca53c965d11dac1d3a1fab7ce"}, - {file = "coverage-7.5.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:75e3f4e86804023e991096b29e147e635f5e2568f77883a1e6eed74512659ab0"}, - {file = "coverage-7.5.3-cp38-cp38-win32.whl", hash = "sha256:c59d2ad092dc0551d9f79d9d44d005c945ba95832a6798f98f9216ede3d5f485"}, - {file = "coverage-7.5.3-cp38-cp38-win_amd64.whl", hash = "sha256:fa21a04112c59ad54f69d80e376f7f9d0f5f9123ab87ecd18fbb9ec3a2beed56"}, - {file = "coverage-7.5.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f5102a92855d518b0996eb197772f5ac2a527c0ec617124ad5242a3af5e25f85"}, - {file = "coverage-7.5.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d1da0a2e3b37b745a2b2a678a4c796462cf753aebf94edcc87dcc6b8641eae31"}, - {file = "coverage-7.5.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8383a6c8cefba1b7cecc0149415046b6fc38836295bc4c84e820872eb5478b3d"}, - {file = "coverage-7.5.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9aad68c3f2566dfae84bf46295a79e79d904e1c21ccfc66de88cd446f8686341"}, - {file = "coverage-7.5.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e079c9ec772fedbade9d7ebc36202a1d9ef7291bc9b3a024ca395c4d52853d7"}, - {file = "coverage-7.5.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bde997cac85fcac227b27d4fb2c7608a2c5f6558469b0eb704c5726ae49e1c52"}, - {file = "coverage-7.5.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:990fb20b32990b2ce2c5f974c3e738c9358b2735bc05075d50a6f36721b8f303"}, - {file = "coverage-7.5.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3d5a67f0da401e105753d474369ab034c7bae51a4c31c77d94030d59e41df5bd"}, - {file = "coverage-7.5.3-cp39-cp39-win32.whl", hash = "sha256:e08c470c2eb01977d221fd87495b44867a56d4d594f43739a8028f8646a51e0d"}, - {file = "coverage-7.5.3-cp39-cp39-win_amd64.whl", hash = "sha256:1d2a830ade66d3563bb61d1e3c77c8def97b30ed91e166c67d0632c018f380f0"}, - {file = "coverage-7.5.3-pp38.pp39.pp310-none-any.whl", hash = "sha256:3538d8fb1ee9bdd2e2692b3b18c22bb1c19ffbefd06880f5ac496e42d7bb3884"}, - {file = "coverage-7.5.3.tar.gz", hash = "sha256:04aefca5190d1dc7a53a4c1a5a7f8568811306d7a8ee231c42fb69215571944f"}, + {file = "coverage-7.5.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6cfb5a4f556bb51aba274588200a46e4dd6b505fb1a5f8c5ae408222eb416f99"}, + {file = "coverage-7.5.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2174e7c23e0a454ffe12267a10732c273243b4f2d50d07544a91198f05c48f47"}, + {file = "coverage-7.5.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2214ee920787d85db1b6a0bd9da5f8503ccc8fcd5814d90796c2f2493a2f4d2e"}, + {file = "coverage-7.5.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1137f46adb28e3813dec8c01fefadcb8c614f33576f672962e323b5128d9a68d"}, + {file = "coverage-7.5.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b385d49609f8e9efc885790a5a0e89f2e3ae042cdf12958b6034cc442de428d3"}, + {file = "coverage-7.5.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b4a474f799456e0eb46d78ab07303286a84a3140e9700b9e154cfebc8f527016"}, + {file = "coverage-7.5.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5cd64adedf3be66f8ccee418473c2916492d53cbafbfcff851cbec5a8454b136"}, + {file = "coverage-7.5.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e564c2cf45d2f44a9da56f4e3a26b2236504a496eb4cb0ca7221cd4cc7a9aca9"}, + {file = "coverage-7.5.4-cp310-cp310-win32.whl", hash = "sha256:7076b4b3a5f6d2b5d7f1185fde25b1e54eb66e647a1dfef0e2c2bfaf9b4c88c8"}, + {file = "coverage-7.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:018a12985185038a5b2bcafab04ab833a9a0f2c59995b3cec07e10074c78635f"}, + {file = "coverage-7.5.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:db14f552ac38f10758ad14dd7b983dbab424e731588d300c7db25b6f89e335b5"}, + {file = "coverage-7.5.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3257fdd8e574805f27bb5342b77bc65578e98cbc004a92232106344053f319ba"}, + {file = "coverage-7.5.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a6612c99081d8d6134005b1354191e103ec9705d7ba2754e848211ac8cacc6b"}, + {file = "coverage-7.5.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d45d3cbd94159c468b9b8c5a556e3f6b81a8d1af2a92b77320e887c3e7a5d080"}, + {file = "coverage-7.5.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed550e7442f278af76d9d65af48069f1fb84c9f745ae249c1a183c1e9d1b025c"}, + {file = "coverage-7.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a892be37ca35eb5019ec85402c3371b0f7cda5ab5056023a7f13da0961e60da"}, + {file = "coverage-7.5.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8192794d120167e2a64721d88dbd688584675e86e15d0569599257566dec9bf0"}, + {file = "coverage-7.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:820bc841faa502e727a48311948e0461132a9c8baa42f6b2b84a29ced24cc078"}, + {file = "coverage-7.5.4-cp311-cp311-win32.whl", hash = "sha256:6aae5cce399a0f065da65c7bb1e8abd5c7a3043da9dceb429ebe1b289bc07806"}, + {file = "coverage-7.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:d2e344d6adc8ef81c5a233d3a57b3c7d5181f40e79e05e1c143da143ccb6377d"}, + {file = "coverage-7.5.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:54317c2b806354cbb2dc7ac27e2b93f97096912cc16b18289c5d4e44fc663233"}, + {file = "coverage-7.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:042183de01f8b6d531e10c197f7f0315a61e8d805ab29c5f7b51a01d62782747"}, + {file = "coverage-7.5.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6bb74ed465d5fb204b2ec41d79bcd28afccf817de721e8a807d5141c3426638"}, + {file = "coverage-7.5.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3d45ff86efb129c599a3b287ae2e44c1e281ae0f9a9bad0edc202179bcc3a2e"}, + {file = "coverage-7.5.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5013ed890dc917cef2c9f765c4c6a8ae9df983cd60dbb635df8ed9f4ebc9f555"}, + {file = "coverage-7.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1014fbf665fef86cdfd6cb5b7371496ce35e4d2a00cda501cf9f5b9e6fced69f"}, + {file = "coverage-7.5.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3684bc2ff328f935981847082ba4fdc950d58906a40eafa93510d1b54c08a66c"}, + {file = "coverage-7.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:581ea96f92bf71a5ec0974001f900db495488434a6928a2ca7f01eee20c23805"}, + {file = "coverage-7.5.4-cp312-cp312-win32.whl", hash = "sha256:73ca8fbc5bc622e54627314c1a6f1dfdd8db69788f3443e752c215f29fa87a0b"}, + {file = "coverage-7.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:cef4649ec906ea7ea5e9e796e68b987f83fa9a718514fe147f538cfeda76d7a7"}, + {file = "coverage-7.5.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cdd31315fc20868c194130de9ee6bfd99755cc9565edff98ecc12585b90be882"}, + {file = "coverage-7.5.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:02ff6e898197cc1e9fa375581382b72498eb2e6d5fc0b53f03e496cfee3fac6d"}, + {file = "coverage-7.5.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d05c16cf4b4c2fc880cb12ba4c9b526e9e5d5bb1d81313d4d732a5b9fe2b9d53"}, + {file = "coverage-7.5.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5986ee7ea0795a4095ac4d113cbb3448601efca7f158ec7f7087a6c705304e4"}, + {file = "coverage-7.5.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5df54843b88901fdc2f598ac06737f03d71168fd1175728054c8f5a2739ac3e4"}, + {file = "coverage-7.5.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:ab73b35e8d109bffbda9a3e91c64e29fe26e03e49addf5b43d85fc426dde11f9"}, + {file = "coverage-7.5.4-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:aea072a941b033813f5e4814541fc265a5c12ed9720daef11ca516aeacd3bd7f"}, + {file = "coverage-7.5.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:16852febd96acd953b0d55fc842ce2dac1710f26729b31c80b940b9afcd9896f"}, + {file = "coverage-7.5.4-cp38-cp38-win32.whl", hash = "sha256:8f894208794b164e6bd4bba61fc98bf6b06be4d390cf2daacfa6eca0a6d2bb4f"}, + {file = "coverage-7.5.4-cp38-cp38-win_amd64.whl", hash = "sha256:e2afe743289273209c992075a5a4913e8d007d569a406ffed0bd080ea02b0633"}, + {file = "coverage-7.5.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b95c3a8cb0463ba9f77383d0fa8c9194cf91f64445a63fc26fb2327e1e1eb088"}, + {file = "coverage-7.5.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3d7564cc09dd91b5a6001754a5b3c6ecc4aba6323baf33a12bd751036c998be4"}, + {file = "coverage-7.5.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:44da56a2589b684813f86d07597fdf8a9c6ce77f58976727329272f5a01f99f7"}, + {file = "coverage-7.5.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e16f3d6b491c48c5ae726308e6ab1e18ee830b4cdd6913f2d7f77354b33f91c8"}, + {file = "coverage-7.5.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dbc5958cb471e5a5af41b0ddaea96a37e74ed289535e8deca404811f6cb0bc3d"}, + {file = "coverage-7.5.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a04e990a2a41740b02d6182b498ee9796cf60eefe40cf859b016650147908029"}, + {file = "coverage-7.5.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ddbd2f9713a79e8e7242d7c51f1929611e991d855f414ca9996c20e44a895f7c"}, + {file = "coverage-7.5.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b1ccf5e728ccf83acd313c89f07c22d70d6c375a9c6f339233dcf792094bcbf7"}, + {file = "coverage-7.5.4-cp39-cp39-win32.whl", hash = "sha256:56b4eafa21c6c175b3ede004ca12c653a88b6f922494b023aeb1e836df953ace"}, + {file = "coverage-7.5.4-cp39-cp39-win_amd64.whl", hash = "sha256:65e528e2e921ba8fd67d9055e6b9f9e34b21ebd6768ae1c1723f4ea6ace1234d"}, + {file = "coverage-7.5.4-pp38.pp39.pp310-none-any.whl", hash = "sha256:79b356f3dd5b26f3ad23b35c75dbdaf1f9e2450b6bcefc6d0825ea0aa3f86ca5"}, + {file = "coverage-7.5.4.tar.gz", hash = "sha256:a44963520b069e12789d0faea4e9fdb1e410cdc4aab89d94f7f55cbb7fef0353"}, ] [package.extras] @@ -320,13 +320,13 @@ toml = ["tomli"] [[package]] name = "faker" -version = "25.8.0" +version = "25.9.1" description = "Faker is a Python package that generates fake data for you." optional = false python-versions = ">=3.8" files = [ - {file = "Faker-25.8.0-py3-none-any.whl", hash = "sha256:4c40b34a9c569018d4f9d6366d71a4da8a883d5ddf2b23197be5370f29b7e1b6"}, - {file = "Faker-25.8.0.tar.gz", hash = "sha256:bdec5f2fb057d244ebef6e0ed318fea4dcbdf32c3a1a010766fc45f5d68fc68d"}, + {file = "Faker-25.9.1-py3-none-any.whl", hash = "sha256:f1dc27dc8035cb7e97e96afbb5fe1305eed6aeea53374702cbac96acfe851626"}, + {file = "Faker-25.9.1.tar.gz", hash = "sha256:0e1cf7a8d3c94de91a65ab1e9cf7050903efae1e97901f8e5924a9f45147ae44"}, ] [package.dependencies] @@ -900,13 +900,13 @@ files = [ [[package]] name = "pydantic" -version = "2.7.3" +version = "2.7.4" description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic-2.7.3-py3-none-any.whl", hash = "sha256:ea91b002777bf643bb20dd717c028ec43216b24a6001a280f83877fd2655d0b4"}, - {file = "pydantic-2.7.3.tar.gz", hash = "sha256:c46c76a40bb1296728d7a8b99aa73dd70a48c3510111ff290034f860c99c419e"}, + {file = "pydantic-2.7.4-py3-none-any.whl", hash = "sha256:ee8538d41ccb9c0a9ad3e0e5f07bf15ed8015b481ced539a1759d8cc89ae90d0"}, + {file = "pydantic-2.7.4.tar.gz", hash = "sha256:0c84efd9548d545f63ac0060c1e4d39bb9b14db8b3c0652338aecc07b5adec52"}, ] [package.dependencies] @@ -1010,13 +1010,13 @@ typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" [[package]] name = "pydantic-settings" -version = "2.3.2" +version = "2.3.3" description = "Settings management using Pydantic" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic_settings-2.3.2-py3-none-any.whl", hash = "sha256:ae06e44349e4c7bff8d57aff415dfd397ae75c217a098d54e9e6990ad7594ac7"}, - {file = "pydantic_settings-2.3.2.tar.gz", hash = "sha256:05d33003c74c2cd585de97b59eb17b6ed67181bc8a3ce594d74b5d24e4df7323"}, + {file = "pydantic_settings-2.3.3-py3-none-any.whl", hash = "sha256:e4ed62ad851670975ec11285141db888fd24947f9440bd4380d7d8788d4965de"}, + {file = "pydantic_settings-2.3.3.tar.gz", hash = "sha256:87fda838b64b5039b970cd47c3e8a1ee460ce136278ff672980af21516f6e6ce"}, ] [package.dependencies] @@ -1200,7 +1200,7 @@ files = [ [[package]] name = "questionpy-server" -version = "0.2.5" +version = "0.3.0" description = "QuestionPy application server" optional = false python-versions = "^3.11" @@ -1220,8 +1220,8 @@ watchdog = "^4.0.0" [package.source] type = "git" url = "https://github.com/questionpy-org/questionpy-server.git" -reference = "763c15bcf3906ebb80d8d0bd9c15e842de4f8b0c" -resolved_reference = "763c15bcf3906ebb80d8d0bd9c15e842de4f8b0c" +reference = "59dfe63ece1e7584d92eedf6136c40518da66771" +resolved_reference = "59dfe63ece1e7584d92eedf6136c40518da66771" [[package]] name = "ruff" @@ -1251,13 +1251,13 @@ files = [ [[package]] name = "selenium" -version = "4.21.0" -description = "" +version = "4.22.0" +description = "Official Python bindings for Selenium WebDriver" optional = false python-versions = ">=3.8" files = [ - {file = "selenium-4.21.0-py3-none-any.whl", hash = "sha256:4770ffe5a5264e609de7dc914be6b89987512040d5a8efb2abb181330d097993"}, - {file = "selenium-4.21.0.tar.gz", hash = "sha256:650dbfa5159895ff00ad16e5ddb6ceecb86b90c7ed2012b3f041f64e6e4904fe"}, + {file = "selenium-4.22.0-py3-none-any.whl", hash = "sha256:e424991196e9857e19bf04fe5c1c0a4aac076794ff5e74615b1124e729d93104"}, + {file = "selenium-4.22.0.tar.gz", hash = "sha256:903c8c9d61b3eea6fcc9809dc7d9377e04e2ac87709876542cc8f863e482c4ce"}, ] [package.dependencies] @@ -1266,6 +1266,7 @@ trio = ">=0.17,<1.0" trio-websocket = ">=0.9,<1.0" typing_extensions = ">=4.9.0" urllib3 = {version = ">=1.26,<3", extras = ["socks"]} +websocket-client = ">=1.8.0" [[package]] name = "semver" @@ -1369,13 +1370,13 @@ files = [ [[package]] name = "urllib3" -version = "2.2.1" +version = "2.2.2" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.8" files = [ - {file = "urllib3-2.2.1-py3-none-any.whl", hash = "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d"}, - {file = "urllib3-2.2.1.tar.gz", hash = "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19"}, + {file = "urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472"}, + {file = "urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168"}, ] [package.dependencies] @@ -1431,6 +1432,22 @@ files = [ [package.extras] watchmedo = ["PyYAML (>=3.10)"] +[[package]] +name = "websocket-client" +version = "1.8.0" +description = "WebSocket client for Python with low level API options" +optional = false +python-versions = ">=3.8" +files = [ + {file = "websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526"}, + {file = "websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da"}, +] + +[package.extras] +docs = ["Sphinx (>=6.0)", "myst-parser (>=2.0.0)", "sphinx-rtd-theme (>=1.1.0)"] +optional = ["python-socks", "wsaccel"] +test = ["websockets"] + [[package]] name = "wsproto" version = "1.2.0" @@ -1551,4 +1568,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "f1da0050fb6b89dc4e61b25719cf0363b1a7e8052c8c8322732bdb2f5f8046d6" +content-hash = "38e86cfb8cff4139da5427255fc01a15f7f06cdab498599cc23aaa2445308176" diff --git a/pyproject.toml b/pyproject.toml index 8fb50139..bdfb9920 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ python = "^3.11" aiohttp = "^3.9.3" pydantic = "^2.6.4" PyYAML = "^6.0.1" -questionpy-server = { git = "https://github.com/questionpy-org/questionpy-server.git", rev = "763c15bcf3906ebb80d8d0bd9c15e842de4f8b0c" } +questionpy-server = { git = "https://github.com/questionpy-org/questionpy-server.git", rev = "59dfe63ece1e7584d92eedf6136c40518da66771" } jinja2 = "^3.1.3" aiohttp-jinja2 = "^1.6" lxml = "~5.1.0" diff --git a/questionpy/__init__.py b/questionpy/__init__.py index e822302a..60adb709 100644 --- a/questionpy/__init__.py +++ b/questionpy/__init__.py @@ -6,17 +6,17 @@ AttemptFile, AttemptModel, AttemptScoredModel, + AttemptStartedModel, AttemptUi, - BaseAttempt, CacheControl, ClassifiedResponse, ScoreModel, ScoringCode, ) -from questionpy_common.api.qtype import BaseQuestionType, OptionsFormValidationError +from questionpy_common.api.qtype import OptionsFormValidationError, QuestionTypeInterface from questionpy_common.api.question import ( - BaseQuestion, PossibleResponse, + QuestionInterface, QuestionModel, ScoringMethod, SubquestionModel, @@ -34,27 +34,36 @@ ) from questionpy_common.manifest import Manifest, PackageType, SourceManifest -from ._attempt import Attempt, AttemptUiPart, BaseAttemptState, BaseScoringState -from ._qtype import BaseQuestionState, Question, QuestionType +from ._attempt import ( + Attempt, + AttemptUiPart, + BaseAttemptState, + BaseScoringState, + InvalidResponseError, + NeedsManualScoringError, + ResponseNotScorableError, +) +from ._qtype import BaseQuestionState, Question from ._ui import create_jinja2_environment +from ._wrappers import QuestionTypeWrapper, QuestionWrapper __all__ = [ "Attempt", "AttemptFile", "AttemptModel", "AttemptScoredModel", + "AttemptStartedModel", "AttemptUi", "AttemptUiPart", - "BaseAttempt", "BaseAttemptState", - "BaseQuestion", "BaseQuestionState", - "BaseQuestionType", "BaseScoringState", "CacheControl", "ClassifiedResponse", "Environment", + "InvalidResponseError", "Manifest", + "NeedsManualScoringError", "NoEnvironmentError", "OnRequestCallback", "OptionsFormValidationError", @@ -63,9 +72,13 @@ "PackageType", "PossibleResponse", "Question", + "QuestionInterface", "QuestionModel", - "QuestionType", + "QuestionTypeInterface", + "QuestionTypeWrapper", + "QuestionWrapper", "RequestUser", + "ResponseNotScorableError", "ScoreModel", "ScoringCode", "ScoringMethod", diff --git a/questionpy/_attempt.py b/questionpy/_attempt.py index 0d3e9fa4..ddf9a04d 100644 --- a/questionpy/_attempt.py +++ b/questionpy/_attempt.py @@ -1,22 +1,15 @@ from abc import ABC, abstractmethod from collections.abc import Sequence from functools import cached_property -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar import jinja2 from pydantic import BaseModel -from questionpy_common.api.attempt import ( - AttemptFile, - AttemptModel, - AttemptScoredModel, - AttemptUi, - BaseAttempt, - CacheControl, - ScoreModel, -) +from questionpy_common.api.attempt import AttemptFile, AttemptUi, CacheControl, ScoredInputModel, ScoringCode from ._ui import create_jinja2_environment +from ._util import get_mro_type_hint if TYPE_CHECKING: from ._qtype import Question @@ -67,61 +60,123 @@ def _merge_uis( ) -class Attempt(BaseAttempt, ABC): +class Attempt(ABC): attempt_state: BaseAttemptState scoring_state: BaseScoringState | None + attempt_state_class: ClassVar[type[BaseAttemptState]] + scoring_state_class: ClassVar[type[BaseScoringState]] + + cache_control = CacheControl.PRIVATE_CACHE + def __init__( self, question: "Question", attempt_state: BaseAttemptState, - response: dict | None = None, scoring_state: BaseScoringState | None = None, + response: dict | None = None, ) -> None: self.question = question self.attempt_state = attempt_state self.response = response self.scoring_state = scoring_state + self.placeholders: dict[str, str] = {} + self.css_files: list[str] = [] + self.files: dict[str, AttemptFile] = {} + + self.scoring_code: ScoringCode | None = None + """When scoring is completed, set this to the outcome. + + This is set by :meth:`score_response` depending on if :meth:`_compute_score` and :meth:`_compute_final_score` + raise any errors. + + Note that when rescoring an attempt, the previous scoring information is not filled in and this field should + only be viewed as an output. + """ + self.scored_inputs: dict[str, ScoredInputModel] = {} + """Optionally, granular scores for the attempt's input fields can be added to this dict. + + Note that when rescoring an attempt, the previous scoring information is not filled in and this field should + only be viewed as an output. + """ + self.score: float | None = None + """Score calculated by :meth:`_score_response`. + + Note that when rescoring an attempt, the previous scoring information is not filled in and this field should + only be viewed as an output. + """ + self.score_final: float | None = None + """Score calculated by :meth:`_score_final_response`. + + Note that when rescoring an attempt, the previous scoring information is not filled in and this field should + only be viewed as an output. + """ + + @property @abstractmethod - def render_formulation(self) -> AttemptUiPart: + def formulation(self) -> str: pass - def render_general_feedback(self) -> AttemptUiPart | None: + @property + def general_feedback(self) -> str | None: return None - def render_specific_feedback(self) -> AttemptUiPart | None: + @property + def specific_feedback(self) -> str | None: return None - def render_right_answer_description(self) -> AttemptUiPart | None: + @property + def right_answer_description(self) -> str | None: return None - def render_ui(self) -> AttemptUi: - formulation = self.render_formulation() - general_feedback = self.render_general_feedback() - specific_feedback = self.render_specific_feedback() - right_answer = self.render_right_answer_description() + def score_response(self, *, try_scoring_with_countback: bool = False, try_giving_hint: bool = False) -> None: + try: + self.score = self._compute_score() + self.score_final = self._compute_final_score() + except _ScoringError as e: + self.scoring_code = e.scoring_code + else: + self.scoring_code = ScoringCode.AUTOMATICALLY_SCORED - return _merge_uis(formulation, general_feedback, specific_feedback, right_answer, self.cache_control) + @abstractmethod + def _compute_score(self) -> float: + pass - @property - def cache_control(self) -> CacheControl: - """Specifies if this attempt's UI may be cached and if that cache may be shared with other attempts.""" - return CacheControl.PRIVATE_CACHE + def _compute_final_score(self) -> float: + return self._compute_score() if self.score is None else self.score @cached_property def jinja2(self) -> jinja2.Environment: - return create_jinja2_environment(self, self.question, self.question.qtype) + return create_jinja2_environment(self, self.question) - def export(self) -> AttemptModel: - return AttemptModel(variant=self.attempt_state.variant, ui=self.render_ui()) + @property + def variant(self) -> int: + return self.attempt_state.variant - @abstractmethod - def export_score(self) -> ScoreModel: - pass + def __init_subclass__(cls, *args: object, **kwargs: object): + super().__init_subclass__(*args, **kwargs) + + cls.attempt_state_class = get_mro_type_hint(cls, "attempt_state", BaseAttemptState) + cls.scoring_state_class = get_mro_type_hint(cls, "scoring_state", BaseScoringState) + + +class _ScoringError(Exception): + def __init__(self, scoring_code: ScoringCode, *args: object) -> None: + self.scoring_code = scoring_code + super().__init__(*args) + + +class ResponseNotScorableError(_ScoringError): + def __init__(self, *args: object) -> None: + super().__init__(ScoringCode.RESPONSE_NOT_SCORABLE, *args) + + +class InvalidResponseError(_ScoringError): + def __init__(self, *args: object) -> None: + super().__init__(ScoringCode.INVALID_RESPONSE, *args) - def export_scored_attempt(self) -> AttemptScoredModel: - return AttemptScoredModel(**self.export().model_dump(), **self.export_score().model_dump()) - def export_attempt_state(self) -> str: - return self.attempt_state.model_dump_json() +class NeedsManualScoringError(_ScoringError): + def __init__(self, *args: object) -> None: + super().__init__(ScoringCode.NEEDS_MANUAL_SCORING, *args) diff --git a/questionpy/_qtype.py b/questionpy/_qtype.py index 841ea779..5cb3a11c 100644 --- a/questionpy/_qtype.py +++ b/questionpy/_qtype.py @@ -2,13 +2,13 @@ # The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md. # (c) Technische Universität Berlin, innoCampus from abc import ABC -from typing import Generic, TypeVar +from typing import ClassVar, Generic, Self, TypeVar from pydantic import BaseModel, ValidationError -from questionpy_common.api.attempt import BaseAttempt -from questionpy_common.api.qtype import BaseQuestionType, InvalidQuestionStateError, OptionsFormValidationError -from questionpy_common.api.question import BaseQuestion +from questionpy_common.api import PlainMapping +from questionpy_common.api.qtype import InvalidQuestionStateError, OptionsFormValidationError +from questionpy_common.api.question import ScoringMethod, SubquestionModel from questionpy_common.environment import get_qpy_environment from ._attempt import Attempt, BaseAttemptState, BaseScoringState @@ -16,158 +16,121 @@ from .form import FormModel, OptionsFormDefinition _F = TypeVar("_F", bound=FormModel) +_S = TypeVar("_S", bound="BaseQuestionState") -class BaseQuestionState(BaseModel, Generic[_F]): +class QuestionStateWithVersion(BaseModel, Generic[_F, _S]): package_name: str package_version: str options: _F + state: _S -class Question(BaseQuestion, ABC): - attempt_class: type["Attempt"] - - options: FormModel - state: BaseQuestionState - - def __init__(self, qtype: BaseQuestionType, state: BaseQuestionState) -> None: - self.qtype = qtype - self.state = state - - def start_attempt(self, variant: int) -> BaseAttempt: - attempt_state = get_mro_type_hint(self.attempt_class, "attempt_state", BaseAttemptState)(variant=variant) - return self.attempt_class(self, attempt_state) - - def get_attempt( - self, - attempt_state: str, - scoring_state: str | None = None, - response: dict | None = None, - *, - compute_score: bool = False, - generate_hint: bool = False, - ) -> BaseAttempt: - attempt_state_obj = get_mro_type_hint( - self.attempt_class, "attempt_state", BaseAttemptState - ).model_validate_json(attempt_state) - scoring_state_obj = None - if scoring_state is not None: - scoring_state_obj = get_mro_type_hint( - self.attempt_class, "scoring_state", BaseScoringState - ).model_validate_json(scoring_state) - return self.attempt_class(self, attempt_state_obj, response, scoring_state_obj) - - def export_question_state(self) -> str: - return self.state.model_dump_json() - - def __init_subclass__(cls, *args: object, **kwargs: object) -> None: - super().__init_subclass__(*args, **kwargs) - - if not hasattr(cls, "attempt_class"): - msg = f"Missing '{cls.__name__}.attempt_class' attribute. It should point to your attempt implementation" - raise TypeError(msg) - - state_class = _get_state_class(cls) - options_class = get_mro_type_hint(cls, "options", FormModel) - # We handle questions using the default state separately in create_question_from_state. - if state_class is not BaseQuestionState and options_class != state_class.model_fields["options"].annotation: - msg = f"{cls.__name__} must have the same FormModel as {state_class.__name__}." - raise TypeError(msg) - - @property # type: ignore[no-redef] - def options(self) -> FormModel: - return self.state.options - - @options.setter - def options(self, value: FormModel) -> None: - self.state.options = value - - -def _get_state_class(question_class: type[Question]) -> type[BaseQuestionState]: - state_class = get_mro_type_hint(question_class, "state", BaseQuestionState) - - if state_class is BaseQuestionState: - return state_class[get_mro_type_hint(question_class, "options", FormModel)] # type: ignore[misc] - - return state_class - - -class QuestionType(BaseQuestionType): - """A question type. - - This class is intended to be used in one of two ways: +class BaseQuestionState(BaseModel): + pass - - If you don't need to override any of the default [`QuestionType`][questionpy.QuestionType] methods, you should - provide your [`FormModel`][questionpy.form.FormModel] and [`Question`][questionpy.Question] subclasses as - constructor arguments. - - If you do, you should inherit from [`QuestionType`][questionpy.QuestionType], specifying your - [`FormModel`][questionpy.form.FormModel] and [`Question`][questionpy.Question] as type arguments. - - Examples: - This example shows how to subclass [`QuestionType`][questionpy.QuestionType]: - - >>> class MyOptions(FormModel): ... - >>> class MyAttempt(Attempt): ... - >>> class MyQuestion(Question): - ... attempt_class = MyAttempt - >>> class MyQuestionType(QuestionType): - ... question_class = MyQuestion - ... # Your code goes here. - """ +class Question(ABC): + attempt_class: type["Attempt"] - question_class: type["Question"] + options: FormModel + question_state: BaseQuestionState - def __init__(self, question_class: type[Question] | None = None) -> None: - """Initializes a new question. + scoring_method = ScoringMethod.AUTOMATICALLY_SCORABLE - Args: - question_class: The :class:`Question`-class used by this type. Can be set as a class variable as well. - """ - if question_class: - self.question_class = question_class + options_class: ClassVar[type[FormModel]] + question_state_class: ClassVar[type[BaseQuestionState]] + question_state_with_version_class: ClassVar[type[QuestionStateWithVersion]] - if not hasattr(self, "question_class"): - msg = ( - f"Missing '{type(self).__name__}.question_class' attribute. It should point to your question " - f"implementation" - ) - raise TypeError(msg) + def __init__(self, state_with_version: QuestionStateWithVersion) -> None: + self.question_state_with_version = state_with_version - def get_options_form(self, question_state: str | None) -> tuple[OptionsFormDefinition, dict[str, object]]: - if question_state: - question = self.create_question_from_state(question_state) - form_data = question.options.model_dump(mode="json") - else: - form_data = {} + self.num_variants = 1 + self.score_min: float = 0 + self.score_max: float = 1 + self.penalty: float | None = None + self.random_guess_score: float | None = None + self.response_analysis_by_variant = False + self.hints_available = False + self.subquestions: list[SubquestionModel] = [] - return (get_mro_type_hint(self.question_class, "options", FormModel).qpy_form, form_data) + @classmethod + def get_new_question_options_form(cls) -> OptionsFormDefinition: + """Get the form used to create a new question.""" + return cls.options_class.qpy_form - def create_question_from_options(self, old_state: str | None, form_data: dict[str, object]) -> Question: + @classmethod + def from_options(cls, old_state: QuestionStateWithVersion | None, form_data: PlainMapping) -> Self: try: - parsed_form_data = get_mro_type_hint(self.question_class, "options", FormModel).model_validate(form_data) + parsed_form_data = cls.options_class.model_validate(form_data) except ValidationError as e: error_dict = {".".join(map(str, error["loc"])): error["msg"] for error in e.errors()} raise OptionsFormValidationError(error_dict) from e if old_state: - state = _get_state_class(self.question_class).model_validate_json(old_state) - # TODO: Should we also update package_name and package_version here? Or check that they match? - state.options = parsed_form_data + new_state = old_state.model_copy(update={"options": parsed_form_data}) else: env = get_qpy_environment() - state = _get_state_class(self.question_class)( + new_state = QuestionStateWithVersion( package_name=f"{env.main_package.manifest.namespace}.{env.main_package.manifest.short_name}", package_version=env.main_package.manifest.version, options=parsed_form_data, + state=cls.make_question_state(parsed_form_data), ) - return self.question_class(self, state) + return cls(new_state) - def create_question_from_state(self, question_state: str) -> Question: - state_class = _get_state_class(self.question_class) + @classmethod + def from_state(cls, question_state: str) -> Self: try: - parsed_state = state_class.model_validate_json(question_state) + parsed_state = cls.question_state_with_version_class.model_validate_json(question_state) except ValidationError as e: raise InvalidQuestionStateError from e - return self.question_class(self, parsed_state) + return cls(parsed_state) + + @classmethod + def make_question_state(cls, options: FormModel) -> BaseQuestionState: + return cls.question_state_class() + + @classmethod + def validate_options(cls, raw_options: dict) -> FormModel: + return cls.options_class.model_validate(raw_options) + + def get_options_form(self) -> tuple[OptionsFormDefinition, PlainMapping]: + return self.options_class.qpy_form, self.options.model_dump() + + def start_attempt(self, variant: int) -> Attempt: + attempt_state = self.attempt_class.attempt_state_class(variant=variant) + return self.attempt_class(self, attempt_state) + + def get_attempt( + self, + attempt_state: BaseAttemptState, + scoring_state: BaseScoringState | None = None, + response: dict | None = None, + *, + compute_score: bool = False, + generate_hint: bool = False, + ) -> Attempt: + return self.attempt_class(self, attempt_state, scoring_state, response) + + def __init_subclass__(cls, *args: object, **kwargs: object) -> None: + super().__init_subclass__(*args, **kwargs) + + if not hasattr(cls, "attempt_class"): + msg = f"Missing '{cls.__name__}.attempt_class' attribute. It should point to your attempt implementation" + raise TypeError(msg) + + cls.question_state_class = get_mro_type_hint(cls, "question_state", BaseQuestionState) + cls.options_class = get_mro_type_hint(cls, "options", FormModel) + cls.question_state_with_version_class = QuestionStateWithVersion[ # type: ignore[misc] + cls.options_class, cls.question_state_class # type: ignore[name-defined] + ] + + @property # type: ignore[no-redef] + def options(self) -> FormModel: + return self.question_state_with_version.options + + @property # type: ignore[no-redef] + def question_state(self) -> BaseQuestionState: + return self.question_state_with_version.state diff --git a/questionpy/_ui.py b/questionpy/_ui.py index cd661bef..e4c00b7b 100644 --- a/questionpy/_ui.py +++ b/questionpy/_ui.py @@ -1,12 +1,13 @@ import importlib.resources +from typing import TYPE_CHECKING import jinja2 -from questionpy_common.api.attempt import BaseAttempt -from questionpy_common.api.qtype import BaseQuestionType -from questionpy_common.api.question import BaseQuestion from questionpy_common.environment import Package, get_qpy_environment +if TYPE_CHECKING: + from questionpy import Attempt, Question + def _loader_for_package(package: Package) -> jinja2.BaseLoader | None: pkg_name = f"{package.manifest.namespace}.{package.manifest.short_name}" @@ -19,9 +20,7 @@ def _loader_for_package(package: Package) -> jinja2.BaseLoader | None: return jinja2.PackageLoader(pkg_name) -def create_jinja2_environment( - attempt: BaseAttempt, question: BaseQuestion, qtype: BaseQuestionType -) -> jinja2.Environment: +def create_jinja2_environment(attempt: "Attempt", question: "Question") -> jinja2.Environment: """Creates a Jinja2 environment with sensible default configuration. - Library templates are accessible under the prefix ``qpy/``. @@ -40,6 +39,11 @@ def create_jinja2_environment( loader_mapping["qpy"] = jinja2.PackageLoader(__package__) env = jinja2.Environment(autoescape=True, loader=jinja2.PrefixLoader(mapping=loader_mapping)) - env.globals.update({"environment": qpy_env, "attempt": attempt, "question": question, "question_type": qtype}) + env.globals.update({ + "environment": qpy_env, + "attempt": attempt, + "question": question, + "question_type": type(question), + }) return env diff --git a/questionpy/_wrappers/__init__.py b/questionpy/_wrappers/__init__.py new file mode 100644 index 00000000..ab9b4928 --- /dev/null +++ b/questionpy/_wrappers/__init__.py @@ -0,0 +1,8 @@ +# This file is part of the QuestionPy SDK. (https://questionpy.org) +# The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md. +# (c) Technische Universität Berlin, innoCampus + +from ._qtype import QuestionTypeWrapper +from ._question import QuestionWrapper + +__all__ = ("QuestionTypeWrapper", "QuestionWrapper") diff --git a/questionpy/_wrappers/_qtype.py b/questionpy/_wrappers/_qtype.py new file mode 100644 index 00000000..74d05191 --- /dev/null +++ b/questionpy/_wrappers/_qtype.py @@ -0,0 +1,37 @@ +# This file is part of the QuestionPy SDK. (https://questionpy.org) +# The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md. +# (c) Technische Universität Berlin, innoCampus +from questionpy import Question +from questionpy._wrappers._question import QuestionWrapper +from questionpy_common.api import PlainMapping +from questionpy_common.api.qtype import QuestionTypeInterface +from questionpy_common.api.question import QuestionInterface +from questionpy_common.elements import OptionsFormDefinition +from questionpy_common.environment import Package +from questionpy_common.manifest import PackageFile + + +class QuestionTypeWrapper(QuestionTypeInterface): + def __init__(self, question_class: type[Question], package: Package) -> None: + self._question_class = question_class + self._package = package + + def get_options_form(self, question_state: str | None) -> tuple[OptionsFormDefinition, PlainMapping]: + if question_state is not None: + question = self._question_class.from_state(question_state) + return question.get_options_form() + + return self._question_class.get_new_question_options_form(), {} + + def create_question_from_options(self, old_state: str | None, form_data: PlainMapping) -> QuestionInterface: + parsed_old_state = None + if old_state is not None: + parsed_old_state = self._question_class.question_state_with_version_class.model_validate_json(old_state) + + return QuestionWrapper(self._question_class.from_options(parsed_old_state, form_data)) + + def create_question_from_state(self, question_state: str) -> QuestionInterface: + return QuestionWrapper(self._question_class.from_state(question_state)) + + def get_static_files(self) -> dict[str, PackageFile]: + return self._package.manifest.static_files diff --git a/questionpy/_wrappers/_question.py b/questionpy/_wrappers/_question.py new file mode 100644 index 00000000..5664097a --- /dev/null +++ b/questionpy/_wrappers/_question.py @@ -0,0 +1,121 @@ +# This file is part of the QuestionPy SDK. (https://questionpy.org) +# The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md. +# (c) Technische Universität Berlin, innoCampus +from questionpy import Attempt, Question +from questionpy_common.api.attempt import AttemptModel, AttemptScoredModel, AttemptStartedModel, AttemptUi, ScoreModel +from questionpy_common.api.question import QuestionInterface, QuestionModel +from questionpy_common.environment import get_qpy_environment + + +def _get_output_lang() -> str: + # TODO: Do something more meaningful per default and allow the package to override. + env = get_qpy_environment() + supported_langs = env.main_package.manifest.languages + preferred_langs = env.request_user.preferred_languages if env.request_user else () + intersection = set.intersection(supported_langs, preferred_langs) + if intersection: + return next(iter(intersection)) + if supported_langs: + return next(iter(supported_langs)) + return "en" + + +def _export_question(question: Question) -> QuestionModel: + return QuestionModel( + lang=_get_output_lang(), + num_variants=question.num_variants, + score_min=question.score_min, + score_max=question.score_max, + scoring_method=question.scoring_method, + penalty=question.penalty, + random_guess_score=question.random_guess_score, + response_analysis_by_variant=question.response_analysis_by_variant, + subquestions=question.subquestions, + ) + + +def _export_attempt(attempt: Attempt) -> AttemptModel: + return AttemptModel( + lang=_get_output_lang(), + variant=attempt.attempt_state.variant, + ui=AttemptUi( + formulation=attempt.formulation, + general_feedback=attempt.general_feedback, + specific_feedback=attempt.specific_feedback, + right_answer=attempt.right_answer_description, + placeholders=attempt.placeholders, + css_files=attempt.css_files, + files=attempt.files, + cache_control=attempt.cache_control, + ), + ) + + +def _export_score(attempt: Attempt) -> ScoreModel: + return ScoreModel( + scoring_state=attempt.scoring_state.model_dump_json() if attempt.scoring_state else None, + scoring_code=attempt.scoring_code, + score=attempt.score, + score_final=attempt.score_final, + scored_inputs=attempt.scored_inputs, + scored_subquestions={}, + ) + + +class QuestionWrapper(QuestionInterface): + def __init__(self, question: Question) -> None: + self._question = question + + def start_attempt(self, variant: int) -> AttemptStartedModel: + attempt = self._question.start_attempt(variant) + return AttemptStartedModel( + **_export_attempt(attempt).model_dump(), attempt_state=attempt.attempt_state.model_dump_json() + ) + + def _get_attempt_internal( + self, + attempt_state: str, + scoring_state: str | None = None, + response: dict | None = None, + *, + compute_score: bool = False, + generate_hint: bool = False, + ) -> Attempt: + parsed_attempt_state = self._question.attempt_class.attempt_state_class.model_validate_json(attempt_state) + parsed_scoring_state = None + if scoring_state: + parsed_scoring_state = self._question.attempt_class.scoring_state_class.model_validate_json(scoring_state) + + return self._question.get_attempt( + parsed_attempt_state, + parsed_scoring_state, + response, + compute_score=compute_score, + generate_hint=generate_hint, + ) + + def get_attempt( + self, attempt_state: str, scoring_state: str | None = None, response: dict | None = None + ) -> AttemptModel: + return _export_attempt(self._get_attempt_internal(attempt_state, scoring_state, response)) + + def score_attempt( + self, + attempt_state: str, + scoring_state: str | None = None, + response: dict | None = None, + *, + try_scoring_with_countback: bool = False, + try_giving_hint: bool = False, + ) -> AttemptScoredModel: + attempt = self._get_attempt_internal( + attempt_state, scoring_state, response, compute_score=True, generate_hint=try_giving_hint + ) + attempt.score_response(try_scoring_with_countback=try_scoring_with_countback, try_giving_hint=try_giving_hint) + return AttemptScoredModel(**_export_attempt(attempt).model_dump(), **_export_score(attempt).model_dump()) + + def export_question_state(self) -> str: + return self._question.question_state_with_version.model_dump_json() + + def export(self) -> QuestionModel: + return _export_question(self._question) diff --git a/questionpy_sdk/webserver/attempt.py b/questionpy_sdk/webserver/attempt.py index 228efbd0..2354853d 100644 --- a/questionpy_sdk/webserver/attempt.py +++ b/questionpy_sdk/webserver/attempt.py @@ -4,13 +4,12 @@ from typing import Literal, TypedDict -from questionpy_common.api.attempt import AttemptModel, AttemptScoredModel +from questionpy_common.api.attempt import AttemptModel, AttemptScoredModel, AttemptStartedModel from questionpy_sdk.webserver.question_ui import ( QuestionDisplayOptions, QuestionFormulationUIRenderer, QuestionUIRenderer, ) -from questionpy_server.api.models import AttemptStarted class _AttemptRenderContext(TypedDict): @@ -42,7 +41,7 @@ def get_attempt_render_context( context: _AttemptRenderContext = { "attempt_status": ( "Started" - if isinstance(attempt, AttemptStarted) + if isinstance(attempt, AttemptStartedModel) else "Scored" if isinstance(attempt, AttemptScoredModel) else "In progress" diff --git a/tests/conftest.py b/tests/conftest.py index 1da1497a..0313747a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,12 +1,14 @@ # This file is part of the QuestionPy SDK. (https://questionpy.org) # The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md. # (c) Technische Universität Berlin, innoCampus - +import json from pathlib import Path from shutil import copytree +from typing import Any import pytest +from questionpy_common.api import PlainMapping from questionpy_common.constants import DIST_DIR @@ -20,3 +22,29 @@ def source_path(request: pytest.FixtureRequest, tmp_path: Path) -> Path: copytree(src_path, dest_path, ignore=lambda src, names: (DIST_DIR,)) return dest_path + + +class EqualsJson(Any): + """Helper object which compares equal to an equal dict or a JSON representation of it.""" + + def __new__(cls, *args: object, **kwargs: object) -> "EqualsJson": + return super().__new__(cls) + + def __init__(self, parsed: PlainMapping) -> None: + self._parsed = parsed + + def __eq__(self, other: object) -> bool: + if isinstance(other, dict): + return self._parsed == other + + if isinstance(other, str): + other_parsed = json.loads(other) + return self._parsed == other_parsed + + return NotImplemented + + def __hash__(self) -> int: + return hash(self._parsed) + + def __repr__(self) -> str: + return json.dumps(self._parsed) diff --git a/tests/questionpy/__init__.py b/tests/questionpy/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/questionpy/wrappers/__init__.py b/tests/questionpy/wrappers/__init__.py new file mode 100644 index 00000000..e750778c --- /dev/null +++ b/tests/questionpy/wrappers/__init__.py @@ -0,0 +1,3 @@ +# This file is part of the QuestionPy SDK. (https://questionpy.org) +# The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md. +# (c) Technische Universität Berlin, innoCampus diff --git a/tests/questionpy/wrappers/conftest.py b/tests/questionpy/wrappers/conftest.py new file mode 100644 index 00000000..7c6b5574 --- /dev/null +++ b/tests/questionpy/wrappers/conftest.py @@ -0,0 +1,105 @@ +# This file is part of the QuestionPy SDK. (https://questionpy.org) +# The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md. +# (c) Technische Universität Berlin, innoCampus +from collections.abc import Generator +from types import SimpleNamespace +from typing import cast + +import pytest + +from questionpy import Attempt, BaseAttemptState, BaseQuestionState, Question +from questionpy.form import FormModel, text_input +from questionpy_common.environment import Environment, RequestUser, set_qpy_environment +from questionpy_common.manifest import Manifest, PackageFile +from questionpy_server.worker.runtime.manager import EnvironmentImpl +from questionpy_server.worker.runtime.package import ImportablePackage + +STATIC_FILES = { + "css/my-styles.css": PackageFile(mime_type="text/css", size=42), + "js/main.js": PackageFile(mime_type="text/javascript", size=534), + "static/logo.svg": PackageFile(mime_type="image/svg+xml", size=1253), +} + + +@pytest.fixture +def package() -> ImportablePackage: + return cast( + ImportablePackage, + SimpleNamespace( + manifest=Manifest( + namespace="test_ns", + short_name="test_package", + version="1.2.3", + author="Testy McTestface", + api_version="0.3", + languages={"en"}, + static_files=STATIC_FILES, + ) + ), + ) + + +@pytest.fixture(autouse=True) +def environment(package: ImportablePackage) -> Generator[Environment, None, None]: + env = EnvironmentImpl( + type="test", + limits=None, + request_user=RequestUser(["en"]), + main_package=package, + packages={}, + _on_request_callbacks=[], + ) + set_qpy_environment(env) + try: + yield env + finally: + set_qpy_environment(None) + + +class SomeModel(FormModel): + input: str | None = text_input("Some Label") + + +class MyQuestionState(BaseQuestionState): + my_question_field: int = 42 + + +class MyAttemptState(BaseAttemptState): + my_attempt_field: int = 17 + + +class SomeAttempt(Attempt): + attempt_state: MyAttemptState + + formulation = "" + + def _compute_score(self) -> float: + return 1 + + +class QuestionUsingDefaultState(Question): + attempt_class = SomeAttempt + + options: SomeModel + + +class QuestionUsingMyQuestionState(Question): + attempt_class = SomeAttempt + + question_state: MyQuestionState + options: SomeModel + + +QUESTION_STATE_DICT = { + "package_name": "test_ns.test_package", + "package_version": "1.2.3", + "options": {"input": "something"}, + "state": { + "my_question_field": 42, + }, +} + +ATTEMPT_STATE_DICT = { + "variant": 3, + "my_attempt_field": 17, +} diff --git a/tests/questionpy/wrappers/test_qtype.py b/tests/questionpy/wrappers/test_qtype.py new file mode 100644 index 00000000..ef0adddd --- /dev/null +++ b/tests/questionpy/wrappers/test_qtype.py @@ -0,0 +1,62 @@ +# This file is part of the QuestionPy SDK. (https://questionpy.org) +# The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md. +# (c) Technische Universität Berlin, innoCampus +import json + +from questionpy import QuestionTypeWrapper, QuestionWrapper +from questionpy_common.elements import OptionsFormDefinition, TextInputElement +from questionpy_common.environment import Package +from tests.questionpy.wrappers.conftest import ( + QUESTION_STATE_DICT, + STATIC_FILES, + QuestionUsingDefaultState, + QuestionUsingMyQuestionState, +) + +_EXPECTED_FORM = OptionsFormDefinition(general=[TextInputElement(name="input", label="Some Label")]) + + +def test_should_get_options_form_for_new_question(package: Package) -> None: + qtype = QuestionTypeWrapper(QuestionUsingMyQuestionState, package) + + form, data = qtype.get_options_form(None) + + assert form == _EXPECTED_FORM + assert data == {} + + +def test_should_get_options_form_for_existing_question(package: Package) -> None: + qtype = QuestionTypeWrapper(QuestionUsingMyQuestionState, package) + + form, data = qtype.get_options_form(json.dumps(QUESTION_STATE_DICT)) + + assert form == _EXPECTED_FORM + assert data == {"input": "something"} + + +def test_should_create_question_from_options(package: Package) -> None: + qtype = QuestionTypeWrapper(QuestionUsingMyQuestionState, package) + question = qtype.create_question_from_options(None, {"input": "something"}) + + assert isinstance(question, QuestionWrapper) + assert json.loads(question.export_question_state()) == QUESTION_STATE_DICT + + +def test_should_create_question_from_state(package: Package) -> None: + qtype = QuestionTypeWrapper(QuestionUsingMyQuestionState, package) + question = qtype.create_question_from_state(json.dumps(QUESTION_STATE_DICT)) + + assert isinstance(question, QuestionWrapper) + assert json.loads(question.export_question_state()) == QUESTION_STATE_DICT + + +def test_should_preserve_options_when_using_default_question_state(package: Package) -> None: + qtype = QuestionTypeWrapper(QuestionUsingDefaultState, package) + + question = qtype.create_question_from_state(json.dumps(QUESTION_STATE_DICT)) + + assert json.loads(question.export_question_state())["options"] == QUESTION_STATE_DICT["options"] + + +def test_should_get_static_files(package: Package) -> None: + package.manifest.static_files = STATIC_FILES diff --git a/tests/questionpy/wrappers/test_question.py b/tests/questionpy/wrappers/test_question.py new file mode 100644 index 00000000..397661df --- /dev/null +++ b/tests/questionpy/wrappers/test_question.py @@ -0,0 +1,105 @@ +# This file is part of the QuestionPy SDK. (https://questionpy.org) +# The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md. +# (c) Technische Universität Berlin, innoCampus +import json +from unittest.mock import patch + +import pytest + +from questionpy import ( + InvalidResponseError, + NeedsManualScoringError, + QuestionTypeWrapper, + QuestionWrapper, + ResponseNotScorableError, +) +from questionpy_common.api.attempt import AttemptModel, AttemptScoredModel, AttemptStartedModel, AttemptUi, ScoringCode +from questionpy_common.api.question import QuestionModel, ScoringMethod +from questionpy_common.environment import Package +from tests.conftest import EqualsJson +from tests.questionpy.wrappers.conftest import ( + ATTEMPT_STATE_DICT, + QUESTION_STATE_DICT, + QuestionUsingMyQuestionState, + SomeAttempt, +) + + +def test_should_start_attempt(package: Package) -> None: + qtype = QuestionTypeWrapper(QuestionUsingMyQuestionState, package) + question = qtype.create_question_from_state(json.dumps(QUESTION_STATE_DICT)) + attempt_started_model = question.start_attempt(3) + + assert attempt_started_model == AttemptStartedModel.construct( + attempt_state=EqualsJson(ATTEMPT_STATE_DICT), lang="en", variant=3, ui=AttemptUi(formulation="") + ) + + +def test_should_get_attempt(package: Package) -> None: + qtype = QuestionTypeWrapper(QuestionUsingMyQuestionState, package) + question = qtype.create_question_from_state(json.dumps(QUESTION_STATE_DICT)) + attempt_model = question.get_attempt(json.dumps(ATTEMPT_STATE_DICT)) + + assert attempt_model == AttemptModel(lang="en", variant=3, ui=AttemptUi(formulation="")) + + +def test_score_attempt_should_return_automatically_scored(package: Package) -> None: + qtype = QuestionTypeWrapper(QuestionUsingMyQuestionState, package) + question = qtype.create_question_from_state(json.dumps(QUESTION_STATE_DICT)) + attempt_scored_model = question.score_attempt(json.dumps(ATTEMPT_STATE_DICT)) + + assert attempt_scored_model == AttemptScoredModel.construct( + lang="en", + variant=3, + ui=AttemptUi(formulation=""), + scoring_code=ScoringCode.AUTOMATICALLY_SCORED, + score=1, + score_final=1, + ) + + +@pytest.mark.parametrize( + ("error", "expected_scoring_code"), + [ + (ResponseNotScorableError(), ScoringCode.RESPONSE_NOT_SCORABLE), + (InvalidResponseError(), ScoringCode.INVALID_RESPONSE), + (NeedsManualScoringError(), ScoringCode.NEEDS_MANUAL_SCORING), + ], +) +def test_score_attempt_should_handle_scoring_error( + package: Package, error: Exception, expected_scoring_code: ScoringCode +) -> None: + qtype = QuestionTypeWrapper(QuestionUsingMyQuestionState, package) + question = qtype.create_question_from_state(json.dumps(QUESTION_STATE_DICT)) + + assert isinstance(question, QuestionWrapper) + with patch.object(SomeAttempt, "_compute_score") as method: + method.side_effect = error + attempt_scored_model = question.score_attempt(json.dumps(ATTEMPT_STATE_DICT)) + + assert attempt_scored_model == AttemptScoredModel.construct( + lang="en", + variant=3, + ui=AttemptUi(formulation=""), + scoring_code=expected_scoring_code, + score=None, + score_final=None, + ) + + +def test_should_export_question_state(package: Package) -> None: + qtype = QuestionTypeWrapper(QuestionUsingMyQuestionState, package) + question = qtype.create_question_from_state(json.dumps(QUESTION_STATE_DICT)) + + question_state = question.export_question_state() + + assert json.loads(question_state) == QUESTION_STATE_DICT + + +def test_should_export_question_model(package: Package) -> None: + qtype = QuestionTypeWrapper(QuestionUsingMyQuestionState, package) + question = qtype.create_question_from_state(json.dumps(QUESTION_STATE_DICT)) + + question_model = question.export() + + assert question_model == QuestionModel(lang="en", scoring_method=ScoringMethod.AUTOMATICALLY_SCORABLE) diff --git a/tests/test_qtype.py b/tests/test_qtype.py deleted file mode 100644 index 0b2f9d94..00000000 --- a/tests/test_qtype.py +++ /dev/null @@ -1,163 +0,0 @@ -# This file is part of the QuestionPy SDK. (https://questionpy.org) -# The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md. -# (c) Technische Universität Berlin, innoCampus -import json -from collections.abc import Generator -from types import SimpleNamespace -from typing import cast - -import pytest - -from questionpy import ( - Attempt, - AttemptUiPart, - BaseAttemptState, - BaseQuestionState, - Environment, - Question, - QuestionType, - RequestUser, -) -from questionpy.form import FormModel, text_input -from questionpy_common.api.attempt import ScoreModel, ScoringCode -from questionpy_common.api.question import QuestionModel, ScoringMethod -from questionpy_common.environment import set_qpy_environment -from questionpy_server.worker.runtime.manager import EnvironmentImpl -from questionpy_server.worker.runtime.package import ImportablePackage - - -@pytest.fixture(autouse=True) -def environment() -> Generator[Environment, None, None]: - env = EnvironmentImpl( - type="test", - limits=None, - request_user=RequestUser(["en"]), - main_package=cast( - ImportablePackage, - SimpleNamespace(manifest=SimpleNamespace(namespace="test_ns", short_name="test_package", version="1.2.3")), - ), - packages={}, - _on_request_callbacks=[], - ) - set_qpy_environment(env) - try: - yield env - finally: - set_qpy_environment(None) - - -class SomeModel(FormModel): - input: str | None = text_input("Some Label") - - -class MyQuestionState(BaseQuestionState[SomeModel]): - my_question_field: int = 42 - - -class MyAttemptState(BaseAttemptState): - my_attempt_field: int = 17 - - -class SomeAttempt(Attempt): - attempt_state: MyAttemptState - - def render_formulation(self) -> AttemptUiPart: - return AttemptUiPart(content="") - - def export_score(self) -> ScoreModel: - return ScoreModel(scoring_code=ScoringCode.AUTOMATICALLY_SCORED, score=1) - - -class QuestionUsingDefaultState(Question): - attempt_class = SomeAttempt - - options: SomeModel - - def export(self) -> QuestionModel: - return QuestionModel(scoring_method=ScoringMethod.AUTOMATICALLY_SCORABLE) - - -class QuestionUsingMyQuestionState(Question): - attempt_class = SomeAttempt - state: MyQuestionState - options: SomeModel - - def export(self) -> QuestionModel: - return QuestionModel(scoring_method=ScoringMethod.AUTOMATICALLY_SCORABLE) - - -def test_should_use_init_argument() -> None: - qtype = QuestionType(QuestionUsingDefaultState) - - assert qtype.question_class is QuestionUsingDefaultState - - -class SomeModel2(SomeModel): - # Mypy crashes for some reason if this is local in test_should_raise_with_different_explicit_form_models. - pass - - -def test_should_raise_with_different_explicit_form_models() -> None: - with pytest.raises(TypeError, match="must have the same FormModel as"): - - class MyQuestion(Question): - attempt_class = SomeAttempt - - state: MyQuestionState - options: FormModel - - -QUESTION_STATE_DICT = { - "package_name": "test_ns.test_package", - "package_version": "1.2.3", - "options": {"input": "something"}, - "my_question_field": 42, -} - -ATTEMPT_STATE_DICT = { - "variant": 3, - "my_attempt_field": 17, -} - - -def test_should_deserialize_correct_options_when_using_BaseQuestionState() -> None: - qtype = QuestionType(QuestionUsingDefaultState) - - question = qtype.create_question_from_state(json.dumps(QUESTION_STATE_DICT)) - - assert question.options == SomeModel(input="something") - - -def test_should_create_question_from_options() -> None: - qtype = QuestionType(QuestionUsingMyQuestionState) - question = qtype.create_question_from_options(None, {"input": "something"}) - - assert isinstance(question, QuestionUsingMyQuestionState) - assert isinstance(question.state, MyQuestionState) - assert json.loads(question.export_question_state()) == QUESTION_STATE_DICT - - -def test_should_create_question_from_state() -> None: - qtype = QuestionType(QuestionUsingMyQuestionState) - question = qtype.create_question_from_state(json.dumps(QUESTION_STATE_DICT)) - - assert isinstance(question, QuestionUsingMyQuestionState) - assert json.loads(question.export_question_state()) == QUESTION_STATE_DICT - - -def test_should_start_attempt() -> None: - qtype = QuestionType(QuestionUsingDefaultState) - question = qtype.create_question_from_state(json.dumps(QUESTION_STATE_DICT)) - attempt = question.start_attempt(3) - - assert isinstance(attempt, SomeAttempt) - assert json.loads(attempt.export_attempt_state()) == ATTEMPT_STATE_DICT - - -def test_should_get_attempt() -> None: - qtype = QuestionType(QuestionUsingDefaultState) - question = qtype.create_question_from_state(json.dumps(QUESTION_STATE_DICT)) - attempt = question.get_attempt(json.dumps(ATTEMPT_STATE_DICT)) - - assert isinstance(attempt, SomeAttempt) - assert json.loads(attempt.export_attempt_state()) == ATTEMPT_STATE_DICT diff --git a/tests/webserver/test_page.py b/tests/webserver/test_page.py index 8dcf418f..6c3ce1ad 100644 --- a/tests/webserver/test_page.py +++ b/tests/webserver/test_page.py @@ -10,52 +10,46 @@ from selenium.webdriver.support import expected_conditions from selenium.webdriver.support.wait import WebDriverWait -from questionpy import Attempt, AttemptUiPart, Question, QuestionType +from questionpy import Attempt, NeedsManualScoringError, Package, Question, QuestionTypeWrapper from questionpy.form import FormModel, checkbox, repeat -from questionpy_common.api.attempt import ScoreModel, ScoringCode -from questionpy_common.api.qtype import BaseQuestionType -from questionpy_common.api.question import QuestionModel, ScoringMethod +from questionpy_common.api.qtype import QuestionTypeInterface from questionpy_common.environment import PackageInitFunction from questionpy_common.manifest import Manifest from questionpy_server.worker.runtime.package_location import FunctionPackageLocation class _NoopAttempt(Attempt): - def export_score(self) -> ScoreModel: - return ScoreModel(scoring_code=ScoringCode.NEEDS_MANUAL_SCORING, score=None) + def _compute_score(self) -> float: + raise NeedsManualScoringError - def render_formulation(self) -> AttemptUiPart: - return AttemptUiPart(content="") + formulation = "" class _NoopQuestion(Question): attempt_class = _NoopAttempt - def export(self) -> QuestionModel: - return QuestionModel(scoring_method=ScoringMethod.AUTOMATICALLY_SCORABLE) - -def package_1_init() -> BaseQuestionType: +def package_1_init(package: Package) -> QuestionTypeInterface: class Package1Form(FormModel): optional_checkbox: bool = checkbox("Optional Checkbox") class Package1Question(_NoopQuestion): options: Package1Form - return QuestionType(Package1Question) + return QuestionTypeWrapper(Package1Question, package) -def package_2_init() -> BaseQuestionType: +def package_2_init(package: Package) -> QuestionTypeInterface: class Package2Form(FormModel): required_checkbox: bool = checkbox("Required Checkbox", required=True) class Package2Question(_NoopQuestion): options: Package2Form - return QuestionType(Package2Question) + return QuestionTypeWrapper(Package2Question, package) -def package_3_init() -> BaseQuestionType: +def package_3_init(package: Package) -> QuestionTypeInterface: class SubModel(FormModel): optional_checkbox: bool = checkbox("Optional Checkbox { qpy:repno }") @@ -65,7 +59,7 @@ class Package3Form(FormModel): class Package3Question(_NoopQuestion): options: Package3Form - return QuestionType(Package3Question) + return QuestionTypeWrapper(Package3Question, package) _C = TypeVar("_C", bound=Callable)