Skip to content

Commit

Permalink
feat(frontend): add webserver application for Vue.js frontend
Browse files Browse the repository at this point in the history
Move legacy web server to `webserver_legacy`.
  • Loading branch information
tumidi committed Feb 28, 2025
1 parent 5c7f1df commit a5b19e5
Show file tree
Hide file tree
Showing 49 changed files with 1,066 additions and 444 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ __pycache__

/example.qpy
/questionpy_sdk/resources/minimal_example.zip
/questionpy_sdk/webserver/question_state_storage/
/questionpy_sdk/webserver_legacy/question_state_storage/
/questionpy_sdk/webserver/static/
17 changes: 17 additions & 0 deletions build.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
# The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md.
# (c) Technische Universität Berlin, innoCampus <[email protected]>

import subprocess
import sys
import zipfile
from pathlib import Path
from typing import Any
Expand All @@ -22,8 +24,23 @@ def create_example_zip() -> None:
zip_file.write(file, file.relative_to(minimal_example), COMPRESS_TYPE)


def _run_command(args: list[str], rel_path: str) -> None:
cwd = Path(__file__).parent.absolute() / rel_path
try:
subprocess.run(args, cwd=cwd, check=True, text=True, stdout=sys.stdout, stderr=sys.stderr) # noqa: S603
except subprocess.CalledProcessError as e:
msg = f"npm build failed with exit code {e.returncode}"
raise RuntimeError(msg) from e


def build_frontend() -> None:
_run_command(["npm", "ci", "--ignore-scripts"], "frontend")
_run_command(["npm", "run", "build"], "frontend")


def build(_setup_kwargs: Any) -> None:
create_example_zip()
build_frontend()


if __name__ == "__main__":
Expand Down
685 changes: 343 additions & 342 deletions poetry.lock

Large diffs are not rendered by default.

12 changes: 8 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ packages = [
{ include = "questionpy" },
{ include = "questionpy_sdk" }
]
include = ["questionpy_sdk/resources/minimal_example.zip"]
include = [
"questionpy_sdk/resources/minimal_example.zip",
{ path = "questionpy_sdk/webserver/static/assets/*", format = ["sdist", "wheel"] },
{ path = "questionpy_sdk/webserver/static/*", format = ["sdist", "wheel"] },
]

[tool.poetry.build]
generate-setup-file = false
Expand All @@ -28,7 +32,7 @@ python = "^3.11"
aiohttp = "^3.9.3"
pydantic = "^2.6.4"
PyYAML = "^6.0.1"
questionpy-server = { git = "https://github.com/questionpy-org/questionpy-server.git", rev = "3f8f2e6199d2c718aef79b07e3fcd5234282a2b3" }
questionpy-server = { git = "https://github.com/questionpy-org/questionpy-server.git", rev = "de758fd181937e3d14e1235a35d63ec29a88714e" }
jinja2 = "^3.1.3"
aiohttp-jinja2 = "^1.6"
lxml = "~5.1.0"
Expand Down Expand Up @@ -64,9 +68,9 @@ extend-ignore-names = ["mcs", "test_*"]

[tool.ruff.lint.extend-per-file-ignores]
# Allow f-string without an `f` prefix for our custom error formatter.
"**/questionpy_sdk/webserver/question_ui/errors.py" = ["RUF027"]
"**/questionpy_sdk/webserver_legacy/question_ui/errors.py" = ["RUF027"]
# unused-async (aiohttp handlers must be async even if they don't use it)
"**/questionpy_sdk/webserver/routes/*" = ["RUF029"]
"**/questionpy_sdk/webserver*/routes/*" = ["RUF029"]

[tool.ruff.lint.pylint]
allow-dunder-method-names = ["__get_pydantic_core_schema__"]
Expand Down
2 changes: 2 additions & 0 deletions questionpy/form/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@

