Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Grundlagen für I18N #136

Open
wants to merge 10 commits into
base: dev
Choose a base branch
from
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ extend = "ruff_defaults.toml"
# unused-async (aiohttp handlers must be async even if they don't use it)
"**/questionpy_server/web/**/*" = ["RUF029"]

[tool.ruff.lint.pylint]
allow-dunder-method-names = ["__get_pydantic_core_schema__"]

[tool.pytest.ini_options]
# https://github.com/pytest-dev/pytest-asyncio#auto-mode
asyncio_mode = "auto"
Expand Down
24 changes: 24 additions & 0 deletions questionpy_common/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,27 @@
# 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 <[email protected]>

from collections.abc import Mapping
from typing import Protocol, runtime_checkable

from pydantic_core import CoreSchema, core_schema


@runtime_checkable
class TranslatableString(Protocol):
"""Protocol for strings which may be lazily retrieved, such as deferred translations."""

def __str__(self) -> str:
"""Translate this string."""

def format(self, *args: object, **kwargs: object) -> "str | TranslatableString":
"""Perform the same formatting as [`str.format`][], possibly lazily."""

def format_map(self, mapping: Mapping[str, object]) -> "str | TranslatableString":
"""Perform the same formatting as [`str.format_map`][], possibly lazily."""

@classmethod
def __get_pydantic_core_schema__(cls, *_: object) -> CoreSchema:
# Never convert anything to a TranslatableString, but accept existing instances, and serialize by using str().
return core_schema.is_instance_schema(cls, serialization=core_schema.to_string_ser_schema())
15 changes: 8 additions & 7 deletions questionpy_common/api/attempt.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
# 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 <[email protected]>

from enum import Enum, StrEnum
from typing import Annotated

from pydantic import BaseModel, Field

from questionpy_common import TranslatableString

from . import Localized

