From a9e2c5928271a1fc1ec101b0dd850e19ce27f984 Mon Sep 17 00:00:00 2001 From: Maximilian Haye Date: Thu, 20 Jun 2024 18:03:13 +0200 Subject: [PATCH] feat!: implement server-package API changes --- poetry.lock | 34 ++-- pyproject.toml | 2 +- questionpy_common/api/__init__.py | 20 +++ questionpy_common/api/attempt.py | 63 +++++--- questionpy_common/api/package.py | 23 +++ questionpy_common/api/qtype.py | 22 ++- questionpy_common/api/question.py | 47 ++++-- questionpy_common/environment.py | 15 +- questionpy_server/api/models.py | 5 - questionpy_server/worker/runtime/manager.py | 152 ++++++++++-------- questionpy_server/worker/runtime/messages.py | 33 +++- questionpy_server/worker/runtime/package.py | 39 +++-- questionpy_server/worker/worker/__init__.py | 15 +- questionpy_server/worker/worker/base.py | 29 ++-- questionpy_server/worker/worker/subprocess.py | 4 +- questionpy_server/worker/worker/thread.py | 6 +- tests/conftest.py | 9 +- tests/questionpy_common/test_elements.py | 39 +++-- .../worker/worker/test_base.py | 29 ++-- tests/test_data/package/package_1.qpy | Bin 25539 -> 28482 bytes tests/test_data/package/package_2.qpy | Bin 25539 -> 28483 bytes tests/test_data/question_state/main.json | 3 - .../question_state/question_state.json | 4 +- tests/worker/runtime/__init__.py | 0 tests/worker/runtime/test_manager.py | 33 ++++ 25 files changed, 398 insertions(+), 228 deletions(-) create mode 100644 questionpy_common/api/package.py delete mode 100644 tests/test_data/question_state/main.json create mode 100644 tests/worker/runtime/__init__.py create mode 100644 tests/worker/runtime/test_manager.py 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 589f6d18027e1905faa842e50d59b9b22212b872..cb31d6c63749b1eaa58bacb198de7e0aa1e8ccfe 100644 GIT binary patch delta 11694 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 delta 9235 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 UX4J^E09rsK5&)26@>}}<0Y1)`qW}N^ delta 9450 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 uge~U3>_PYs@hX1+pRLee=d@^t2M*A9 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})"