from ._dsl import (
checkbox,
checkbox_group,
does_not_equal,
equals,
generated_id,
Expand Down Expand Up @@ -101,6 +102,7 @@
"TextAreaElement",
"TextInputElement",
"checkbox",
"checkbox_group",
"does_not_equal",
"equals",
"generated_id",
Expand Down
35 changes: 35 additions & 0 deletions questionpy/form/_dsl.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from questionpy_common.conditions import Condition, DoesNotEqual, Equals, In, IsChecked, IsNotChecked
from questionpy_common.elements import (
CheckboxElement,
CheckboxGroupElement,
GeneratedIdElement,
GroupElement,
HiddenElement,
Expand Down Expand Up @@ -366,6 +367,40 @@ def checkbox(
)


def checkbox_group(
label: str | None = None,
checkboxes: list[CheckboxElement] | None = None,
*,
help: str | None = None,
disable_if: _ZeroOrMoreConditions = None,
hide_if: _ZeroOrMoreConditions = None,
) -> Any:
"""Adds a checkbox group.
Args:
label: Text describing the element, shown verbatim.
checkboxes (list[CheckboxElement]): List of checkbox elements.
help: Element help text.
disable_if (Condition | list[Condition] | None): Disable this element if some condition(s) match.
hide_if (Condition | list[Condition] | None): Hide this element if some condition(s) match.
Returns:
An internal object containing metadata about the field.
"""
return _FieldInfo(
type=set[str],
build=lambda name: CheckboxGroupElement(
name=name,
label=label,
checkboxes=[] if checkboxes is None else checkboxes,
help=help,
disable_if=_listify(disable_if),
hide_if=_listify(hide_if),
),
pydantic_field_info=FieldInfo(default=set() if disable_if or hide_if else PydanticUndefined),
)


@overload
def radio_group(
label: str,
Expand Down
31 changes: 25 additions & 6 deletions questionpy_sdk/commands/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,27 @@
import click

from questionpy_sdk.commands._helper import get_package_location
from questionpy_sdk.constants import DEFAULT_STATE_STORAGE_PATH
from questionpy_sdk.get_webserver import get_webserver
from questionpy_sdk.watcher import Watcher
from questionpy_sdk.webserver.app import DEFAULT_STATE_STORAGE_PATH, WebServer
from questionpy_server.worker.runtime.package_location import DirPackageLocation

if TYPE_CHECKING:
from collections.abc import Coroutine


async def run_watcher(
pkg_path: Path, pkg_location: DirPackageLocation, state_storage_path: Path, host: str, port: int
pkg_path: Path,
pkg_location: DirPackageLocation,
state_storage_path: Path,
host: str,
port: int,
*,
legacy_frontend: bool,
) -> None:
async with Watcher(pkg_path, pkg_location, state_storage_path, host, port) as watcher:
async with Watcher(
pkg_path, pkg_location, state_storage_path, host, port, legacy_frontend=legacy_frontend
) as watcher:
await watcher.run_forever()


Expand All @@ -41,7 +50,15 @@ async def run_watcher(
"--port", "-p", "port", default=8080, show_default=True, type=click.IntRange(1024, 65535), help="Port to bind to."
)
@click.option("--watch", "-w", "watch", is_flag=True, help="Watch source directory and rebuild on changes.")
def run(package: str, state_storage_path: Path, host: str, port: int, *, watch: bool) -> None:
@click.option(
"--legacy-frontend",
"-l",
"legacy_frontend",
is_flag=True,
help="Use legacy frontend. "
"(This is a temporary option that is going to be removed once the new frontend is finalized.)",
)
def run(package: str, state_storage_path: Path, host: str, port: int, *, watch: bool, legacy_frontend: bool) -> None:
"""Run a package.
\b
Expand All @@ -58,8 +75,10 @@ def run(package: str, state_storage_path: Path, host: str, port: int, *, watch:
if not isinstance(pkg_location, DirPackageLocation) or pkg_path == pkg_location.path:
msg = "The --watch option only works with source directories."
raise click.BadParameter(msg)
coro = run_watcher(pkg_path, pkg_location, state_storage_path, host, port)
coro = run_watcher(pkg_path, pkg_location, state_storage_path, host, port, legacy_frontend=legacy_frontend)
else:
coro = WebServer(pkg_location, state_storage_path, host, port).run_forever()
coro = get_webserver(
pkg_location, state_storage_path, host, port, legacy_frontend=legacy_frontend
).run_forever()

asyncio.run(coro)
4 changes: 4 additions & 0 deletions questionpy_sdk/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,8 @@
# The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md.
# (c) Technische Universität Berlin, innoCampus <[email protected]>

import os
from pathlib import Path

PACKAGE_CONFIG_FILENAME = "qpy_config.yml"
DEFAULT_STATE_STORAGE_PATH = Path(os.getenv("XDG_STATE_HOME", os.path.expanduser("~/.local/state"))) / "questionpy-sdk"
32 changes: 32 additions & 0 deletions questionpy_sdk/get_webserver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# This file is part of the QuestionPy SDK. (https://questionpy.org)
# The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md.
# (c) Technische Universität Berlin, innoCampus <[email protected]>

from pathlib import Path
from typing import Protocol, Self

from questionpy_server.worker.runtime.package_location import PackageLocation


class WebServerProtocol(Protocol):
async def run_forever(self: Self) -> None: ...
async def stop_server(self: Self) -> None: ...
async def start_server(self: Self) -> None: ...


def get_webserver(
package_location: PackageLocation,
state_storage_path: Path,
host: str,
port: int,
*,
legacy_frontend: bool = False,
) -> WebServerProtocol:
if legacy_frontend:
from questionpy_sdk.webserver_legacy.app import WebServer as WebServerLegacy # noqa: PLC0415

return WebServerLegacy(package_location, state_storage_path, host, port)

from questionpy_sdk.webserver.app import WebServer as WebServerSpa # noqa: PLC0415

return WebServerSpa(package_location, state_storage_path, host, port)
15 changes: 12 additions & 3 deletions questionpy_sdk/watcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@
from watchdog.utils.event_debouncer import EventDebouncer

from questionpy_common.constants import DIST_DIR
from questionpy_sdk.get_webserver import get_webserver
from questionpy_sdk.package.builder import DirPackageBuilder
from questionpy_sdk.package.errors import PackageBuildError, PackageSourceValidationError
from questionpy_sdk.package.source import PackageSource
from questionpy_sdk.webserver.app import WebServer
from questionpy_server.worker.runtime.package_location import DirPackageLocation

if TYPE_CHECKING:
Expand Down Expand Up @@ -91,7 +91,14 @@ class Watcher(AbstractAsyncContextManager):
"""Watch a package source path and rebuild package/restart server on file changes."""

def __init__(
self, source_path: Path, pkg_location: DirPackageLocation, state_storage_path: Path, host: str, port: int
self,
source_path: Path,
pkg_location: DirPackageLocation,
state_storage_path: Path,
host: str,
port: int,
*,
legacy_frontend: bool,
) -> None:
self._source_path = source_path
self._pkg_location = pkg_location
Expand All @@ -100,7 +107,9 @@ def __init__(

self._event_handler = _EventHandler(asyncio.get_running_loop(), self._notify, self._source_path)
self._observer = Observer()
self._webserver = WebServer(self._pkg_location, state_storage_path, self._host, self._port)
self._webserver = get_webserver(
self._pkg_location, state_storage_path, self._host, self._port, legacy_frontend=legacy_frontend
)
self._on_change_event = asyncio.Event()
self._watch: ObservedWatch | None = None

Expand Down
72 changes: 56 additions & 16 deletions questionpy_sdk/webserver/_form_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# (c) Technische Universität Berlin, innoCampus <[email protected]>

import operator
from collections.abc import Iterable
from typing import Any


Expand All @@ -19,7 +20,7 @@ def _unflatten(flat_form_data: dict[str, str]) -> dict[str, Any]:
... "general[my_repetition][1][role]": "OPT_1",
... "general[my_repetition][1][name][first_name]": "John",
... })
{'general': {'my_hidden': 'foo', 'my_repetition': {'1': {'role': 'OPT_1', 'name': {'first_name': 'John'}}}}}
{'general': {'my_hidden': 'foo', 'my_repetition': [{'role': 'OPT_1', 'name': {'first_name': 'John'}}]}}
"""
unflattened_dict: dict[str, Any] = {}
for flat_key, value in flat_form_data.items():
Expand All @@ -45,27 +46,33 @@ def _convert_repetition_dict_to_list(dictionary: dict[str, Any]) -> dict[str, An
for key, value in dictionary.items():
dictionary[key] = _convert_repetition_dict_to_list(value)

if dictionary.pop("qpy_repetition_marker", ...) is not ...:
if all(key.isnumeric() for key in dictionary):
# Sort by key (i.e. the index) and put the sorted values into a list.
return [value for key, value in sorted(dictionary.items(), key=operator.itemgetter(0))]

return dictionary


def parse_form_data(form_data: dict) -> dict:
"""Parses form data from a flat dictionary into a nested dictionary.
def parse_form_data(form_data: dict[str, Any]) -> dict[str, Any]:
"""Parses form data from a flat into a nested dictionary to be consumed by Pydantic.
This function parses a dictionary, where the keys are the references to the Form Element from the Options Form.
This function parses a dictionary, where the keys are the references to the Form Elements from the Options Form.
The references are used to create a nested dictionary with the form data. Elements in the 'general' section are
moved to the root of the dictionary.
Args:
form_data: The flat dictionary representing the form data.
Returns:
The nested form data.
Examples:
>>> parse_form_data({
... "general[my_hidden]": "foo",
... "general[my_repetition][1][role]": "OPT_1",
... "general[my_repetition][1][name][first_name]": "John",
... })
{'my_hidden': 'foo', 'my_repetition': {'1': {'role': 'OPT_1', 'name': {'first_name': 'John'}}}}
{'my_hidden': 'foo', 'my_repetition': [{'role': 'OPT_1', 'name': {'first_name': 'John'}}]}
"""
unflattened_form_data = _unflatten(form_data)
options = unflattened_form_data.get("general", {})
Expand All @@ -75,15 +82,48 @@ def parse_form_data(form_data: dict) -> dict:
return options


def get_nested_form_data(form_data: dict[str, Any], reference: str) -> object:
current_element = form_data
parts = reference.replace("]", "").split("[")
def _flatten_value(value: Any, prefix: str, result: dict[str, Any]) -> None:
# group
if isinstance(value, dict):
for k, v in value.items():
_flatten_value(v, f"{prefix}[{k}]", result)

# repetition
elif isinstance(value, list) and len(value) > 0 and all(isinstance(item, dict) for item in value):
for idx, v in enumerate(value, start=1):
item_prefix = f"{prefix}[{idx}]"
_flatten_value(v, item_prefix, result)

else:
result[prefix] = value


def flatten_form_data(form_data: dict[str, Any], section_names: Iterable[str]) -> dict[str, Any]:
"""Flattens form data from a nested dictionary into a flat dictionary to be consumed by the frontend.
ref = parts.pop(0)
if ref != "general":
current_element = current_element[ref]
while parts:
ref = parts.pop(0)
current_element = current_element[ref]
This function flattens a nested dictionary into a flat dictionary, where the keys are references
to the Form Elements in the Options Form. Top-level elements are put under the 'general' section,
while elements under the given `section_names` are put under their respective sections.
return current_element
Args:
form_data: The nested dictionary representing the form data.
section_names: An iterable of section names that should be treated as sections and not put
under 'general'.
Returns:
The flat form data.
Examples:
>>> flatten_form_data(
... {
... "my_hidden": "foo",
... "my_repetition": [{"input": "foo"}],
... },
... section_names=[],
... )
{'general[my_hidden]': 'foo', 'general[my_repetition][1][input]': 'foo'}
"""
result: dict[str, Any] = {}
for key, value in form_data.items():
_flatten_value(value, key if key in section_names else f"general[{key}]", result)
return result
Loading

0 comments on commit a5b19e5

Please sign in to comment.