Skip to content

Commit

Permalink
refactor: consolidate web package structure
Browse files Browse the repository at this point in the history
  • Loading branch information
MHajoha committed Aug 22, 2024
1 parent d6efced commit d9cba0b
Show file tree
Hide file tree
Showing 31 changed files with 111 additions and 111 deletions.
2 changes: 1 addition & 1 deletion questionpy_server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class QPyServer(web.AppKey["QPyServer"]):

def __init__(self, settings: Settings):
# We import here, so we don't have to work around circular imports.
from .api.routes import routes # noqa: PLC0415
from questionpy_server.web._routes import routes # noqa: PLC0415

self.settings: Settings = settings
self.web_app = web.Application(client_max_size=settings.webservice.max_main_size)
Expand Down
2 changes: 1 addition & 1 deletion questionpy_server/collector/_package_collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@

if TYPE_CHECKING:
from questionpy_server.collector.abc import BaseCollector
from questionpy_server.hash import HashContainer
from questionpy_server.package import Package
from questionpy_server.web import HashContainer


class PackageCollection:
Expand Down
2 changes: 1 addition & 1 deletion questionpy_server/collector/lms_collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@

if TYPE_CHECKING:
from questionpy_server.collector.indexer import Indexer
from questionpy_server.hash import HashContainer
from questionpy_server.package import Package
from questionpy_server.web import HashContainer


class LMSCollector(CachedCollector):
Expand Down
2 changes: 1 addition & 1 deletion questionpy_server/collector/local_collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from watchdog.utils.dirsnapshot import DirectorySnapshot, DirectorySnapshotDiff, EmptyDirectorySnapshot

from questionpy_server.collector.abc import BaseCollector
from questionpy_server.misc import calculate_hash
from questionpy_server.hash import calculate_hash

if TYPE_CHECKING:
from questionpy_server.collector.indexer import Indexer
Expand Down
2 changes: 1 addition & 1 deletion questionpy_server/factories/package.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from faker import Faker
from polyfactory.factories.pydantic_factory import ModelFactory

from questionpy_server.api.models import PackageInfo
from questionpy_server.models import PackageInfo

languages = ["en", "de"]
fake = Faker()
Expand Down
2 changes: 1 addition & 1 deletion questionpy_server/factories/question_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from polyfactory.factories.pydantic_factory import ModelFactory

from questionpy_server.api.models import RequestBaseData
from questionpy_server.models import RequestBaseData


class RequestBaseDataFactory(ModelFactory):
Expand Down
6 changes: 6 additions & 0 deletions questionpy_server/misc.py → questionpy_server/hash.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from hashlib import sha256
from pathlib import Path
from typing import NamedTuple

from questionpy_common.constants import MiB

Expand All @@ -27,3 +28,8 @@ def calculate_hash(source: bytes | Path) -> str:
sha.update(chunk)

return sha.hexdigest()


class HashContainer(NamedTuple):
data: bytes
hash: str
File renamed without changes.
2 changes: 1 addition & 1 deletion questionpy_server/package.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
from pathlib import Path
from typing import TYPE_CHECKING

from questionpy_server.api.models import PackageInfo
from questionpy_server.collector.lms_collector import LMSCollector
from questionpy_server.collector.local_collector import LocalCollector
from questionpy_server.collector.repo_collector import RepoCollector
from questionpy_server.models import PackageInfo
from questionpy_server.utils.manifest import ComparableManifest

if TYPE_CHECKING:
Expand Down
2 changes: 1 addition & 1 deletion questionpy_server/repository/helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from aiohttp import ClientError, ClientSession

from questionpy_server.misc import calculate_hash
from questionpy_server.hash import calculate_hash


class DownloadError(Exception):
Expand Down
7 changes: 3 additions & 4 deletions questionpy_server/settings.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# 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 <[email protected]>

import builtins
import logging
from configparser import ConfigParser
from datetime import timedelta
Expand All @@ -20,7 +20,6 @@
)

from questionpy_common.constants import MAX_PACKAGE_SIZE, MiB
from questionpy_server.types import WorkerType
from questionpy_server.worker.worker import Worker
from questionpy_server.worker.worker.subprocess import SubprocessWorker

Expand Down Expand Up @@ -83,14 +82,14 @@ def max_package_size_bigger_then_predefined_value(cls, value: ByteSize) -> ByteS


class WorkerSettings(BaseModel):
type: WorkerType = SubprocessWorker
type: builtins.type[Worker] = SubprocessWorker
"""Fully qualified name of the worker class or the class itself (for the default)."""
max_workers: int = 8
max_memory: ByteSize = ByteSize(500 * MiB)

@field_validator("type", mode="before")
@classmethod
def _load_worker_class(cls, value: object) -> WorkerType:
def _load_worker_class(cls, value: object) -> builtins.type[Worker]:
if isinstance(value, str):
value = locate(value)