__all__ = [
Expand Down Expand Up @@ -68,16 +69,16 @@ class AttemptFile(BaseModel):


class AttemptUi(BaseModel):
formulation: str
formulation: str | TranslatableString
"""X(H)ML markup of the formulation part of the question."""
general_feedback: str | None = None
general_feedback: str | TranslatableString | None = None
"""X(H)ML markup of the general feedback part of the question."""
specific_feedback: str | None = None
specific_feedback: str | TranslatableString | None = None
"""X(H)ML markup of the response-specific feedback part of the question."""
right_answer: str | None = None
right_answer: str | TranslatableString | None = None
"""X(H)ML markup of the part of the question which explains the correct answer."""

placeholders: dict[str, str] = {}
placeholders: dict[str, str | TranslatableString] = {}
"""Names and values of the ``<?p`` placeholders that appear in content."""
css_files: list[str] = []
javascript_calls: list[JsModuleCall] = []
Expand Down Expand Up @@ -124,7 +125,7 @@ class ScoredSubquestionModel(BaseModel):
score: float | None = None
score_final: float | None = None
scoring_code: ScoringCode | None = None
response_summary: str
response_summary: str | TranslatableString
response_class: str


Expand Down
4 changes: 3 additions & 1 deletion questionpy_common/conditions.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@

from pydantic import BaseModel, Field

_Value: TypeAlias = str | int | bool
from questionpy_common import TranslatableString

_Value: TypeAlias = str | TranslatableString | int | bool


class _BaseCondition(ABC, BaseModel):
Expand Down
30 changes: 15 additions & 15 deletions questionpy_common/elements.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
# 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 <[email protected]>

from typing import Annotated, Literal, TypeAlias, TypeGuard, get_args

from pydantic import BaseModel, Field, PositiveInt

from questionpy_common import TranslatableString
from questionpy_common.conditions import Condition

__all__ = [
Expand Down Expand Up @@ -37,7 +37,7 @@ class _BaseElement(BaseModel):


class _Labelled(BaseModel):
label: str
label: str | TranslatableString
"""Text describing the element, shown verbatim."""


Expand All @@ -53,42 +53,42 @@ class CanHaveConditions(BaseModel):
class CanHaveHelp(BaseModel):
"""Mixin class for elements that can have a help text hidden behind a button."""

help: str | None = None
help: str | TranslatableString | None = None
"""Text to be shown when the help button is clicked."""


class StaticTextElement(_BaseElement, _Labelled, CanHaveConditions, CanHaveHelp):
"""Some static text with a label."""

kind: Literal["static_text"] = "static_text"
text: str
text: str | TranslatableString


class TextInputElement(_BaseElement, _Labelled, CanHaveConditions, CanHaveHelp):
kind: Literal["input"] = "input"
required: bool = False
"""Require some non-empty input to be entered before the form can be submitted."""
default: str | None = None
default: str | TranslatableString | None = None
"""Default value of the input when first loading the form. Part of the submitted form data."""
placeholder: str | None = None
placeholder: str | TranslatableString | None = None
"""Placeholder to show when no value has been entered yet. Not part of the submitted form data."""


class TextAreaElement(_BaseElement, _Labelled, CanHaveConditions, CanHaveHelp):
kind: Literal["textarea"] = "textarea"
required: bool = False
"""Require some non-empty input to be entered before the form can be submitted."""
default: str | None = None
default: str | TranslatableString | None = None
"""Default value of the input when first loading the form. Part of the submitted form data."""
placeholder: str | None = None
placeholder: str | TranslatableString | None = None
"""Placeholder to show when no value has been entered yet. Not part of the submitted form data."""


class CheckboxElement(_BaseElement, CanHaveConditions, CanHaveHelp):
kind: Literal["checkbox"] = "checkbox"
left_label: str | None = None
left_label: str | TranslatableString | None = None
"""Label shown the same way as labels on other element types."""
right_label: str | None = None
right_label: str | TranslatableString | None = None
"""Additional label shown to the right of the checkbox."""
required: bool = False
"""Require this checkbox to be selected before the form can be submitted."""
Expand All @@ -106,9 +106,9 @@ class CheckboxGroupElement(_BaseElement):
class Option(BaseModel):
"""A possible option for radio groups and drop-downs."""

label: str
label: str | TranslatableString
"""Text describing the option, shown verbatim."""
value: str
value: str | TranslatableString
"""Value that will be taken by the radio group or drop-down when this option is selected."""
selected: bool = False
"""Default state of the option."""
Expand Down Expand Up @@ -140,7 +140,7 @@ class HiddenElement(_BaseElement, CanHaveConditions):
"""An element that isn't shown to the user but still submits its fixed value."""

kind: Literal["hidden"] = "hidden"
value: str
value: str | TranslatableString


class GroupElement(_BaseElement, _Labelled, CanHaveConditions, CanHaveHelp):
Expand All @@ -161,7 +161,7 @@ class RepetitionElement(_BaseElement):
"""Minimum number of repetitions, at or below which removal is not possible."""
increment: PositiveInt
"""Number of repetitions to add with each click of the button."""
button_label: str | None = None
button_label: str | TranslatableString | None = None
"""Label for the button that adds more repetitions, or None to use default provided by LMS."""

elements: list["FormElement"]
Expand Down Expand Up @@ -198,7 +198,7 @@ class FormSection(BaseModel):

name: str
"""Name that will later identify the element in submitted form data."""
header: str
header: str | TranslatableString
"""Header to be shown at the top of the section."""
elements: list[FormElement] = []
"""Elements contained in the section."""
Expand Down
28 changes: 25 additions & 3 deletions questionpy_common/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
from collections.abc import Callable, Mapping, Sequence
from contextvars import ContextVar
from dataclasses import dataclass
from enum import Enum
from functools import total_ordering
from importlib.resources.abc import Traversable
from typing import NamedTuple, Protocol, TypeAlias

from questionpy_common.api.package import QPyPackageInterface
from questionpy_common.api.qtype import QuestionTypeInterface
from questionpy_common.manifest import Manifest
from questionpy_common.manifest import Bcp47LanguageTag, Manifest

__all__ = [
"Environment",
Expand All @@ -19,6 +21,7 @@
"Package",
"PackageInitFunction",
"PackageNamespaceAndShortName",
"PackageState",
"RequestUser",
"WorkerResourceLimits",
"get_qpy_environment",
Expand All @@ -30,7 +33,7 @@
class RequestUser:
"""Preferences of the user that a request is being processed for."""

preferred_languages: Sequence[str]
preferred_languages: Sequence[Bcp47LanguageTag]


@dataclass
Expand All @@ -41,6 +44,21 @@ class WorkerResourceLimits:
max_cpu_time_seconds_per_call: float


@total_ordering
class PackageState(Enum):
PREPARED = 1
"""The package is present and in the process of being loaded, but none of its code has been executed yet."""
LOADED = 2
"""The package entrypoint has been imported."""
INITIALIZED = 3
"""The package's `init` function, if any, has been executed."""

def __lt__(self, other: object) -> bool:
if isinstance(other, PackageState):
return self.value < other.value
return NotImplemented


class Package(Protocol):
@property
def manifest(self) -> Manifest: ...
Expand All @@ -54,6 +72,9 @@ def get_path(self, path: str) -> Traversable:
path: Path relative to the root of the package.
"""

@property
def state(self) -> PackageState: ...


OnRequestCallback: TypeAlias = Callable[[RequestUser], None]

Expand Down Expand Up @@ -90,7 +111,8 @@ class Environment(Protocol):
packages: Mapping[PackageNamespaceAndShortName, Package]
"""All packages loaded in the worker, including the main package.

Keys are the package namespace and short name. Only one version of a package can be loaded at a time.
Keys are the package namespace and short name. Only one version of a package can be loaded at a time. This may
include packages which are not yet initialized (i.e. their `init` function has not finished yet).
"""

@abstractmethod
Expand Down
16 changes: 12 additions & 4 deletions questionpy_common/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import re
from enum import StrEnum
from keyword import iskeyword, issoftkeyword
from typing import Annotated
from typing import Annotated, NewType

from pydantic import BaseModel, field_validator
from pydantic.fields import Field
Expand Down Expand Up @@ -74,6 +74,9 @@ def ensure_is_valid_name(name: str) -> str:
return name


Bcp47LanguageTag = NewType("Bcp47LanguageTag", str)


class SourceManifest(BaseModel):
"""Represents the fields in a package source directory.

Expand All @@ -85,11 +88,16 @@ class SourceManifest(BaseModel):
version: Annotated[str, Field(pattern=RE_SEMVER)]
api_version: Annotated[str, Field(pattern=RE_API)]
author: str
name: dict[str, str] = {}
name: dict[Bcp47LanguageTag, str] = {}
entrypoint: str | None = None
url: str | None = None
languages: set[str] = set()
description: dict[str, str] = {}
languages: list[Bcp47LanguageTag] = Field(min_length=1)
"""Languages supported by the package, in BCP 47 format.

The first entry should by the language that the package is written in, i.e. the language used when no translation is
done. If the package does not support localization, that should be the only entry.
"""
description: dict[Bcp47LanguageTag, str] = {}
icon: str | None = None
type: PackageType = DEFAULT_PACKAGETYPE
license: str | None = None
Expand Down
8 changes: 4 additions & 4 deletions questionpy_server/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,20 @@
from questionpy_common.api.attempt import AttemptModel, AttemptScoredModel, AttemptStartedModel
from questionpy_common.api.question import QuestionModel
from questionpy_common.elements import OptionsFormDefinition
from questionpy_common.manifest import PackageType
from questionpy_common.manifest import Bcp47LanguageTag, PackageType


class PackageInfo(BaseModel):
model_config = ConfigDict(use_enum_values=True)

short_name: str
namespace: str
name: dict[str, str]
name: dict[Bcp47LanguageTag, str]
type: PackageType
author: str | None
url: str | None
languages: set[str] | None
description: dict[str, str] | None
languages: list[Bcp47LanguageTag] | None
description: dict[Bcp47LanguageTag, str] | None
icon: str | None
license: str | None
tags: set[str] | None
Expand Down
9 changes: 4 additions & 5 deletions questionpy_server/web/_routes/_attempts.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

from aiohttp import web

from questionpy_common.environment import RequestUser
from questionpy_server.models import (
AttemptResponse,
AttemptScoreArguments,
Expand All @@ -17,7 +16,7 @@
)
from questionpy_server.package import Package
from questionpy_server.web._decorators import ensure_required_parts
from questionpy_server.web._utils import pydantic_json_response
from questionpy_server.web._utils import DEFAULT_REQUEST_USER, pydantic_json_response
from questionpy_server.web.app import QPyServer

if TYPE_CHECKING:
Expand All @@ -36,7 +35,7 @@ async def post_attempt_start(
location = await package.get_zip_package_location()
worker: Worker
async with qpyserver.worker_pool.get_worker(location, 0, data.context) as worker:
attempt = await worker.start_attempt(RequestUser(["de", "en"]), question_state.decode(), data.variant)
attempt = await worker.start_attempt(DEFAULT_REQUEST_USER, question_state.decode(), data.variant)
packages = worker.get_loaded_packages()

resp = AttemptStartedResponse(**dict(attempt), package_dependencies=packages)
Expand All @@ -54,7 +53,7 @@ async def post_attempt_view(
worker: Worker
async with qpyserver.worker_pool.get_worker(location, 0, data.context) as worker:
attempt = await worker.get_attempt(
request_user=RequestUser(["de", "en"]),
request_user=DEFAULT_REQUEST_USER,
question_state=question_state.decode(),
attempt_state=data.attempt_state,
scoring_state=data.scoring_state,
Expand All @@ -77,7 +76,7 @@ async def post_attempt_score(
worker: Worker
async with qpyserver.worker_pool.get_worker(location, 0, data.context) as worker:
attempt_scored = await worker.score_attempt(
request_user=RequestUser(["de", "en"]),
request_user=DEFAULT_REQUEST_USER,
question_state=question_state.decode(),
attempt_state=data.attempt_state,
scoring_state=data.scoring_state,
Expand Down
Loading