diff --git a/questionpy_common/api/qtype.py b/questionpy_common/api/qtype.py index a64aabfa..f6063677 100644 --- a/questionpy_common/api/qtype.py +++ b/questionpy_common/api/qtype.py @@ -4,6 +4,7 @@ from __future__ import annotations from abc import abstractmethod +from enum import Enum from typing import TYPE_CHECKING, Protocol from questionpy_common.api.package import BasePackageInterface @@ -19,6 +20,8 @@ __all__ = [ "InvalidAttemptStateError", "InvalidQuestionStateError", + "MigrationError", + "MigrationErrorKind", "OptionsFormValidationError", "QuestionTypeInterface", ] @@ -61,12 +64,24 @@ def create_question_from_state(self, question_state: str) -> QuestionInterface: InvalidQuestionStateError: When the given question state is invalid and cannot be reused. """ + @abstractmethod + def upgrade(self, question_state: str) -> str: + """Upgrade the given question state to the question state version of the main package.""" + + @abstractmethod + def downgrade(self, question_state: str, to: int) -> str: + """Downgrade the given question state to the provided question state version of the main package.""" + + @abstractmethod + def sidegrade(self, question_state: str) -> str: + """Sidegrade the given question state to the version used by the main package.""" + class OptionsFormValidationError(QPyBaseError): - def __init__(self, errors: dict[str, str]): + def __init__(self, errors: dict[str, str], reason: str | None = None, temporary: bool = False): # noqa: FBT001, FBT002 """There was at least one validation error.""" self.errors = errors # input element name -> error description - super().__init__("Form input data could not be validated successfully.") + super().__init__("Form input data could not be validated successfully.", reason=reason, temporary=temporary) class InvalidAttemptStateError(QPyBaseError): @@ -75,3 +90,23 @@ class InvalidAttemptStateError(QPyBaseError): class InvalidQuestionStateError(QPyBaseError): """Error to raise when your package cannot parse the question state it is given.""" + + +class MigrationErrorKind(Enum): + NOT_IMPLEMENTED = "NOT_IMPLEMENTED" + NOT_POSSIBLE = "NOT_POSSIBLE" + PACKAGE_MISSMATCH = "PACKAGE_MISSMATCH" + QUESTION_STATE_INVALID = "QUESTION_STATE_INVALID" + FAILED = "FAILED" + DISCOVERY_ERROR = "DISCOVERY_ERROR" + OTHER_ERROR = "OTHER_ERROR" + + +class MigrationError(QPyBaseError): + """The migration failed.""" + + def __init__( + self, *args: object, kind: MigrationErrorKind, reason: str | None = None, temporary: bool = False + ) -> None: + self.kind = kind + super().__init__(*args, reason=reason, temporary=temporary) diff --git a/questionpy_common/manifest.py b/questionpy_common/manifest.py index 39370c6c..4d3293ab 100644 --- a/questionpy_common/manifest.py +++ b/questionpy_common/manifest.py @@ -7,7 +7,16 @@ from keyword import iskeyword, issoftkeyword from typing import Annotated, NewType -from pydantic import BaseModel, ByteSize, PositiveInt, StringConstraints, conset, field_validator +from pydantic import ( + AfterValidator, + BaseModel, + ByteSize, + NonNegativeInt, + PositiveInt, + StringConstraints, + conset, + field_validator, +) from pydantic.fields import Field from questionpy_common.constants import ENVIRONMENT_VARIABLE_REGEX @@ -79,6 +88,10 @@ def ensure_is_valid_name(name: str) -> str: Bcp47LanguageTag = NewType("Bcp47LanguageTag", str) +type Namespace = Annotated[str, AfterValidator(ensure_is_valid_name)] +type ShortName = Namespace + + class PartialPackagePermissions(BaseModel): cpus: int | None = None memory: ByteSize | None = None @@ -97,8 +110,8 @@ class SourceManifest(BaseModel): These fields are valid inside a package's configuration file. """ - short_name: str - namespace: str = DEFAULT_NAMESPACE + short_name: ShortName + namespace: Namespace = DEFAULT_NAMESPACE version: Annotated[str, Field(pattern=RE_SEMVER)] api_version: Annotated[str, Field(pattern=RE_API)] author: str @@ -120,11 +133,6 @@ class SourceManifest(BaseModel): tags: set[str] = set() requirements: str | list[str] | None = None - @field_validator("short_name", "namespace") - @classmethod - def ensure_is_valid_name(cls, value: str) -> str: - return ensure_is_valid_name(value) - @field_validator("languages", "name") @classmethod def ensure_contains_english_translation( @@ -170,3 +178,6 @@ class Manifest(SourceManifest): static_files: dict[str, PackageFile] = {} dependencies: DistDependencies = DistDependencies() + + state_version: NonNegativeInt = 0 + possible_side_migrations: dict[Namespace, dict[ShortName, set[NonNegativeInt]]] = {} diff --git a/questionpy_server/models.py b/questionpy_server/models.py index 6746d208..de19c1f7 100644 --- a/questionpy_server/models.py +++ b/questionpy_server/models.py @@ -8,6 +8,7 @@ from pydantic import BaseModel, ByteSize, ConfigDict, Field from questionpy_common.api.attempt import AttemptModel, AttemptScoredModel, AttemptStartedModel +from questionpy_common.api.qtype import MigrationErrorKind from questionpy_common.api.question import LmsPermissions, QuestionModel from questionpy_common.elements import OptionsFormDefinition from questionpy_common.environment import LmsProvidedAttributes as EnvironmentLmsProvidedAttributes @@ -84,6 +85,23 @@ class QuestionCreated(QuestionModel): question_state: str +class QuestionUpgradeArguments(RequestBaseData): + question_state: str + + +class QuestionDowngradeArguments(RequestBaseData): + question_state: str + to: int + + +class QuestionSidegradeArguments(RequestBaseData): + question_state: str + + +class QuestionMigrated(BaseModel): + question_state: str + + class LmsProvidedAttributes(BaseModel): lms_provided_attributes: EnvironmentLmsProvidedAttributes | None = None @@ -128,6 +146,7 @@ class RequestErrorCode(Enum): INVALID_OPTIONS_FORM = "INVALID_OPTIONS_FORM" PACKAGE_ERROR = "PACKAGE_ERROR" PACKAGE_NOT_FOUND = "PACKAGE_NOT_FOUND" + MIGRATION_ERROR = "MIGRATION_ERROR" CALLBACK_API_ERROR = "CALLBACK_API_ERROR" SERVER_ERROR = "SERVER_ERROR" @@ -144,20 +163,8 @@ class OptionsFormValidationError(RequestError): errors: dict[str, str] -class QuestionStateMigrationErrorCode(Enum): - NOT_IMPLEMENTED = "NOT_IMPLEMENTED" - DOWNGRADE_NOT_POSSIBLE = "DOWNGRADE_NOT_POSSIBLE" - PACKAGE_MISMATCH = "PACKAGE_MISMATCH" - CURRENT_QUESTION_STATE_INVALID = "CURRENT_QUESTION_STATE_INVALID" - MAJOR_VERSION_MISMATCH = "MAJOR_VERSION_MISMATCH" - OTHER_ERROR = "OTHER_ERROR" - - -class QuestionStateMigrationError(BaseModel): - model_config = ConfigDict(use_enum_values=True) - - error_code: QuestionStateMigrationErrorCode - reason: str | None = None +class MigrationError(RequestError): + kind: MigrationErrorKind class Usage(BaseModel): diff --git a/questionpy_server/web/_routes/_packages.py b/questionpy_server/web/_routes/_packages.py index 8b336f7c..3d5cb5f3 100644 --- a/questionpy_server/web/_routes/_packages.py +++ b/questionpy_server/web/_routes/_packages.py @@ -3,10 +3,16 @@ # (c) Technische Universität Berlin, innoCampus from aiohttp import web -from aiohttp.web_exceptions import HTTPMethodNotAllowed from questionpy_common.api.question import LmsPermissions -from questionpy_server.models import QuestionCreateArguments, QuestionEditFormResponse, RequestBaseData +from questionpy_server.models import ( + QuestionCreateArguments, + QuestionDowngradeArguments, + QuestionEditFormResponse, + QuestionSidegradeArguments, + QuestionUpgradeArguments, + RequestBaseData, +) from questionpy_server.package import Package from questionpy_server.web._decorators import ensure_package, ensure_required_parts from questionpy_server.web._utils import pydantic_json_response @@ -74,10 +80,41 @@ async def post_question( return pydantic_json_response(data=question) -@package_routes.post(r"/packages/{package_hash:\w+}/question/migrate") -async def post_question_migrate(_request: web.Request) -> web.Response: - method = "POST" - raise HTTPMethodNotAllowed(method, []) +@package_routes.post(r"/packages/{package_hash:\w+}/question/upgrade") +@ensure_required_parts +async def post_question_upgrade(request: web.Request, package: Package, data: QuestionUpgradeArguments) -> web.Response: + async with worker_context(request, package, data) as context: + new_question_state = await context.worker.upgrade_question( + context.request_info, + data.question_state, + ) + + return pydantic_json_response(data=new_question_state) + + +@package_routes.post(r"/packages/{package_hash:\w+}/question/downgrade") +@ensure_required_parts +async def post_question_downgrade( + request: web.Request, package: Package, data: QuestionDowngradeArguments +) -> web.Response: + async with worker_context(request, package, data) as context: + new_question_state = await context.worker.downgrade_question(context.request_info, data.question_state, data.to) + + return pydantic_json_response(data=new_question_state) + + +@package_routes.post(r"/packages/{package_hash:\w+}/question/sidegrade") +@ensure_required_parts +async def post_question_sidegrade( + request: web.Request, package: Package, data: QuestionSidegradeArguments +) -> web.Response: + async with worker_context(request, package, data) as context: + new_question_state = await context.worker.sidegrade_question( + context.request_info, + data.question_state, + ) + + return pydantic_json_response(data=new_question_state) @package_routes.post(r"/package-extract-info") diff --git a/questionpy_server/web/errors.py b/questionpy_server/web/errors.py index 7a8eb626..131665e2 100644 --- a/questionpy_server/web/errors.py +++ b/questionpy_server/web/errors.py @@ -6,6 +6,8 @@ from aiohttp import web from aiohttp.log import web_logger +from questionpy_common.api.qtype import MigrationErrorKind +from questionpy_server.models import MigrationError as MigrationErrorModel from questionpy_server.models import OptionsFormValidationError, RequestError, RequestErrorCode @@ -156,6 +158,19 @@ def __init__(self, *, reason: str | None, temporary: bool) -> None: ) +class MigrationError(web.HTTPUnprocessableEntity, _ExceptionMixin): + def __init__(self, *, reason: str | None, temporary: bool, kind: MigrationErrorKind) -> None: + super().__init__( + "Migration failed or is not possible.", + MigrationErrorModel( + error_code=RequestErrorCode.MIGRATION_ERROR, + temporary=temporary, + reason=reason, + kind=kind, + ), + ) + + class ServerError(web.HTTPInternalServerError): def __init__(self, *, reason: str | None, temporary: bool) -> None: body = RequestError( diff --git a/questionpy_server/web/middlewares/_error.py b/questionpy_server/web/middlewares/_error.py index 332ee493..705f3115 100644 --- a/questionpy_server/web/middlewares/_error.py +++ b/questionpy_server/web/middlewares/_error.py @@ -9,7 +9,12 @@ from aiohttp.web_response import StreamResponse import questionpy_server.web.errors as web_error -from questionpy_common.api.qtype import InvalidAttemptStateError, InvalidQuestionStateError, OptionsFormValidationError +from questionpy_common.api.qtype import ( + InvalidAttemptStateError, + InvalidQuestionStateError, + MigrationError, + OptionsFormValidationError, +) from questionpy_common.error import QPyBaseError from questionpy_server.utils.manifest import ManifestError from questionpy_server.worker.exception import ( @@ -49,6 +54,8 @@ async def error_middleware(request: Request, handler: Handler) -> StreamResponse raise exception(reason=e.reason, temporary=e.temporary) from e except OptionsFormValidationError as e: raise web_error.InvalidOptionsFormError(reason=e.reason, errors=e.errors) from e + except MigrationError as e: + raise web_error.MigrationError(reason=e.reason, temporary=e.temporary, kind=e.kind) from e except Exception as e: web_logger.exception("There was an unexpected error while processing the request.") raise web_error.ServerError(reason="unknown", temporary=True) from e diff --git a/questionpy_server/worker/__init__.py b/questionpy_server/worker/__init__.py index ece7f186..83a9680a 100644 --- a/questionpy_server/worker/__init__.py +++ b/questionpy_server/worker/__init__.py @@ -14,7 +14,7 @@ from questionpy_common.elements import OptionsFormDefinition from questionpy_common.environment import PackagePermissions, RequestInfo from questionpy_common.manifest import PackageFile -from questionpy_server.models import LoadedPackage, QuestionCreated +from questionpy_server.models import LoadedPackage, QuestionCreated, QuestionMigrated from questionpy_server.utils.manifest import ComparableManifest from questionpy_server.worker.runtime.messages import MessageToServer, MessageToWorker from questionpy_server.worker.runtime.package_location import PackageLocation @@ -151,6 +151,46 @@ async def create_question_from_options( New question. """ + @abstractmethod + async def upgrade_question(self, request_info: RequestInfo, question_state: str) -> QuestionMigrated: + """Upgrade the given question state to the question state version of this package. + + Args: + request_info: Information about the current request. + question_state: The question state which should be upgraded. + + Returns: + Migrated question state. + """ + + @abstractmethod + async def downgrade_question(self, request_info: RequestInfo, question_state: str, to: int) -> QuestionMigrated: + """Downgrade the given question state to the question state version of this package. + + Args: + request_info: Information about the current request. + question_state: The question state with the same question state version as this package which should be + downgraded. + to: The question state version to downgrade to. + + Returns: + Migrated question state. + """ + + @abstractmethod + async def sidegrade_question(self, request_info: RequestInfo, question_state: str) -> QuestionMigrated: + """Sidegrade the given question state to the question state version of this package. + + The question state can origin from a different package. + + Args: + request_info: Information about the current request. + question_state: The question state which should be upgraded. + + Returns: + Migrated question state. + """ + @abstractmethod async def start_attempt(self, request_info: RequestInfo, question_state: str, variant: int) -> AttemptStartedModel: """Start an attempt at this question with the given variant. diff --git a/questionpy_server/worker/impl/_base.py b/questionpy_server/worker/impl/_base.py index 38660703..25b50e59 100644 --- a/questionpy_server/worker/impl/_base.py +++ b/questionpy_server/worker/impl/_base.py @@ -20,7 +20,7 @@ from questionpy_common.elements import OptionsFormDefinition from questionpy_common.environment import RequestInfo from questionpy_common.manifest import Manifest, PackageFile -from questionpy_server.models import LoadedPackage, QuestionCreated +from questionpy_server.models import LoadedPackage, QuestionCreated, QuestionMigrated from questionpy_server.utils.manifest import ComparableManifest from questionpy_server.worker import PackageFileData, Worker, WorkerArgs, WorkerState from questionpy_server.worker.exception import ( @@ -34,6 +34,7 @@ from questionpy_server.worker.runtime.messages import ( BaseWorkerError, CreateQuestionFromOptions, + DowngradeQuestion, Exit, GetOptionsForm, GetQPyPackageManifest, @@ -43,7 +44,9 @@ MessageToServer, MessageToWorker, ScoreAttempt, + SidegradeQuestion, StartAttempt, + UpgradeQuestion, ViewAttempt, WorkerError, ) @@ -251,6 +254,34 @@ async def create_question_from_options( question_state=ret.question_state, lms_permissions=lms_permissions, **ret.question_model.model_dump() ) + async def upgrade_question(self, request_info: RequestInfo, question_state: str) -> QuestionMigrated: + msg = UpgradeQuestion( + request_info=request_info, + question_state=question_state, + ) + ret = await self.send_and_wait_for_response(msg, UpgradeQuestion.Response) + + return QuestionMigrated(question_state=ret.question_state) + + async def downgrade_question(self, request_info: RequestInfo, question_state: str, to: int) -> QuestionMigrated: + msg = DowngradeQuestion( + request_info=request_info, + question_state=question_state, + target_question_state_version=to, + ) + ret = await self.send_and_wait_for_response(msg, DowngradeQuestion.Response) + + return QuestionMigrated(question_state=ret.question_state) + + async def sidegrade_question(self, request_info: RequestInfo, question_state: str) -> QuestionMigrated: + msg = SidegradeQuestion( + request_info=request_info, + question_state=question_state, + ) + ret = await self.send_and_wait_for_response(msg, SidegradeQuestion.Response) + + return QuestionMigrated(question_state=ret.question_state) + async def start_attempt(self, request_info: RequestInfo, question_state: str, variant: int) -> AttemptStartedModel: msg = StartAttempt(question_state=question_state, variant=variant, request_info=request_info) ret = await self.send_and_wait_for_response(msg, StartAttempt.Response) diff --git a/questionpy_server/worker/runtime/manager.py b/questionpy_server/worker/runtime/manager.py index 58b99b21..baed6e0c 100644 --- a/questionpy_server/worker/runtime/manager.py +++ b/questionpy_server/worker/runtime/manager.py @@ -26,6 +26,7 @@ from questionpy_server.worker.runtime.connection import WorkerToServerConnection from questionpy_server.worker.runtime.messages import ( CreateQuestionFromOptions, + DowngradeQuestion, Exit, GetOptionsForm, GetQPyPackageManifest, @@ -35,7 +36,9 @@ MessageToServer, MessageToWorker, ScoreAttempt, + SidegradeQuestion, StartAttempt, + UpgradeQuestion, ViewAttempt, WorkerError, ) @@ -114,6 +117,9 @@ def __init__(self, server_connection: WorkerToServerConnection): GetQPyPackageManifest.message_id: self.on_msg_get_qpy_package_manifest, GetOptionsForm.message_id: self.on_msg_get_options_form_definition, CreateQuestionFromOptions.message_id: self.on_msg_create_question_from_options, + UpgradeQuestion.message_id: self.on_msg_upgrade_question, + DowngradeQuestion.message_id: self.on_msg_downgrade_question, + SidegradeQuestion.message_id: self.on_msg_sidegrade_question, StartAttempt.message_id: self.on_msg_start_attempt, ViewAttempt.message_id: self.on_msg_view_attempt, ScoreAttempt.message_id: self.on_msg_score_attempt, @@ -255,6 +261,38 @@ def on_msg_create_question_from_options(self, msg: CreateQuestionFromOptions) -> question_state=question.export_question_state(), question_model=question.export() ) + def on_msg_upgrade_question(self, msg: UpgradeQuestion) -> UpgradeQuestion.Response: + if not self._env: + self._raise_not_initialized(msg) + if not self._question_type: + self._raise_no_main_package_loaded(msg) + + with self._with_request_info(msg, msg.request_info): + migrated_question_state = self._question_type.upgrade(msg.question_state) + return UpgradeQuestion.Response(question_state=migrated_question_state) + + def on_msg_downgrade_question(self, msg: DowngradeQuestion) -> DowngradeQuestion.Response: + if not self._env: + self._raise_not_initialized(msg) + if not self._question_type: + self._raise_no_main_package_loaded(msg) + + with self._with_request_info(msg, msg.request_info): + migrated_question_state = self._question_type.downgrade( + msg.question_state, msg.target_question_state_version + ) + return DowngradeQuestion.Response(question_state=migrated_question_state) + + def on_msg_sidegrade_question(self, msg: SidegradeQuestion) -> SidegradeQuestion.Response: + if not self._env: + self._raise_not_initialized(msg) + if not self._question_type: + self._raise_no_main_package_loaded(msg) + + with self._with_request_info(msg, msg.request_info): + migrated_question_state = self._question_type.sidegrade(msg.question_state) + return SidegradeQuestion.Response(question_state=migrated_question_state) + def on_msg_start_attempt(self, msg: StartAttempt) -> StartAttempt.Response: if not self._env: self._raise_not_initialized(msg) diff --git a/questionpy_server/worker/runtime/messages.py b/questionpy_server/worker/runtime/messages.py index e4447b8b..365997da 100644 --- a/questionpy_server/worker/runtime/messages.py +++ b/questionpy_server/worker/runtime/messages.py @@ -11,7 +11,7 @@ from pydantic import BaseModel, JsonValue from questionpy_common.api.attempt import AttemptModel, AttemptScoredModel, AttemptStartedModel -from questionpy_common.api.qtype import InvalidQuestionStateError, OptionsFormValidationError +from questionpy_common.api.qtype import InvalidQuestionStateError, MigrationError, OptionsFormValidationError from questionpy_common.api.question import QuestionModel from questionpy_common.elements import OptionsFormDefinition from questionpy_common.environment import PackageNamespaceAndShortName, PackagePermissions, RequestInfo @@ -35,7 +35,11 @@ class MessageIds(IntEnum): LOAD_QPY_PACKAGE = 10 GET_QPY_PACKAGE_MANIFEST = 20 GET_OPTIONS_FORM_DEFINITION = 30 + CREATE_QUESTION = 40 + UPGRADE_QUESTION = 41 + DOWNGRADE_QUESTION = 42 + SIDEGRADE_QUESTION = 43 START_ATTEMPT = 50 VIEW_ATTEMPT = 51 @@ -48,7 +52,11 @@ class MessageIds(IntEnum): LOADED_QPY_PACKAGE = 1010 RETURN_QPY_PACKAGE_MANIFEST = 1020 RETURN_OPTIONS_FORM_DEFINITION = 1030 + RETURN_CREATE_QUESTION = 1040 + RETURN_UPGRADE_QUESTION = 1041 + RETURN_DOWNGRADE_QUESTION = 1042 + RETURN_SIDEGRADE_QUESTION = 1043 RETURN_START_ATTEMPT = 1050 RETURN_VIEW_ATTEMPT = 1051 @@ -207,6 +215,37 @@ class Response(MessageToServer): attempt_scored_model: AttemptScoredModel +class UpgradeQuestion(MessageToWorker): + message_id: ClassVar[MessageIds] = MessageIds.UPGRADE_QUESTION + request_info: RequestInfo + question_state: str + + class Response(MessageToServer): + message_id: ClassVar[MessageIds] = MessageIds.RETURN_UPGRADE_QUESTION + question_state: str + + +class DowngradeQuestion(MessageToWorker): + message_id: ClassVar[MessageIds] = MessageIds.DOWNGRADE_QUESTION + request_info: RequestInfo + question_state: str + target_question_state_version: int + + class Response(MessageToServer): + message_id: ClassVar[MessageIds] = MessageIds.RETURN_DOWNGRADE_QUESTION + question_state: str + + +class SidegradeQuestion(MessageToWorker): + message_id: ClassVar[MessageIds] = MessageIds.SIDEGRADE_QUESTION + request_info: RequestInfo + question_state: str + + class Response(MessageToServer): + message_id: ClassVar[MessageIds] = MessageIds.RETURN_SIDEGRADE_QUESTION + question_state: str + + class WorkerError(MessageToServer): """Error message.""" @@ -217,12 +256,13 @@ class ErrorType(StrEnum): MEMORY_EXCEEDED = auto() QUESTION_STATE_INVALID = auto() FORM_OPTIONS_INVALID = auto() + MIGRATION_ERROR = auto() message_id: ClassVar[MessageIds] = MessageIds.ERROR expected_response_id: MessageIds type: ErrorType + exception_kwargs: dict[str, Any] = {} message: str | None - error_data: dict[str, str] | None = None original_stacktrace: str | None = None """The original worker-side stacktrace.""" @@ -230,15 +270,19 @@ class ErrorType(StrEnum): @classmethod def from_exception(cls, error: Exception, cause: MessageToWorker) -> "WorkerError": """Get a WorkerError message from an exception.""" - error_data: dict[str, str] | None = None + kwargs: dict[str, Any] = {} if isinstance(error, MemoryError): error_type = WorkerError.ErrorType.MEMORY_EXCEEDED elif isinstance(error, InvalidQuestionStateError): error_type = WorkerError.ErrorType.QUESTION_STATE_INVALID + kwargs = {"reason": error.reason, "temporary": error.temporary} elif isinstance(error, OptionsFormValidationError): error_type = WorkerError.ErrorType.FORM_OPTIONS_INVALID - error_data = error.errors + kwargs = {"errors": error.errors, "reason": error.reason, "temporary": error.temporary} + elif isinstance(error, MigrationError): + error_type = WorkerError.ErrorType.MIGRATION_ERROR + kwargs = {"kind": error.kind, "reason": error.reason, "temporary": error.temporary} else: error_type = WorkerError.ErrorType.UNKNOWN @@ -252,7 +296,7 @@ def from_exception(cls, error: Exception, cause: MessageToWorker) -> "WorkerErro message=str(error), expected_response_id=cause.Response.message_id, original_stacktrace=original_stacktrace, - error_data=error_data, + exception_kwargs=kwargs, ) def to_exception(self, worker_name: str) -> Exception: @@ -263,7 +307,9 @@ def to_exception(self, worker_name: str) -> Exception: elif self.type == WorkerError.ErrorType.QUESTION_STATE_INVALID: error = InvalidQuestionStateError(self.message) elif self.type == WorkerError.ErrorType.FORM_OPTIONS_INVALID: - error = OptionsFormValidationError(self.error_data or {}) + error = OptionsFormValidationError(**self.exception_kwargs) + elif self.type == WorkerError.ErrorType.MIGRATION_ERROR: + error = MigrationError(self.message, **self.exception_kwargs) else: error = WorkerUnknownError(self.message, worker_name=worker_name) diff --git a/tests/test_data/factories.py b/tests/test_data/factories.py index 21ba16c1..814d67ee 100644 --- a/tests/test_data/factories.py +++ b/tests/test_data/factories.py @@ -7,6 +7,7 @@ from polyfactory import Use from polyfactory.factories.pydantic_factory import ModelFactory +from pydantic import BaseModel from semver import Version from questionpy_common.manifest import Bcp47LanguageTag, PartialPackagePermissions @@ -14,7 +15,7 @@ from questionpy_server.utils.manifest import ComparableManifest -class CustomFactory(ModelFactory[Any]): +class CustomFactory[T: BaseModel](ModelFactory[T]): """Custom factory base class adding support for :class:`Version` fields.""" __is_base_factory__ = True @@ -24,16 +25,14 @@ def get_provider_map(cls) -> dict[Any, Callable[[], Any]]: return {**super().get_provider_map(), Version: lambda: cls.__faker__.numerify(text="#.#.#")} -class RepoMetaFactory(ModelFactory): - __model__ = RepoMeta +class RepoMetaFactory(ModelFactory[RepoMeta]): ... -class RepoPackageVersionsFactory(CustomFactory): - __model__ = RepoPackageVersions +class RepoPackageVersionsFactory(CustomFactory[RepoPackageVersions]): ... -class ManifestFactory(CustomFactory): - __model__ = ComparableManifest +class ManifestFactory(CustomFactory[ComparableManifest]): + __set_as_default_factory_for_type__ = True short_name = Use(lambda: ModelFactory.__faker__.word().lower() + "_sn") namespace = Use(lambda: ModelFactory.__faker__.word().lower() + "_ns") @@ -42,3 +41,5 @@ class ManifestFactory(CustomFactory): url = Use(ModelFactory.__faker__.url) icon = None permissions = PartialPackagePermissions() + state_version = 0 + possible_side_migrations: dict[str, dict[str, int]] = {} diff --git a/tests/test_data/package/package_1.qpy b/tests/test_data/package/package_1.qpy index da022699..4198c29b 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 322e126f..e2d99e0b 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/question_state.json b/tests/test_data/question_state/question_state.json index 0861c6fb..1a20d746 100644 --- a/tests/test_data/question_state/question_state.json +++ b/tests/test_data/question_state/question_state.json @@ -1,5 +1,6 @@ { - "package_name": "example", + "package_namespace": "example_namespace", + "package_short_name": "example_short_name", "package_version": "0.1.0", "options": { "input": "bar", @@ -9,5 +10,6 @@ }, "state": { "example": "question_state" - } + }, + "state_version": 0 }