Expand Down
18 changes: 0 additions & 18 deletions questionpy_server/types.py

This file was deleted.

File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,24 +1,31 @@
# 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 <[email protected]>
import inspect
from collections.abc import Awaitable, Callable
from functools import wraps
from inspect import Parameter
from typing import Concatenate, NamedTuple, ParamSpec, TypeAlias
from typing import Concatenate, NamedTuple, ParamSpec, TypeAlias, TypeVar

from aiohttp import BodyPartReader, web
from aiohttp.log import web_logger
from aiohttp.web_exceptions import HTTPBadRequest
from pydantic import BaseModel, ValidationError

from questionpy_common import constants
from questionpy_server.api.models import MainBaseModel, NotFoundStatus, NotFoundStatusWhat
from questionpy_server.app import QPyServer
from questionpy_server.cache import CacheItemTooLargeError
from questionpy_server.hash import HashContainer
from questionpy_server.models import MainBaseModel
from questionpy_server.package import Package
from questionpy_server.types import M
from questionpy_server.web import (
HashContainer,
read_part,
from questionpy_server.web._errors import (
MainBodyMissingError,
PackageHashMismatchError,
PackageMissingByHashError,
PackageMissingWithoutHashError,
QuestionStateMissingError,
)
from questionpy_server.web._utils import read_part

_P = ParamSpec("_P")
_HandlerFunc: TypeAlias = Callable[Concatenate[web.Request, _P], Awaitable[web.StreamResponse]]
Expand Down Expand Up @@ -94,56 +101,6 @@ async def _read_body_parts(request: web.Request) -> _RequestBodyParts:
return parts


class _ExceptionMixin(web.HTTPException):
def __init__(self, msg: str, body: BaseModel | None = None) -> None:
if body:
# Send structured error body as JSON.
super().__init__(reason=type(self).__name__, text=body.model_dump_json(), content_type="application/json")
else:
# Send the detailed message.
super().__init__(reason=type(self).__name__, text=msg)

# web.HTTPException uses the HTTP reason (which should be very short) as the exception message (which should be
# detailed). This sets the message to our detailed one.
Exception.__init__(self, msg)

web_logger.info(msg)


class MainBodyMissingError(web.HTTPBadRequest, _ExceptionMixin):
def __init__(self) -> None:
super().__init__("The main body is required but was not provided.")


class PackageMissingWithoutHashError(web.HTTPBadRequest, _ExceptionMixin):
def __init__(self) -> None:
super().__init__("The package is required but was not provided.")


class PackageMissingByHashError(web.HTTPNotFound, _ExceptionMixin):
def __init__(self, package_hash: str) -> None:
super().__init__(
f"The package was not provided, is not cached and could not be found by its hash. ('{package_hash}')",
NotFoundStatus(what=NotFoundStatusWhat.PACKAGE),
)


class PackageHashMismatchError(web.HTTPBadRequest, _ExceptionMixin):
def __init__(self, from_uri: str, from_body: str) -> None:
super().__init__(
f"The request URI specifies a package with hash '{from_uri}', but the sent package has a hash of "
f"'{from_body}'."
)


class QuestionStateMissingError(web.HTTPBadRequest, _ExceptionMixin):
def __init__(self) -> None:
super().__init__(
"A question state part is required but was not provided.",
NotFoundStatus(what=NotFoundStatusWhat.QUESTION_STATE),
)


def ensure_package(handler: _HandlerFunc, *, param: inspect.Parameter | None = None) -> _HandlerFunc:
"""Decorator ensuring that the package needed by the handler is present."""
if not param:
Expand Down Expand Up @@ -283,12 +240,15 @@ async def _parse_form_data(request: web.Request) -> _RequestBodyParts:
return _RequestBodyParts(main, package, question_state)


def _validate_from_http(raw_body: str | bytes, param_class: type[M]) -> M:
_M = TypeVar("_M", bound=BaseModel)


def _validate_from_http(raw_body: str | bytes, param_class: type[_M]) -> _M:
"""Validates the given json which was presumably an HTTP body to the given Pydantic model.
Args:
raw_body: raw json body
param_class: the [pydantic.BaseModel][] subclass to valdiate to
param_class: the [pydantic.BaseModel][] subclass to validate to
"""
try:
return param_class.model_validate_json(raw_body)
Expand Down
58 changes: 58 additions & 0 deletions questionpy_server/web/_errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# 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 <[email protected]>
from aiohttp import web
from aiohttp.log import web_logger
from pydantic import BaseModel

from questionpy_server.models import NotFoundStatus, NotFoundStatusWhat


class _ExceptionMixin(web.HTTPException):
def __init__(self, msg: str, body: BaseModel | None = None) -> None:
if body:
# Send structured error body as JSON.
super().__init__(reason=type(self).__name__, text=body.model_dump_json(), content_type="application/json")
else:
# Send the detailed message.
super().__init__(reason=type(self).__name__, text=msg)

# web.HTTPException uses the HTTP reason (which should be very short) as the exception message (which should be
# detailed). This sets the message to our detailed one.
Exception.__init__(self, msg)

web_logger.info(msg)


class MainBodyMissingError(web.HTTPBadRequest, _ExceptionMixin):
def __init__(self) -> None:
super().__init__("The main body is required but was not provided.")


class PackageMissingWithoutHashError(web.HTTPBadRequest, _ExceptionMixin):
def __init__(self) -> None:
super().__init__("The package is required but was not provided.")


class PackageMissingByHashError(web.HTTPNotFound, _ExceptionMixin):
def __init__(self, package_hash: str) -> None:
super().__init__(
f"The package was not provided, is not cached and could not be found by its hash. ('{package_hash}')",
NotFoundStatus(what=NotFoundStatusWhat.PACKAGE),
)


class PackageHashMismatchError(web.HTTPBadRequest, _ExceptionMixin):
def __init__(self, from_uri: str, from_body: str) -> None:
super().__init__(
f"The request URI specifies a package with hash '{from_uri}', but the sent package has a hash of "
f"'{from_body}'."
)


class QuestionStateMissingError(web.HTTPBadRequest, _ExceptionMixin):
def __init__(self) -> None:
super().__init__(
"A question state part is required but was not provided.",
NotFoundStatus(what=NotFoundStatusWhat.QUESTION_STATE),
)
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@
from aiohttp import web

from questionpy_common.environment import RequestUser
from questionpy_server.api.models import AttemptScoreArguments, AttemptStartArguments, AttemptViewArguments
from questionpy_server.app import QPyServer
from questionpy_server.decorators import ensure_required_parts
from questionpy_server.models import AttemptScoreArguments, AttemptStartArguments, AttemptViewArguments
from questionpy_server.package import Package
from questionpy_server.web import json_response
from questionpy_server.web._decorators import ensure_required_parts
from questionpy_server.web._utils import json_response
from questionpy_server.worker.runtime.package_location import ZipPackageLocation

if TYPE_CHECKING:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
from aiohttp.web_exceptions import HTTPNotImplemented

from questionpy_server.app import QPyServer
from questionpy_server.decorators import ensure_package
from questionpy_server.package import Package
from questionpy_server.web._decorators import ensure_package
from questionpy_server.worker.runtime.package_location import ZipPackageLocation

if TYPE_CHECKING:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@
from aiohttp.web_exceptions import HTTPMethodNotAllowed, HTTPNotFound

from questionpy_common.environment import RequestUser
from questionpy_server.api.models import QuestionCreateArguments, QuestionEditFormResponse, RequestBaseData
from questionpy_server.app import QPyServer
from questionpy_server.decorators import ensure_package, ensure_required_parts
from questionpy_server.models import QuestionCreateArguments, QuestionEditFormResponse, RequestBaseData
from questionpy_server.package import Package
from questionpy_server.web import json_response
from questionpy_server.web._decorators import ensure_package, ensure_required_parts
from questionpy_server.web._utils import json_response
from questionpy_server.worker.runtime.package_location import ZipPackageLocation

if TYPE_CHECKING:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
from aiohttp import web

from questionpy_server import __version__
from questionpy_server.api.models import ServerStatus, Usage
from questionpy_server.app import QPyServer
from questionpy_server.web import json_response
from questionpy_server.models import ServerStatus, Usage
from questionpy_server.web._utils import json_response

status_routes = web.RouteTableDef()

Expand Down
8 changes: 2 additions & 6 deletions questionpy_server/web.py → questionpy_server/web/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from collections.abc import Sequence
from hashlib import sha256
from io import BytesIO
from typing import Literal, NamedTuple, overload
from typing import Literal, overload

from aiohttp import BodyPartReader
from aiohttp.log import web_logger
Expand All @@ -14,6 +14,7 @@
from pydantic import BaseModel

from questionpy_common.constants import KiB
from questionpy_server.hash import HashContainer


def json_response(data: Sequence[BaseModel] | BaseModel, status: int = 200) -> Response:
Expand All @@ -32,11 +33,6 @@ def json_response(data: Sequence[BaseModel] | BaseModel, status: int = 200) -> R
return Response(text=data.model_dump_json(), status=status, content_type="application/json")


class HashContainer(NamedTuple):
data: bytes
hash: str


@overload
async def read_part(part: BodyPartReader, max_size: int, *, calculate_hash: Literal[True]) -> HashContainer: ...

Expand Down
Loading

0 comments on commit d9cba0b

Please sign in to comment.