diff --git a/portal/api/agenda.py b/portal/api/agenda.py index ea5ac56b8bce64dfde9ad7f6bd804b0f00edce40..d7717f61c8dd24d72fbc449fb5c7488663a22241 100644 --- a/portal/api/agenda.py +++ b/portal/api/agenda.py @@ -7,7 +7,7 @@ from ..model import Key @bp.get("/agendas/-/items") @require_valid(Key) -def the_agenda_items(): +def the_agenda_items() -> list[dict[str, str]]: tasks = sorted( TaigaUserStoryProvider.get_the_one().fetch_user_stories(), key=lambda task: task["kanban_order"], diff --git a/portal/api/auth.py b/portal/api/auth.py index acf7b0d33774cb7f889bd63b778c1b5bd6d20e19..bdbde6ae3438f5ecaf46454730ca4775311852b8 100644 --- a/portal/api/auth.py +++ b/portal/api/auth.py @@ -1,19 +1,25 @@ # Login code adapted from the flaskr tutorial (see LICENSE-3RD-PARTY.md for details) # See https://flask.palletsprojects.com/en/2.0.x/tutorial/views/ import functools +from typing import Any, Callable -from flask import request, g +from flask import g, request from portal import db +from ..model import Key, User +from ..problem_details import ProblemResponse, not_found, unauthorized from .blueprint import bp -from ..model import User, Key -from ..problem_details import not_found, unauthorized @bp.before_app_request -def set_user_if_valid(): - if not request.authorization or request.authorization.type != "basic": +def set_user_if_valid() -> None: + if ( + not request.authorization + or request.authorization.type != "basic" + or request.authorization.username is None + or request.authorization.password is None + ): g.user = None return @@ -23,15 +29,19 @@ def set_user_if_valid(): @bp.before_app_request -def set_key_if_valid(): - if not request.authorization or request.authorization.type != "bearer": +def set_key_if_valid() -> None: + if ( + not request.authorization + or request.authorization.type != "bearer" + or request.authorization.token is None + ): g.key = None return g.key = Key.get_by_secret_unless_expired(request.authorization.token) -def require_valid(credential_type): +def require_valid(credential_type: type[User | Key]) -> Callable: """ Requires that valid credentials are provided before allowing access to a route. If no valid credentials are provided, abort and send a 401. @@ -41,9 +51,9 @@ def require_valid(credential_type): - `@require_valid(Key)` to require a valid key """ - def decorator(route): + def decorator(route: Callable) -> Callable: @functools.wraps(route) - def protected_route(**kwargs): + def protected_route(**kwargs: Any) -> Any: # Determine which credentials to use credentials = {User: g.user, Key: g.key}.get(credential_type) @@ -60,14 +70,14 @@ def require_valid(credential_type): @bp.post("/keys") @require_valid(User) -def post_key(): +def post_key() -> tuple[dict, int]: key, secret = Key.generate(g.user) return key.to_dict() | {"secret": secret}, 201 @bp.delete("/keys/<string:uuid>") @require_valid(Key) -def delete_key(uuid: str): +def delete_key(uuid: str) -> tuple[str, int] | ProblemResponse: key_to_delete = Key.get_only(uuid) if not key_to_delete: diff --git a/portal/api/rest.py b/portal/api/rest.py index dc763fb6d27034c796fbd5f78dc38a2f271723b7..e4d89e9d52ea71a717c688288785694bcddd450a 100644 --- a/portal/api/rest.py +++ b/portal/api/rest.py @@ -1,11 +1,11 @@ from flask import render_template +from ..problem_details import ProblemResponse, not_found from .blueprint import bp -from ..problem_details import not_found @bp.get("/<path:_>") -def fallback_not_found(_): +def fallback_not_found(_: str) -> ProblemResponse: """ If you can't find a matching API endpoint, fallback to returning a 404. @@ -16,5 +16,5 @@ def fallback_not_found(_): @bp.get("/") -def index(): +def index() -> str: return render_template("redocly.html") diff --git a/portal/api/version.py b/portal/api/version.py index 8674f682293185ae7d97f8aba24d9d7b3575cfed..b12b5274c26b0f98101ac958cc331e2013965f70 100644 --- a/portal/api/version.py +++ b/portal/api/version.py @@ -1,12 +1,13 @@ import json from datetime import datetime from pathlib import Path +from typing import Any from .blueprint import bp @bp.get("/version") -def get_version(): +def get_version() -> dict[str, Any]: portal_version_path = Path("./PORTAL_VERSION") if portal_version_path.is_file(): with open("PORTAL_VERSION") as f: diff --git a/portal/main.py b/portal/main.py index 327b6d81adfa8c5bc5ca422e57ed30fb928dc8bf..86220faf1137f83724b8f2c27e9b9706d060bce7 100644 --- a/portal/main.py +++ b/portal/main.py @@ -1,11 +1,12 @@ -from flask import Blueprint, render_template, redirect, url_for +from flask import Blueprint, redirect, render_template, url_for +from werkzeug import Response bp = Blueprint("main", __name__) @bp.get("/", defaults={"_": ""}) @bp.get("/<path:_>") -def index(_): +def index(_: str) -> str: """ Serve the index page at /*, i.e. at any path below / and at / itself. @@ -16,5 +17,5 @@ def index(_): @bp.get("/api/") -def redirect_to_api(): +def redirect_to_api() -> Response: return redirect(url_for("api.index")) diff --git a/portal/model/Retrievable.py b/portal/model/Retrievable.py index d754852cd8660460136385e0e80180ec4abf2526..1b7c6495c41b7e1aace3d3628fa627b34ae3b56a 100644 --- a/portal/model/Retrievable.py +++ b/portal/model/Retrievable.py @@ -1,7 +1,8 @@ from abc import abstractmethod -from typing import List, Optional, Any, Self +from typing import Any, List, Optional, Self from flask import g + from portal import db diff --git a/portal/model/UtcDateTime.py b/portal/model/UtcDateTime.py index a7e4abb12b2f8e9b6a3177cb21373f640c3f314d..7597b66e1f76efe4ef14325b46a616b906fb35a5 100644 --- a/portal/model/UtcDateTime.py +++ b/portal/model/UtcDateTime.py @@ -28,15 +28,15 @@ class UtcDateTime(types.TypeDecorator): return types.String(32) def process_bind_param( - self, value: datetime, dialect: Dialect - ) -> Optional[str | datetime]: + self, value: Optional[datetime], dialect: Dialect + ) -> Optional[str]: if value is None: return None return value.astimezone(timezone.utc).isoformat() def process_result_value( - self, value: str | datetime, dialect: Dialect + self, value: Optional[str], dialect: Dialect ) -> Optional[datetime]: if value is None: return None diff --git a/portal/model/Uuid.py b/portal/model/Uuid.py index 5eb2a46668316c38a7c866e92e11d7269bae3c7a..44739944fb779e468400078a0e54b23a771f714d 100644 --- a/portal/model/Uuid.py +++ b/portal/model/Uuid.py @@ -26,7 +26,9 @@ class Uuid(types.TypeDecorator): def load_dialect_impl(self, dialect: Dialect) -> types.TypeEngine[Any]: return types.String(UUID_LENGTH) - def process_bind_param(self, value: str | UUID, dialect: Dialect) -> Optional[str]: + def process_bind_param( + self, value: Optional[str | UUID], dialect: Dialect + ) -> Optional[str]: if isinstance(value, str): # Manually create UUID from string, to raise an error if the string is malformed UUID(value) diff --git a/portal/problem_details.py b/portal/problem_details.py index a397f0dec1b824ae526b8653cbcbd728244d7f97..851786d7c343f967b383cf7b1eb805b079b78ddf 100644 --- a/portal/problem_details.py +++ b/portal/problem_details.py @@ -2,17 +2,22 @@ Provide RFC 7807-compliant problem details responses for the API. """ -from typing import Optional +from typing import Any, Optional +ResponseBody = dict[str, Any] +HttpStatusCode = int +HttpHeaders = dict[str, str] +ProblemResponse = tuple[ResponseBody, HttpStatusCode, HttpHeaders] -def unauthorized(): + +def unauthorized() -> ProblemResponse: """ Return a 401 message and status code """ return _problem_response(title="Unauthorized", status=401) -def not_found(): +def not_found() -> ProblemResponse: """ Return a 404 message and status code """ @@ -24,7 +29,7 @@ def _problem_response( status: int, type_uri: Optional[str] = None, extensions: Optional[dict] = None, -) -> tuple[dict, int, dict]: +) -> ProblemResponse: type_dict = {"type": type_uri} if type_uri is not None else {} title_dict = {"title": title}