diff --git a/poetry.lock b/poetry.lock index 594db26c..f65c64ae 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" @@ -216,13 +216,13 @@ toml = ["tomli"] [[package]] name = "faker" -version = "25.6.0" +version = "25.8.0" description = "Faker is a Python package that generates fake data for you." optional = false python-versions = ">=3.8" files = [ - {file = "Faker-25.6.0-py3-none-any.whl", hash = "sha256:7fe5f48ebfd43b9cdf9149a087a4dad3cf3e1618e2f69247deea1cb56bff85f9"}, - {file = "Faker-25.6.0.tar.gz", hash = "sha256:e1d271759d9030db33eab68d3951ec0ac251cdc14c295d6bcc34a02f39ef0e84"}, + {file = "Faker-25.8.0-py3-none-any.whl", hash = "sha256:4c40b34a9c569018d4f9d6366d71a4da8a883d5ddf2b23197be5370f29b7e1b6"}, + {file = "Faker-25.8.0.tar.gz", hash = "sha256:bdec5f2fb057d244ebef6e0ed318fea4dcbdf32c3a1a010766fc45f5d68fc68d"}, ] [package.dependencies] @@ -580,13 +580,13 @@ files = [ [[package]] name = "packaging" -version = "24.0" +version = "24.1" description = "Core utilities for Python packages" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, - {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, + {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, + {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, ] [[package]] @@ -658,13 +658,13 @@ test = ["enum34", "ipaddress", "mock", "pywin32", "wmi"] [[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] @@ -768,13 +768,13 @@ typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" [[package]] name = "pydantic-settings" -version = "2.3.1" +version = "2.3.3" description = "Settings management using Pydantic" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic_settings-2.3.1-py3-none-any.whl", hash = "sha256:acb2c213140dfff9669f4fe9f8180d43914f51626db28ab2db7308a576cce51a"}, - {file = "pydantic_settings-2.3.1.tar.gz", hash = "sha256:e34bbd649803a6bb3e2f0f58fb0edff1f0c7f556849fda106cc21bcce12c30ab"}, + {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] @@ -945,13 +945,13 @@ files = [ [[package]] name = "typing-extensions" -version = "4.12.1" +version = "4.12.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" files = [ - {file = "typing_extensions-4.12.1-py3-none-any.whl", hash = "sha256:6024b58b69089e5a89c347397254e35f1bf02a907728ec7fee9bf0fe837d203a"}, - {file = "typing_extensions-4.12.1.tar.gz", hash = "sha256:915f5e35ff76f56588223f15fdd5938f9a1cf9195c0de25130c627e4d597f6d1"}, + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index 893211d4..dfdf39ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ description = "QuestionPy application server" authors = ["Technische Universität Berlin, innoCampus "] license = "MIT" homepage = "https://questionpy.org" -version = "0.2.5" +version = "0.3.0" packages = [ { include = "questionpy_common" }, { include = "questionpy_server" } diff --git a/questionpy_common/api/__init__.py b/questionpy_common/api/__init__.py index 077e1f40..06dc7b47 100644 --- a/questionpy_common/api/__init__.py +++ b/questionpy_common/api/__init__.py @@ -1,3 +1,23 @@ # This file is part of QuestionPy. (https://questionpy.org) # QuestionPy is free software released under terms of the MIT license. See LICENSE.md. # (c) Technische Universität Berlin, innoCampus +from collections.abc import ( + Mapping, + Sequence, # noqa: F401 +) +from typing import ( + TypeAlias, + Union, # noqa: F401, +) + +from pydantic import BaseModel +from typing_extensions import TypeAliasType + + +class Localized(BaseModel): + lang: str + + +# "Regular" recursive type aliases break Pydantic: https://github.com/pydantic/pydantic/issues/8346 +PlainValue: TypeAlias = TypeAliasType("PlainValue", "Union[None, int, str, bool, Sequence[PlainValue], PlainMapping]") +PlainMapping: TypeAlias = TypeAliasType("PlainMapping", Mapping[str, PlainValue]) diff --git a/questionpy_common/api/attempt.py b/questionpy_common/api/attempt.py index cb141f41..8fab87da 100644 --- a/questionpy_common/api/attempt.py +++ b/questionpy_common/api/attempt.py @@ -2,19 +2,19 @@ # QuestionPy is free software released under terms of the MIT license. See LICENSE.md. # (c) Technische Universität Berlin, innoCampus -from abc import ABC, abstractmethod -from collections.abc import Sequence from enum import Enum from typing import Annotated from pydantic import BaseModel, Field +from . import Localized + __all__ = [ "AttemptFile", "AttemptModel", "AttemptScoredModel", + "AttemptStartedModel", "AttemptUi", - "BaseAttempt", "CacheControl", "ClassifiedResponse", "ScoreModel", @@ -46,16 +46,20 @@ class AttemptUi(BaseModel): placeholders: dict[str, str] = {} """Names and values of the `` str: - """Serialize this attempt's relevant data. - - A future call to :meth:`BaseQuestion.view_attempt` should result in an attempt object identical to the one which - exported the state. - """ - - @abstractmethod - def export(self) -> AttemptModel: - """Get metadata about this attempt.""" - - @abstractmethod - def export_scored_attempt(self) -> AttemptScoredModel: - """Score this attempt.""" diff --git a/questionpy_common/api/package.py b/questionpy_common/api/package.py new file mode 100644 index 00000000..9e7f4d75 --- /dev/null +++ b/questionpy_common/api/package.py @@ -0,0 +1,23 @@ +# This file is part of QuestionPy. (https://questionpy.org) +# QuestionPy is free software released under terms of the MIT license. See LICENSE.md. +# (c) Technische Universität Berlin, innoCampus +from abc import abstractmethod +from typing import TYPE_CHECKING, Protocol, TypeAlias + +from questionpy_common.manifest import PackageFile + +if TYPE_CHECKING: + from questionpy_common.api.qtype import QuestionTypeInterface + + +class BasePackageInterface(Protocol): + @abstractmethod + def get_static_files(self) -> dict[str, PackageFile]: + pass + + +class LibraryPackageInterface(BasePackageInterface, Protocol): + pass + + +QPyPackageInterface: TypeAlias = "LibraryPackageInterface | QuestionTypeInterface" diff --git a/questionpy_common/api/qtype.py b/questionpy_common/api/qtype.py index 92066569..da473a80 100644 --- a/questionpy_common/api/qtype.py +++ b/questionpy_common/api/qtype.py @@ -1,19 +1,25 @@ # This file is part of QuestionPy. (https://questionpy.org) # QuestionPy is free software released under terms of the MIT license. See LICENSE.md. # (c) Technische Universität Berlin, innoCampus +from __future__ import annotations -from abc import ABC, abstractmethod +from abc import abstractmethod +from typing import TYPE_CHECKING, Protocol -from questionpy_common.elements import OptionsFormDefinition +from questionpy_common.api.package import BasePackageInterface -from .question import BaseQuestion +if TYPE_CHECKING: + from questionpy_common.elements import OptionsFormDefinition -__all__ = ["BaseQuestionType", "InvalidQuestionStateError", "OptionsFormValidationError"] + from . import PlainMapping + from .question import QuestionInterface +__all__ = ["InvalidQuestionStateError", "OptionsFormValidationError", "QuestionTypeInterface"] -class BaseQuestionType(ABC): + +class QuestionTypeInterface(BasePackageInterface, Protocol): @abstractmethod - def get_options_form(self, question_state: str | None) -> tuple[OptionsFormDefinition, dict[str, object]]: + def get_options_form(self, question_state: str | None) -> tuple[OptionsFormDefinition, PlainMapping]: """Get the form used to create a new or edit an existing question. Args: @@ -24,7 +30,7 @@ def get_options_form(self, question_state: str | None) -> tuple[OptionsFormDefin """ @abstractmethod - def create_question_from_options(self, old_state: str | None, form_data: dict[str, object]) -> BaseQuestion: + def create_question_from_options(self, old_state: str | None, form_data: PlainMapping) -> QuestionInterface: """Create or update the question (state) with the form data from a submitted question edit form. Args: @@ -39,7 +45,7 @@ def create_question_from_options(self, old_state: str | None, form_data: dict[st """ @abstractmethod - def create_question_from_state(self, question_state: str) -> BaseQuestion: + def create_question_from_state(self, question_state: str) -> QuestionInterface: """Deserialize the given question state, returning a question object equivalent to the one which exported it. Raises: diff --git a/questionpy_common/api/question.py b/questionpy_common/api/question.py index f9725dd3..12e9b663 100644 --- a/questionpy_common/api/question.py +++ b/questionpy_common/api/question.py @@ -1,15 +1,16 @@ # This file is part of QuestionPy. (https://questionpy.org) # QuestionPy is free software released under terms of the MIT license. See LICENSE.md. # (c) Technische Universität Berlin, innoCampus -from abc import ABC, abstractmethod +from abc import abstractmethod from enum import Enum -from typing import Annotated +from typing import Annotated, Protocol from pydantic import BaseModel, Field -from .attempt import BaseAttempt +from . import Localized +from .attempt import AttemptModel, AttemptScoredModel, AttemptStartedModel -__all__ = ["BaseQuestion", "PossibleResponse", "QuestionModel", "ScoringMethod", "SubquestionModel"] +__all__ = ["PossibleResponse", "QuestionInterface", "QuestionModel", "ScoringMethod", "SubquestionModel"] class ScoringMethod(Enum): @@ -29,7 +30,7 @@ class SubquestionModel(BaseModel): response_classes: list[PossibleResponse] | None -class QuestionModel(BaseModel): +class QuestionModel(Localized): num_variants: Annotated[int, Field(ge=1, strict=True)] = 1 score_min: float = 0 """Lowest score used by this question.""" @@ -38,14 +39,14 @@ class QuestionModel(BaseModel): scoring_method: ScoringMethod penalty: float | None = None random_guess_score: float | None = None - response_analysis_by_variant: bool = True + response_analysis_by_variant: bool = False - subquestions: list[SubquestionModel] | None = None + subquestions: list[SubquestionModel] = [] -class BaseQuestion(ABC): +class QuestionInterface(Protocol): @abstractmethod - def start_attempt(self, variant: int) -> BaseAttempt: + def start_attempt(self, variant: int) -> AttemptStartedModel: """Start an attempt at this question with the given variant. Args: @@ -57,14 +58,30 @@ def start_attempt(self, variant: int) -> BaseAttempt: @abstractmethod def get_attempt( + self, attempt_state: str, scoring_state: str | None = None, response: dict | None = None + ) -> AttemptModel: + """Create an attempt object for a previously started attempt. + + Args: + attempt_state: The `attempt_state` attribute of an attempt which was previously returned by + :meth:`start_attempt`. + scoring_state: Not implemented. + response: The response currently entered by the student. + + Returns: + A :class:`BaseAttempt` object which should be identical to the one which generated the given state(s). + """ + + @abstractmethod + def score_attempt( self, attempt_state: str, scoring_state: str | None = None, response: dict | None = None, *, - compute_score: bool = False, - generate_hint: bool = False, - ) -> BaseAttempt: + try_scoring_with_countback: bool = False, + try_giving_hint: bool = False, + ) -> AttemptScoredModel: """Create an attempt object for a previously started attempt. Args: @@ -72,8 +89,8 @@ def get_attempt( :meth:`start_attempt`. scoring_state: Not implemented. response: The response currently entered by the student. - compute_score: Whether the attempt is retrieved to be scored. - generate_hint: Whether the package should generate a hint for the student. + try_scoring_with_countback: TBD + try_giving_hint: TBD Returns: A :class:`BaseAttempt` object which should be identical to the one which generated the given state(s). @@ -83,7 +100,7 @@ def get_attempt( def export_question_state(self) -> str: """Serialize this question's relevant data. - A future call to :meth:`BaseQuestionType.create_question_from_state` should result in a question object + A future call to :meth:`QuestionTypeInterface.create_question_from_state` should result in a question object identical to the one which exported the state. """ diff --git a/questionpy_common/environment.py b/questionpy_common/environment.py index d5ab3c20..6b8c976c 100644 --- a/questionpy_common/environment.py +++ b/questionpy_common/environment.py @@ -1,6 +1,8 @@ # This file is part of QuestionPy. (https://questionpy.org) # QuestionPy is free software released under terms of the MIT license. See LICENSE.md. # (c) Technische Universität Berlin, innoCampus +import builtins +import types from abc import abstractmethod from collections.abc import Callable, Mapping, Sequence from contextvars import ContextVar @@ -8,7 +10,8 @@ from importlib.resources.abc import Traversable from typing import Protocol, TypeAlias -from questionpy_common.api.qtype import BaseQuestionType +from questionpy_common.api.package import QPyPackageInterface +from questionpy_common.api.qtype import QuestionTypeInterface from questionpy_common.manifest import Manifest __all__ = [ @@ -92,8 +95,16 @@ def register_on_request_callback(self, callback: OnRequestCallback) -> None: finished. """ + @abstractmethod + def get_package_defining(self, what: builtins.type | types.FunctionType) -> Package: + """Returns the :class:`Package` which defines the given symbol, if any.""" + -PackageInitFunction: TypeAlias = Callable[[Environment], BaseQuestionType] | Callable[[], BaseQuestionType] +PackageInitFunction: TypeAlias = ( + Callable[[Package, Environment], QPyPackageInterface] + | Callable[[Package], QPyPackageInterface] + | Callable[[], QuestionTypeInterface] +) """Signature of the "init"-function expected in the main package.""" _current_env: ContextVar[Environment | None] = ContextVar("_current_env") diff --git a/questionpy_server/api/models.py b/questionpy_server/api/models.py index 9212ad11..d07e0dd3 100644 --- a/questionpy_server/api/models.py +++ b/questionpy_server/api/models.py @@ -7,7 +7,6 @@ from pydantic import BaseModel, ByteSize, ConfigDict, Field, FilePath, HttpUrl -from questionpy_common.api.attempt import AttemptModel from questionpy_common.api.question import QuestionModel from questionpy_common.elements import OptionsFormDefinition from questionpy_common.manifest import PackageType @@ -68,10 +67,6 @@ class AttemptStartArguments(RequestBaseData): variant: Annotated[int, Field(ge=1, strict=True)] -class AttemptStarted(AttemptModel): - attempt_state: str - - class AttemptViewArguments(RequestBaseData): attempt_state: str scoring_state: str | None = None diff --git a/questionpy_server/worker/runtime/manager.py b/questionpy_server/worker/runtime/manager.py index cf5289a0..054dc0c3 100644 --- a/questionpy_server/worker/runtime/manager.py +++ b/questionpy_server/worker/runtime/manager.py @@ -1,25 +1,28 @@ # This file is part of the QuestionPy Server. (https://questionpy.org) # The QuestionPy Server is free software released under terms of the MIT license. See LICENSE.md. # (c) Technische Universität Berlin, innoCampus - +import builtins import resource -import warnings +import types from collections.abc import Callable, Generator from contextlib import contextmanager from dataclasses import dataclass -from typing import Any, NoReturn +from typing import Any, NoReturn, cast -from questionpy_common.api.qtype import BaseQuestionType +from questionpy_common.api.qtype import QuestionTypeInterface from questionpy_common.environment import ( Environment, OnRequestCallback, + Package, RequestUser, WorkerResourceLimits, get_qpy_environment, + set_qpy_environment, ) from questionpy_server.worker.runtime.connection import WorkerToServerConnection from questionpy_server.worker.runtime.messages import ( CreateQuestionFromOptions, + DebugEval, Exit, GetOptionsForm, GetQPyPackageManifest, @@ -48,16 +51,28 @@ class EnvironmentImpl(Environment): def register_on_request_callback(self, callback: OnRequestCallback) -> None: self._on_request_callbacks.append(callback) + def get_package_defining(self, what: builtins.type | types.FunctionType) -> Package: + for package in self.packages.values(): + if package.owns(what): + return package + + msg = f"'{what}' is not defined by a loaded package" + raise TypeError(msg) + class WorkerManager: def __init__(self, server_connection: WorkerToServerConnection): - self.worker_type: str | None = None - self.server_connection: WorkerToServerConnection = server_connection - self.limits: WorkerResourceLimits | None = None - self.loaded_packages: dict[str, ImportablePackage] = {} - self.main_package: ImportablePackage | None = None - self.question_type: BaseQuestionType | None = None - self.message_dispatch: dict[MessageIds, Callable[[Any], MessageToServer]] = { + self._connection: WorkerToServerConnection = server_connection + + self._worker_type: str | None = None + self._loaded_packages: dict[str, ImportablePackage] = {} + + self._limits: WorkerResourceLimits | None = None + + self._env: EnvironmentImpl | None = None + self._question_type: QuestionTypeInterface | None = None + + self._message_dispatch: dict[MessageIds, Callable[[Any], MessageToServer]] = { LoadQPyPackage.message_id: self.on_msg_load_qpy_package, GetQPyPackageManifest.message_id: self.on_msg_get_qpy_package_manifest, GetOptionsForm.message_id: self.on_msg_get_options_form_definition, @@ -66,136 +81,133 @@ def __init__(self, server_connection: WorkerToServerConnection): ViewAttempt.message_id: self.on_msg_view_attempt, ScoreAttempt.message_id: self.on_msg_score_attempt, } + if __debug__: + self._message_dispatch[DebugEval.message_id] = self.on_msg_debug_eval + self._on_request_callbacks: list[OnRequestCallback] = [] def bootstrap(self) -> None: - init_msg = self.server_connection.receive_message() + init_msg = self._connection.receive_message() if not isinstance(init_msg, InitWorker): raise self._raise_not_initialized(init_msg) - self.worker_type = init_msg.worker_type - self.limits = init_msg.limits - if self.limits: + self._worker_type = init_msg.worker_type + self._limits = init_msg.limits + if self._limits: # Limit memory usage. - resource.setrlimit(resource.RLIMIT_AS, (self.limits.max_memory, self.limits.max_memory)) + resource.setrlimit(resource.RLIMIT_AS, (self._limits.max_memory, self._limits.max_memory)) - self.server_connection.send_message(InitWorker.Response()) + self._connection.send_message(InitWorker.Response()) def loop(self) -> None: """Dispatch incoming messages.""" while True: - msg = self.server_connection.receive_message() + msg = self._connection.receive_message() if isinstance(msg, Exit): return try: - response = self.message_dispatch[msg.message_id](msg) + response = self._message_dispatch[msg.message_id](msg) except Exception as error: # noqa: BLE001 response = WorkerError.from_exception(error, cause=msg) - self.server_connection.send_message(response) + self._connection.send_message(response) def on_msg_load_qpy_package(self, msg: LoadQPyPackage) -> MessageToServer: - if not self.worker_type: + if not self._worker_type: self._raise_not_initialized(msg) package = load_package(msg.location) package.setup_imports() - self.loaded_packages[str(msg.location)] = package if msg.main: - qtype = package.init_as_main( - EnvironmentImpl( - type=self.worker_type, - limits=self.limits, - packages=self.loaded_packages, - main_package=package, - _on_request_callbacks=self._on_request_callbacks, - ) + self._env = EnvironmentImpl( + type=self._worker_type, + limits=self._limits, + packages=self._loaded_packages, + main_package=package, + _on_request_callbacks=self._on_request_callbacks, ) - if not isinstance(qtype, BaseQuestionType): - msg = f"Package initialization returned '{qtype}', BaseQuestionType expected" - raise PackageInitFailedError(msg) + set_qpy_environment(self._env) + elif not self._env: + self._raise_no_main_package_loaded(msg) - self.main_package = package - self.question_type = qtype + package_interface = package.init(self._env) + if msg.main: + self._question_type = cast(QuestionTypeInterface, package_interface) + self._loaded_packages[str(msg.location)] = package return LoadQPyPackage.Response() def on_msg_get_qpy_package_manifest(self, msg: GetQPyPackageManifest) -> MessageToServer: - if not self.worker_type: + if not self._worker_type: self._raise_not_initialized(msg) - package = self.loaded_packages[msg.path] + package = self._loaded_packages[msg.path] return GetQPyPackageManifest.Response(manifest=package.manifest) def on_msg_get_options_form_definition(self, msg: GetOptionsForm) -> MessageToServer: - if not self.worker_type: + if not self._worker_type: self._raise_not_initialized(msg) - if not self.question_type: + if not self._question_type: self._raise_no_main_package_loaded(msg) with self._with_request_user(msg.request_user): - definition, form_data = self.question_type.get_options_form(msg.question_state) + definition, form_data = self._question_type.get_options_form(msg.question_state) return GetOptionsForm.Response(definition=definition, form_data=form_data) def on_msg_create_question_from_options(self, msg: CreateQuestionFromOptions) -> CreateQuestionFromOptions.Response: - if not self.worker_type: + if not self._worker_type: self._raise_not_initialized(msg) - if not self.question_type: + if not self._question_type: self._raise_no_main_package_loaded(msg) with self._with_request_user(msg.request_user): - question = self.question_type.create_question_from_options(msg.question_state, msg.form_data) + question = self._question_type.create_question_from_options(msg.question_state, msg.form_data) return CreateQuestionFromOptions.Response( question_state=question.export_question_state(), question_model=question.export() ) def on_msg_start_attempt(self, msg: StartAttempt) -> StartAttempt.Response: - if not self.worker_type: + if not self._worker_type: self._raise_not_initialized(msg) - if not self.question_type: + if not self._question_type: self._raise_no_main_package_loaded(msg) with self._with_request_user(msg.request_user): - question = self.question_type.create_question_from_state(msg.question_state) - attempt = question.start_attempt(msg.variant) - return StartAttempt.Response(attempt_state=attempt.export_attempt_state(), attempt_model=attempt.export()) + question = self._question_type.create_question_from_state(msg.question_state) + attempt_started_model = question.start_attempt(msg.variant) + return StartAttempt.Response(attempt_started_model=attempt_started_model) def on_msg_view_attempt(self, msg: ViewAttempt) -> ViewAttempt.Response: - if not self.worker_type: + if not self._worker_type: self._raise_not_initialized(msg) - if not self.question_type: + if not self._question_type: self._raise_no_main_package_loaded(msg) with self._with_request_user(msg.request_user): - question = self.question_type.create_question_from_state(msg.question_state) - attempt = question.get_attempt( - msg.attempt_state, msg.scoring_state, msg.response, compute_score=False, generate_hint=False - ) - if __debug__ and msg.attempt_state != attempt.export_attempt_state(): - warnings.warn( - "The attempt state has been changed by viewing the attempt, which has no effect and " - "should not happen.", - stacklevel=2, - ) - - return ViewAttempt.Response(attempt_model=attempt.export()) + question = self._question_type.create_question_from_state(msg.question_state) + attempt_model = question.get_attempt(msg.attempt_state, msg.scoring_state, msg.response) + return ViewAttempt.Response(attempt_model=attempt_model) def on_msg_score_attempt(self, msg: ScoreAttempt) -> ScoreAttempt.Response: - if not self.worker_type: + if not self._worker_type: self._raise_not_initialized(msg) - if not self.question_type: + if not self._question_type: self._raise_no_main_package_loaded(msg) with self._with_request_user(msg.request_user): - question = self.question_type.create_question_from_state(msg.question_state) - attempt = question.get_attempt( - msg.attempt_state, msg.scoring_state, msg.response, compute_score=True, generate_hint=False - ) - scored_model = attempt.export_scored_attempt() - return ScoreAttempt.Response(attempt_scored_model=scored_model) + question = self._question_type.create_question_from_state(msg.question_state) + attempt_scored_model = question.score_attempt(msg.attempt_state, msg.scoring_state, msg.response) + return ScoreAttempt.Response(attempt_scored_model=attempt_scored_model) + + if __debug__: + + def on_msg_debug_eval(self, msg: DebugEval) -> DebugEval.Response: + locals_ = msg.locals.copy() + result = eval(msg.code, {"manager": self, "env": self._env, **globals()}, locals_) # noqa: S307 + return DebugEval.Response(result=result, locals=locals_) @staticmethod def _raise_not_initialized(msg: MessageToWorker) -> NoReturn: diff --git a/questionpy_server/worker/runtime/messages.py b/questionpy_server/worker/runtime/messages.py index db9ef6f0..ae812899 100644 --- a/questionpy_server/worker/runtime/messages.py +++ b/questionpy_server/worker/runtime/messages.py @@ -8,9 +8,11 @@ from pydantic import BaseModel -from questionpy_common.api.attempt import AttemptModel, AttemptScoredModel -from questionpy_common.api.qtype import InvalidQuestionStateError, OptionsFormDefinition +from questionpy_common.api import PlainMapping +from questionpy_common.api.attempt import AttemptModel, AttemptScoredModel, AttemptStartedModel +from questionpy_common.api.qtype import InvalidQuestionStateError from questionpy_common.api.question import QuestionModel +from questionpy_common.elements import OptionsFormDefinition from questionpy_common.environment import RequestUser, WorkerResourceLimits from questionpy_common.manifest import Manifest from questionpy_server.worker.runtime.package_location import PackageLocation @@ -37,6 +39,9 @@ class MessageIds(IntEnum): VIEW_ATTEMPT = 51 SCORE_ATTEMPT = 52 + if __debug__: + DEBUG_EVAL = 900 + # Worker to server. WORKER_STARTED = 1000 SANDBOX_ENABLED = 1001 @@ -50,6 +55,9 @@ class MessageIds(IntEnum): RETURN_VIEW_ATTEMPT = 1051 RETURN_SCORE_ATTEMPT = 1052 + if __debug__: + RETURN_DEBUG_EVAL = 1900 + ERROR = 1100 @@ -147,7 +155,7 @@ class CreateQuestionFromOptions(MessageToWorker): request_user: RequestUser question_state: str | None """Old question state or ``None`` if the question is new.""" - form_data: dict[str, object] + form_data: PlainMapping class Response(MessageToServer): message_id: ClassVar[MessageIds] = MessageIds.RETURN_CREATE_QUESTION @@ -164,8 +172,7 @@ class StartAttempt(MessageToWorker): class Response(MessageToServer): message_id: ClassVar[MessageIds] = MessageIds.RETURN_START_ATTEMPT - attempt_state: str - attempt_model: AttemptModel + attempt_started_model: AttemptStartedModel class ViewAttempt(MessageToWorker): @@ -194,6 +201,22 @@ class Response(MessageToServer): attempt_scored_model: AttemptScoredModel +if __debug__: + + class DebugEval(MessageToWorker): + message_id: ClassVar[MessageIds] = MessageIds.DEBUG_EVAL + code: str + locals: dict[str, object] = {} + + class Response(MessageToServer): + message_id: ClassVar[MessageIds] = MessageIds.RETURN_DEBUG_EVAL + result: object + locals: dict[str, object] +else: + # So imports needn't by wrapped in 'if __debug__'. + DebugEval = NotImplemented # type: ignore[misc] + + class WorkerError(MessageToServer): """Error message.""" diff --git a/questionpy_server/worker/runtime/package.py b/questionpy_server/worker/runtime/package.py index 3ed084a4..4320f2d1 100644 --- a/questionpy_server/worker/runtime/package.py +++ b/questionpy_server/worker/runtime/package.py @@ -4,6 +4,7 @@ import inspect import json import sys +import types import zipfile from abc import ABC, abstractmethod from functools import cached_property @@ -13,10 +14,11 @@ from types import ModuleType from typing import cast from zipfile import ZipFile +from zipimport import zipimporter -from questionpy_common.api.qtype import BaseQuestionType +from questionpy_common.api.package import QPyPackageInterface from questionpy_common.constants import DIST_DIR, MANIFEST_FILENAME -from questionpy_common.environment import Environment, Package, set_qpy_environment +from questionpy_common.environment import Environment, Package from questionpy_common.manifest import Manifest from questionpy_server.worker.runtime.package_location import ( DirPackageLocation, @@ -38,13 +40,11 @@ class ImportablePackage(ABC, Package): def setup_imports(self) -> None: """Modifies ``sys.path`` to include the package's python code.""" - def init_as_main(self, env: Environment) -> BaseQuestionType: + def init(self, env: Environment) -> QPyPackageInterface: """Imports the package's entrypoint and executes its ``init`` function. :meth:`setup_imports` should be called beforehand to allow the import. """ - set_qpy_environment(env) - main_module: ModuleType if self.manifest.entrypoint: main_module = import_module( @@ -57,10 +57,11 @@ def init_as_main(self, env: Environment) -> BaseQuestionType: raise NoInitFunctionError(main_module) signature = inspect.signature(main_module.init) - if len(signature.parameters) == 0: - return main_module.init() + return main_module.init(*(self, env)[: len(signature.parameters)]) - return main_module.init(env) + @abstractmethod + def owns(self, what: type | types.FunctionType) -> bool: + """Is the given symbol defined by this package?""" class ZipBasedPackage(ZipFile, ImportablePackage): @@ -91,6 +92,10 @@ def setup_imports(self) -> None: if new_path not in sys.path: sys.path.insert(0, new_path) + def owns(self, what: type | types.FunctionType) -> bool: + loader = sys.modules[what.__module__].__loader__ + return isinstance(loader, zipimporter) and Path(loader.archive) == self.path + def __repr__(self) -> str: return f"{type(self).__name__}({self.path})" @@ -119,6 +124,14 @@ def setup_imports(self) -> None: if new_path not in sys.path: sys.path.insert(0, new_path) + def owns(self, what: type | types.FunctionType) -> bool: + try: + file = inspect.getfile(what) + except (TypeError, OSError): + return False + + return self.path in Path(file).parents + def __repr__(self) -> str: return f"{type(self).__name__}({self.path})" @@ -147,19 +160,17 @@ def setup_imports(self) -> None: # Nothing to do. pass - def init_as_main(self, env: Environment) -> BaseQuestionType: - set_qpy_environment(env) - + def init(self, env: Environment) -> QPyPackageInterface: main_module = import_module(self.module_name) init_function = getattr(main_module, self.function_name, None) if not init_function or not callable(init_function): raise NoInitFunctionError(main_module) signature = inspect.signature(init_function) - if len(signature.parameters) == 0: - return init_function() + return init_function(*(self, env)[: len(signature.parameters)]) - return init_function(env) + def owns(self, what: type | types.FunctionType) -> bool: + return what.__module__ == self.module_name and what.__qualname__.startswith(self.module_name + ".") def __repr__(self) -> str: return f"{type(self).__name__}({self.module_name, self.function_name})" diff --git a/questionpy_server/worker/worker/__init__.py b/questionpy_server/worker/worker/__init__.py index eb161e45..66192c86 100644 --- a/questionpy_server/worker/worker/__init__.py +++ b/questionpy_server/worker/worker/__init__.py @@ -5,17 +5,20 @@ from abc import ABC, abstractmethod from dataclasses import dataclass from enum import Enum +from typing import TypeVar -from questionpy_common.api.attempt import AttemptModel, AttemptScoredModel +from questionpy_common.api.attempt import AttemptModel, AttemptScoredModel, AttemptStartedModel from questionpy_common.elements import OptionsFormDefinition from questionpy_common.environment import RequestUser, WorkerResourceLimits from questionpy_common.manifest import PackageFile -from questionpy_server.api.models import AttemptStarted, QuestionCreated +from questionpy_server.api.models import QuestionCreated from questionpy_server.utils.manifest import ComparableManifest from questionpy_server.worker import WorkerResources -from questionpy_server.worker.runtime.messages import MessageToWorker +from questionpy_server.worker.runtime.messages import MessageToServer, MessageToWorker from questionpy_server.worker.runtime.package_location import PackageLocation +_M = TypeVar("_M", bound=MessageToServer) + class WorkerState(Enum): NOT_RUNNING = 1 @@ -71,6 +74,10 @@ async def kill(self) -> None: def send(self, message: MessageToWorker) -> None: """Send a message to the worker.""" + @abstractmethod + async def send_and_wait_for_response(self, message: MessageToWorker, expected_response_message: type[_M]) -> _M: + pass + @abstractmethod async def get_resource_usage(self) -> WorkerResources | None: """Get the worker's current resource usage. If unknown or unsupported, return None.""" @@ -109,7 +116,7 @@ async def create_question_from_options( """ @abstractmethod - async def start_attempt(self, request_user: RequestUser, question_state: str, variant: int) -> AttemptStarted: + async def start_attempt(self, request_user: RequestUser, question_state: str, variant: int) -> AttemptStartedModel: """Start an attempt at this question with the given variant. Args: diff --git a/questionpy_server/worker/worker/base.py b/questionpy_server/worker/worker/base.py index 9abce3bf..98c21e1f 100644 --- a/questionpy_server/worker/worker/base.py +++ b/questionpy_server/worker/worker/base.py @@ -11,12 +11,12 @@ from typing import TYPE_CHECKING, TypeVar from zipfile import ZipFile -from questionpy_common.api.attempt import AttemptModel, AttemptScoredModel +from questionpy_common.api.attempt import AttemptModel, AttemptScoredModel, AttemptStartedModel from questionpy_common.constants import DIST_DIR from questionpy_common.elements import OptionsFormDefinition from questionpy_common.environment import RequestUser, WorkerResourceLimits from questionpy_common.manifest import Manifest, PackageFile -from questionpy_server.api.models import AttemptStarted, QuestionCreated +from questionpy_server.api.models import QuestionCreated from questionpy_server.utils.manifest import ComparableManifest from questionpy_server.worker.exception import WorkerNotRunningError, WorkerStartError from questionpy_server.worker.runtime.messages import ( @@ -48,7 +48,7 @@ from questionpy_server.worker.connection import ServerToWorkerConnection log = logging.getLogger(__name__) -_T = TypeVar("_T", bound=MessageToServer) +_M = TypeVar("_M", bound=MessageToServer) class BaseWorker(Worker, ABC): @@ -73,14 +73,14 @@ async def _initialize(self) -> None: self._observe_task = asyncio.create_task(self._observe(), name="observe worker task") try: - await self._send_and_wait_response( + await self.send_and_wait_for_response( InitWorker( limits=self.limits, worker_type=self._worker_type, ), InitWorker.Response, ) - await self._send_and_wait_response( + await self.send_and_wait_for_response( LoadQPyPackage(location=self.package, main=True), LoadQPyPackage.Response ) except WorkerNotRunningError as e: @@ -92,7 +92,7 @@ def send(self, message: MessageToWorker) -> None: raise WorkerNotRunningError self._connection.send_message(message) - async def _send_and_wait_response(self, message: MessageToWorker, expected_response_message: type[_T]) -> _T: + async def send_and_wait_for_response(self, message: MessageToWorker, expected_response_message: type[_M]) -> _M: self.send(message) fut = asyncio.get_running_loop().create_future() self._expected_incoming_messages.append((expected_response_message.message_id, fut)) @@ -171,29 +171,28 @@ async def stop(self, timeout: float) -> None: async def get_manifest(self) -> ComparableManifest: msg = GetQPyPackageManifest(path=str(self.package)) - ret = await self._send_and_wait_response(msg, GetQPyPackageManifest.Response) + ret = await self.send_and_wait_for_response(msg, GetQPyPackageManifest.Response) return ComparableManifest(**ret.manifest.model_dump()) async def get_options_form( self, request_user: RequestUser, question_state: str | None ) -> tuple[OptionsFormDefinition, dict[str, object]]: msg = GetOptionsForm(question_state=question_state, request_user=request_user) - ret = await self._send_and_wait_response(msg, GetOptionsForm.Response) + ret = await self.send_and_wait_for_response(msg, GetOptionsForm.Response) return ret.definition, ret.form_data async def create_question_from_options( self, request_user: RequestUser, old_state: str | None, form_data: dict[str, object] ) -> QuestionCreated: msg = CreateQuestionFromOptions(question_state=old_state, form_data=form_data, request_user=request_user) - ret = await self._send_and_wait_response(msg, CreateQuestionFromOptions.Response) + ret = await self.send_and_wait_for_response(msg, CreateQuestionFromOptions.Response) return QuestionCreated(question_state=ret.question_state, **ret.question_model.model_dump()) - async def start_attempt(self, request_user: RequestUser, question_state: str, variant: int) -> AttemptStarted: + async def start_attempt(self, request_user: RequestUser, question_state: str, variant: int) -> AttemptStartedModel: msg = StartAttempt(question_state=question_state, variant=variant, request_user=request_user) - ret = await self._send_and_wait_response(msg, StartAttempt.Response) - - return AttemptStarted(attempt_state=ret.attempt_state, **ret.attempt_model.model_dump()) + ret = await self.send_and_wait_for_response(msg, StartAttempt.Response) + return ret.attempt_started_model async def get_attempt( self, @@ -211,7 +210,7 @@ async def get_attempt( response=response, request_user=request_user, ) - ret = await self._send_and_wait_response(msg, ViewAttempt.Response) + ret = await self.send_and_wait_for_response(msg, ViewAttempt.Response) return ret.attempt_model @@ -231,7 +230,7 @@ async def score_attempt( response=response, request_user=request_user, ) - ret = await self._send_and_wait_response(msg, ScoreAttempt.Response) + ret = await self.send_and_wait_for_response(msg, ScoreAttempt.Response) return ret.attempt_scored_model diff --git a/questionpy_server/worker/worker/subprocess.py b/questionpy_server/worker/worker/subprocess.py index cf095348..30b806ef 100644 --- a/questionpy_server/worker/worker/subprocess.py +++ b/questionpy_server/worker/worker/subprocess.py @@ -109,9 +109,9 @@ async def start(self) -> None: # Whether initialization was successful or not, flush the logs. self._stderr_buffer.flush() - async def _send_and_wait_response(self, message: MessageToWorker, expected_response_message: type[_T]) -> _T: + async def send_and_wait_for_response(self, message: MessageToWorker, expected_response_message: type[_T]) -> _T: try: - return await super()._send_and_wait_response(message, expected_response_message) + return await super().send_and_wait_for_response(message, expected_response_message) finally: # Write worker's stderr to log after every exchange. if self._stderr_buffer: diff --git a/questionpy_server/worker/worker/thread.py b/questionpy_server/worker/worker/thread.py index 59ac5372..9d15a845 100644 --- a/questionpy_server/worker/worker/thread.py +++ b/questionpy_server/worker/worker/thread.py @@ -38,6 +38,7 @@ def run(self) -> None: # sys.path isn't thread-local, so this doesn't isolate concurrently running workers, but since the thread worker # is only for testing anyway, it'll do for now. original_path = sys.path.copy() + original_module_names = set(sys.modules.keys()) connection = WorkerToServerConnection(self._pipe.right, self._pipe.right) manager = WorkerManager(connection) @@ -49,8 +50,9 @@ def run(self) -> None: self._loop.call_soon_threadsafe(self._end_event.set) sys.path = original_path - # Having reset the path, this forces the questionpy module to be reloaded upon next import. - sys.modules.pop("questionpy", None) + for module_name in sys.modules.keys() - original_module_names: + # Having reset the path, this forces questionpy and any package modules to be reloaded upon next import. + del sys.modules[module_name] async def wait(self) -> None: await self._end_event.wait() diff --git a/tests/conftest.py b/tests/conftest.py index b0ad1deb..6588a1b7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,7 +14,8 @@ from aiohttp.pytest_plugin import AiohttpClient from aiohttp.test_utils import TestClient -from questionpy_common.constants import DIST_DIR, MANIFEST_FILENAME, KiB +from questionpy_common.constants import DIST_DIR, MANIFEST_FILENAME, KiB, MiB +from questionpy_server import WorkerPool from questionpy_server.app import QPyServer from questionpy_server.settings import ( CollectorSettings, @@ -27,6 +28,7 @@ ) from questionpy_server.utils.manifest import ComparableManifest from questionpy_server.worker.runtime.package_location import DirPackageLocation, ZipPackageLocation +from questionpy_server.worker.worker.subprocess import SubprocessWorker from questionpy_server.worker.worker.thread import ThreadWorker @@ -78,3 +80,8 @@ def qpy_server(tmp_path_factory: pytest.TempPathFactory) -> QPyServer: @pytest.fixture async def client(qpy_server: QPyServer, aiohttp_client: AiohttpClient) -> TestClient: return await aiohttp_client(qpy_server.web_app) + + +@pytest.fixture(params=(SubprocessWorker, ThreadWorker)) +def worker_pool(request: pytest.FixtureRequest) -> WorkerPool: + return WorkerPool(1, 512 * MiB, worker_type=request.param) diff --git a/tests/questionpy_common/test_elements.py b/tests/questionpy_common/test_elements.py index f6ad8fd0..47585a42 100644 --- a/tests/questionpy_common/test_elements.py +++ b/tests/questionpy_common/test_elements.py @@ -1,7 +1,7 @@ # This file is part of QuestionPy. (https://questionpy.org) # QuestionPy is free software released under terms of the MIT license. See LICENSE.md. # (c) Technische Universität Berlin, innoCampus - +import json from io import BytesIO import pytest @@ -40,30 +40,29 @@ ) from tests.conftest import get_file_hash, package_dir, test_data_path -PACKAGE = package_dir / "package_1.qpy" -PACKAGE_HASH = get_file_hash(PACKAGE) +_PACKAGE = package_dir / "package_1.qpy" +_PACKAGE_HASH = get_file_hash(_PACKAGE) -METHOD = "POST" -URL = f"packages/{PACKAGE_HASH}/options" +_METHOD = "POST" +_URL = f"packages/{_PACKAGE_HASH}/options" -path = test_data_path / "question_state" -QUESTION_STATE = (path / "question_state.json").read_text() -QUESTION_STATE_REQUEST = (path / "main.json").read_text() +_QUESTION_STATE = (test_data_path / "question_state" / "question_state.json").read_text() +_REQUEST_MAIN = json.dumps({"context": 1}) async def test_optional_question_state(client: TestClient) -> None: # Even though the question state is optional, the body is still required to be valid JSON. - res = await client.request(METHOD, URL, data=b"{not_valid!}", headers={"Content-Type": "application/json"}) + res = await client.request(_METHOD, _URL, data=b"{not_valid!}", headers={"Content-Type": "application/json"}) assert res.status == 400 async def test_no_package(client: TestClient) -> None: payload = FormData() - payload.add_field("main", QUESTION_STATE_REQUEST) - payload.add_field("question_state", QUESTION_STATE) + payload.add_field("main", _REQUEST_MAIN) + payload.add_field("question_state", _QUESTION_STATE) payload.add_field("ignore", BytesIO()) # Additional fields get ignored. - res = await client.request(METHOD, URL, data=payload) + res = await client.request(_METHOD, _URL, data=payload) assert res.status == 404 res_data = await res.json() @@ -71,23 +70,23 @@ async def test_no_package(client: TestClient) -> None: async def test_data_gets_cached(client: TestClient) -> None: - with PACKAGE.open("rb") as file: + with _PACKAGE.open("rb") as file: payload = FormData() - payload.add_field("main", QUESTION_STATE_REQUEST) - payload.add_field("question_state", QUESTION_STATE) - payload.add_field("package", file, filename=PACKAGE.name) + payload.add_field("main", _REQUEST_MAIN) + payload.add_field("question_state", _QUESTION_STATE) + payload.add_field("package", file, filename=_PACKAGE.name) - res = await client.request(METHOD, URL, data=payload) + res = await client.request(_METHOD, _URL, data=payload) assert res.status == 200 reference = await res.json() OptionsFormDefinition(**reference) payload = FormData() - payload.add_field("main", QUESTION_STATE_REQUEST) - payload.add_field("question_state", QUESTION_STATE) + payload.add_field("main", _REQUEST_MAIN) + payload.add_field("question_state", _QUESTION_STATE) payload.add_field("ignore", BytesIO()) # Additional fields get ignored. - res = await client.request(METHOD, URL, data=payload) + res = await client.request(_METHOD, _URL, data=payload) assert res.status == 200 res_data = await res.json() assert res_data == reference diff --git a/tests/questionpy_server/worker/worker/test_base.py b/tests/questionpy_server/worker/worker/test_base.py index 50e7f3c4..59975593 100644 --- a/tests/questionpy_server/worker/worker/test_base.py +++ b/tests/questionpy_server/worker/worker/test_base.py @@ -8,26 +8,19 @@ import pytest -from questionpy_common.constants import DIST_DIR, MANIFEST_FILENAME, MiB +from questionpy_common.constants import DIST_DIR, MANIFEST_FILENAME from questionpy_common.manifest import PackageFile from questionpy_server import WorkerPool from questionpy_server.worker.runtime.package_location import DirPackageLocation from questionpy_server.worker.worker.base import StaticFileSizeMismatchError -from questionpy_server.worker.worker.subprocess import SubprocessWorker -from questionpy_server.worker.worker.thread import ThreadWorker from tests.conftest import PACKAGE if TYPE_CHECKING: from questionpy_server.worker.worker import Worker -@pytest.fixture(params=(SubprocessWorker, ThreadWorker)) -def pool(request: pytest.FixtureRequest) -> WorkerPool: - return WorkerPool(1, 512 * MiB, worker_type=request.param) - - -async def test_should_get_manifest(pool: WorkerPool) -> None: - async with pool.get_worker(PACKAGE, 1, 1) as worker: +async def test_should_get_manifest(worker_pool: WorkerPool) -> None: + async with worker_pool.get_worker(PACKAGE, 1, 1) as worker: manifest = await worker.get_manifest() assert manifest == PACKAGE.manifest @@ -51,45 +44,45 @@ def _inject_static_file_into_manifest(package: DirPackageLocation, name: str, si _STATIC_FILE_CONTENT = "static example file\n" -async def test_should_get_static_file_from_dir(pool: WorkerPool) -> None: +async def test_should_get_static_file_from_dir(worker_pool: WorkerPool) -> None: with PACKAGE.as_dir_package() as package: _inject_static_file_into_dist(package, _STATIC_FILE_NAME, _STATIC_FILE_CONTENT) _inject_static_file_into_manifest(package, _STATIC_FILE_NAME, len(_STATIC_FILE_CONTENT)) worker: Worker - async with pool.get_worker(package, 1, 1) as worker: + async with worker_pool.get_worker(package, 1, 1) as worker: static_file = await worker.get_static_file(_STATIC_FILE_NAME) assert static_file.data == _STATIC_FILE_CONTENT.encode() assert static_file.mime_type == "text/plain" assert static_file.size == len(_STATIC_FILE_CONTENT) -async def test_should_raise_file_not_found_error_when_not_in_manifest(pool: WorkerPool) -> None: +async def test_should_raise_file_not_found_error_when_not_in_manifest(worker_pool: WorkerPool) -> None: with PACKAGE.as_dir_package() as package: _inject_static_file_into_dist(package, _STATIC_FILE_NAME, _STATIC_FILE_CONTENT) worker: Worker - async with pool.get_worker(package, 1, 1) as worker: + async with worker_pool.get_worker(package, 1, 1) as worker: with pytest.raises(FileNotFoundError): await worker.get_static_file(_STATIC_FILE_NAME) -async def test_should_raise_file_not_found_error_when_not_on_disk(pool: WorkerPool) -> None: +async def test_should_raise_file_not_found_error_when_not_on_disk(worker_pool: WorkerPool) -> None: with PACKAGE.as_dir_package() as package: _inject_static_file_into_manifest(package, _STATIC_FILE_NAME, len(_STATIC_FILE_CONTENT)) worker: Worker - async with pool.get_worker(package, 1, 1) as worker: + async with worker_pool.get_worker(package, 1, 1) as worker: with pytest.raises(FileNotFoundError): await worker.get_static_file(_STATIC_FILE_NAME) -async def test_should_raise_static_file_size_mismatch_error_when_sizes_dont_match(pool: WorkerPool) -> None: +async def test_should_raise_static_file_size_mismatch_error_when_sizes_dont_match(worker_pool: WorkerPool) -> None: with PACKAGE.as_dir_package() as package: _inject_static_file_into_dist(package, _STATIC_FILE_NAME, _STATIC_FILE_CONTENT) _inject_static_file_into_manifest(package, _STATIC_FILE_NAME, 1234) worker: Worker - async with pool.get_worker(package, 1, 1) as worker: + async with worker_pool.get_worker(package, 1, 1) as worker: with pytest.raises(StaticFileSizeMismatchError): await worker.get_static_file(_STATIC_FILE_NAME) diff --git a/tests/test_data/package/package_1.qpy b/tests/test_data/package/package_1.qpy index 589f6d18..cb31d6c6 100644 Binary files a/tests/test_data/package/package_1.qpy and b/tests/test_data/package/package_1.qpy differ diff --git a/tests/test_data/package/package_2.qpy b/tests/test_data/package/package_2.qpy index deb6726f..879f69b8 100644 Binary files a/tests/test_data/package/package_2.qpy and b/tests/test_data/package/package_2.qpy differ diff --git a/tests/test_data/question_state/main.json b/tests/test_data/question_state/main.json deleted file mode 100644 index ffdfbaec..00000000 --- a/tests/test_data/question_state/main.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "context": null -} \ No newline at end of file diff --git a/tests/test_data/question_state/question_state.json b/tests/test_data/question_state/question_state.json index 719f592b..ce9c1dad 100644 --- a/tests/test_data/question_state/question_state.json +++ b/tests/test_data/question_state/question_state.json @@ -7,5 +7,7 @@ "my_repetition": [], "name_group": {} }, - "example": "question_state" + "state": { + "example": "question_state" + } } diff --git a/tests/worker/runtime/__init__.py b/tests/worker/runtime/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/worker/runtime/test_manager.py b/tests/worker/runtime/test_manager.py new file mode 100644 index 00000000..9c4f60ab --- /dev/null +++ b/tests/worker/runtime/test_manager.py @@ -0,0 +1,33 @@ +from typing import TYPE_CHECKING + +from questionpy_server import WorkerPool +from questionpy_server.worker.runtime.messages import DebugEval +from tests.conftest import PACKAGE + +if TYPE_CHECKING: + from questionpy_server.worker.worker import Worker + +_GET_PACKAGE_CODE = "repr(get_qpy_environment().get_package_defining(manager._question_type._question_class))" + + +async def test_environment_should_get_defining_package_from_zip(worker_pool: WorkerPool) -> None: + worker: Worker + async with worker_pool.get_worker(PACKAGE, 1, 1) as worker: + response = await worker.send_and_wait_for_response( + DebugEval(code=_GET_PACKAGE_CODE), + DebugEval.Response, + ) + + assert response.result == f"ZipBasedPackage({PACKAGE.path})" + + +async def test_environment_should_get_defining_package_from_dir(worker_pool: WorkerPool) -> None: + worker: Worker + with PACKAGE.as_dir_package() as package: + async with worker_pool.get_worker(package, 1, 1) as worker: + response = await worker.send_and_wait_for_response( + DebugEval(code=_GET_PACKAGE_CODE), + DebugEval.Response, + ) + + assert response.result == f"DirBasedPackage({package.path})"