You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by ar...@apache.org on 2026/02/26 16:59:21 UTC
(tooling-trusted-releases) 01/01: Add taint tracking types to post endpoints
This is an automated email from the ASF dual-hosted git repository.
arm pushed a commit to branch taint_tracking_types
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
commit 939539af957514b459e5d117f2a12c251b21a580
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Thu Feb 26 16:47:22 2026 +0000
Add taint tracking types to post endpoints
---
atr/blueprints/common.py | 69 ++++++++++-----
atr/blueprints/get.py | 16 ++--
atr/blueprints/post.py | 217 +++++++++++++++--------------------------------
atr/get/distribution.py | 12 +--
atr/post/announce.py | 44 +++++-----
atr/post/distribution.py | 91 ++++++++++++++------
atr/post/draft.py | 196 +++++++++++++++++++++++++++---------------
atr/post/finish.py | 23 +++--
atr/post/ignores.py | 22 +++--
atr/post/keys.py | 93 ++++++++++++++------
atr/post/manual.py | 63 ++++++++------
atr/post/projects.py | 36 +++++---
atr/post/resolve.py | 21 +++--
atr/post/revisions.py | 20 +++--
atr/post/sbom.py | 22 +++--
atr/post/start.py | 22 +++--
atr/post/test.py | 48 +++++++----
atr/post/tokens.py | 21 +++--
atr/post/upload.py | 56 +++++++-----
atr/post/user.py | 11 ++-
atr/post/vote.py | 21 +++--
atr/post/voting.py | 59 +++++++------
tests/docker-compose.yml | 2 +
tests/run-playwright.sh | 4 +-
24 files changed, 708 insertions(+), 481 deletions(-)
diff --git a/atr/blueprints/common.py b/atr/blueprints/common.py
index 0428b83d..ccdca547 100644
--- a/atr/blueprints/common.py
+++ b/atr/blueprints/common.py
@@ -17,13 +17,14 @@
import inspect
from collections.abc import Callable
-from typing import Any, Literal, get_args, get_origin, get_type_hints
+from typing import Annotated, Any, Literal, TypeAliasType, get_args, get_origin, get_type_hints
import asfquart.base as base
import asfquart.session
import atr.cache as cache
import atr.db as db
+import atr.form as form
import atr.ldap as ldap
import atr.models.safe as safe
import atr.models.sql as sql
@@ -60,12 +61,16 @@ async def authenticate_public() -> web.Public:
return None
-def build_path(func: Callable[..., Any]) -> tuple[str, list[tuple[str, type]], dict[str, str], bool]:
+def build_path(
+ func: Callable[..., Any],
+) -> tuple[str, list[tuple[str, type]], dict[str, str], tuple[str, type] | None, bool]:
"""Inspect a function's type hints to build a URL path and a validation plan.
- Returns (path, validated_params, literal_params, public) where validated_params is a
- list of (param_name, param_type) for each parameter that needs async
- validation, literal_params maps parameter names to their values, and public is whether the route is public or not
+ Returns (path, validated_params, literal_params, form_param, public) where:
+ - validated_params: (name, type) pairs for URL params validated via cache/DB
+ - literal_params: param name → literal string value for Literal["..."] params
+ - form_param: (name, type) for the single form.Form subclass param, or None
+ - public: True if the session type is web.Public
"""
hints = get_type_hints(func, include_extras=True)
params = list(inspect.signature(func).parameters.keys())
@@ -73,38 +78,34 @@ def build_path(func: Callable[..., Any]) -> tuple[str, list[tuple[str, type]], d
segments: list[str] = []
validated_params: list[tuple[str, type]] = []
literal_params: dict[str, str] = {}
+ form_param: tuple[str, type] | None = None
for ix, param_name in enumerate(params):
hint = hints.get(param_name)
if hint is None:
raise TypeError(f"Parameter {param_name!r} in {func.__name__} has no type annotation")
- # This is the session object, which should be first
if param_name == "session":
if ix != 0:
raise TypeError(f"Parameter {param_name!r} in {func.__name__} must be first")
public = hint is web.Public
continue
- origin = get_origin(hint)
+ if _is_form_type(hint):
+ if form_param is not None:
+ raise TypeError(f"Parameter {param_name!r} in {func.__name__}: only one Form is allowed")
+ form_param = (param_name, hint)
+ continue
- if origin is Literal:
- literal_value = get_args(hint)[0]
- segments.append(str(literal_value))
- literal_params[param_name] = str(literal_value)
- elif hint in VALIDATED_TYPES:
- segments.append(f"<{param_name}>")
+ segment = _param_to_segment(param_name, hint, func.__name__)
+ segments.append(segment)
+ if hint in VALIDATED_TYPES:
validated_params.append((param_name, hint))
- elif hint in QUART_CONVERTERS:
- converter = QUART_CONVERTERS[hint]
- segments.append(f"<{converter}:{param_name}>")
- elif hint is str:
- segments.append(f"<{param_name}>")
- else:
- raise TypeError(f"Parameter {param_name!r} in {func.__name__} has unsupported type {hint!r}")
+ elif get_origin(hint) is Literal:
+ literal_params[param_name] = str(get_args(hint)[0])
path = "/" + "/".join(segments)
- return path, validated_params, literal_params, public
+ return path, validated_params, literal_params, form_param, public
def register_route(func: Callable[..., Any], prefix: str, routes: list[str]) -> None:
@@ -146,3 +147,29 @@ async def validate_version(project_name: safe.ProjectName, raw: str) -> safe.Ver
if release is None:
raise base.ASFQuartException(f"Version {raw!r} not found for project {project_name!s}", errorcode=404)
return safe.VersionName(release.version)
+
+
+def _is_form_type(hint: Any) -> bool:
+ """Check if a type hint represents a form.Form subclass or Annotated discriminated union of forms."""
+ if isinstance(hint, type) and issubclass(hint, form.Form):
+ return True
+ # Unwrap TypeAliasType to get the underlying type
+ if isinstance(hint, TypeAliasType):
+ hint = hint.__value__
+ if get_origin(hint) is Annotated:
+ args = get_args(hint)
+ return len(args) >= 2 and form.DISCRIMINATOR in args[1:]
+ return False
+
+
+def _param_to_segment(param_name: str, hint: Any, func_name: str) -> str:
+ """Convert a single parameter's type hint into a URL path segment."""
+ if get_origin(hint) is Literal:
+ return str(get_args(hint)[0])
+ if hint in VALIDATED_TYPES:
+ return f"<{param_name}>"
+ if hint in QUART_CONVERTERS:
+ return f"<{QUART_CONVERTERS[hint]}:{param_name}>"
+ if hint is str:
+ return f"<{param_name}>"
+ raise TypeError(f"Parameter {param_name!r} in {func_name} has unsupported type {hint!r}")
diff --git a/atr/blueprints/get.py b/atr/blueprints/get.py
index c978add0..cd586505 100644
--- a/atr/blueprints/get.py
+++ b/atr/blueprints/get.py
@@ -37,6 +37,13 @@ _P = ParamSpec("_P")
_R = TypeVar("_R")
+def register(app: base.QuartApp) -> tuple[ModuleType, list[str]]:
+ import atr.get as get
+
+ app.register_blueprint(_BLUEPRINT)
+ return get, _routes
+
+
@overload
def typed[**P, R](func: Callable[Concatenate[web.Committer, P], Awaitable[R]]) -> web.RouteFunction[R]: ...
@@ -55,7 +62,7 @@ def typed(func: Callable[..., Any]) -> web.RouteFunction[Any]:
- str parameters pass through as-is
- check_access is called automatically for committer routes with project_name
"""
- path, validated_params, literal_params, public = common.build_path(func)
+ path, validated_params, literal_params, _, public = common.build_path(func)
project_name_var = next((name for name, type_name in validated_params if type_name is safe.ProjectName), None)
check_access = not public and (project_name_var is not None)
@@ -89,10 +96,3 @@ def typed(func: Callable[..., Any]) -> web.RouteFunction[Any]:
common.register_route(func, "get", _routes)
return decorated
-
-
-def register(app: base.QuartApp) -> tuple[ModuleType, list[str]]:
- import atr.get as get
-
- app.register_blueprint(_BLUEPRINT)
- return get, _routes
diff --git a/atr/blueprints/post.py b/atr/blueprints/post.py
index ac20af18..a5091269 100644
--- a/atr/blueprints/post.py
+++ b/atr/blueprints/post.py
@@ -19,7 +19,7 @@ import json
import time
from collections.abc import Awaitable, Callable
from types import ModuleType
-from typing import Any
+from typing import Any, Concatenate, overload
import asfquart.auth as auth
import asfquart.base as base
@@ -27,9 +27,11 @@ import asfquart.session
import pydantic
import quart
+import atr.blueprints.common as common
import atr.form
import atr.ldap as ldap
import atr.log as log
+import atr.models.safe as safe
import atr.web as web
_BLUEPRINT_NAME = "post_blueprint"
@@ -37,171 +39,92 @@ _BLUEPRINT = quart.Blueprint(_BLUEPRINT_NAME, __name__)
_routes: list[str] = []
-async def _authenticate() -> web.Committer:
- web_session = await asfquart.session.read()
- if web_session is None:
- raise base.ASFQuartException("Not authenticated", errorcode=401)
- if (web_session.uid is None) or (not await ldap.is_active(web_session.uid)):
- asfquart.session.clear()
- raise base.ASFQuartException("Account is disabled", errorcode=401)
- return web.Committer(web_session)
+def register(app: base.QuartApp) -> tuple[ModuleType, list[str]]:
+ import atr.post as post
+ app.register_blueprint(_BLUEPRINT)
+ return post, _routes
+
+
+@overload
+def typed[**P, R](func: Callable[Concatenate[web.Committer, P], Awaitable[R]]) -> web.RouteFunction[R]: ...
+
+
+@overload
+def typed[**P, R](func: Callable[Concatenate[web.Public, P], Awaitable[R]]) -> web.RouteFunction[R]: ... # pyright: ignore[reportOverlappingOverload]
-def _register(func: Callable[..., Any]) -> None:
- module_name = func.__module__.split(".")[-1]
- _routes.append(f"post.{module_name}.{func.__name__}")
-
-
-def committer(path: str) -> Callable[[web.CommitterRouteFunction[Any]], web.RouteFunction[Any]]:
- def decorator(func: web.CommitterRouteFunction[Any]) -> web.RouteFunction[Any]:
- async def wrapper(*args: Any, **kwargs: Any) -> Any:
- enhanced_session = await _authenticate()
- start_time_ns = time.perf_counter_ns()
- response = await func(enhanced_session, *args, **kwargs)
- end_time_ns = time.perf_counter_ns()
- total_ns = end_time_ns - start_time_ns
- total_ms = total_ns // 1_000_000
-
- # TODO: Make this configurable in config.py
- log.performance(
- f"POST {path} {func.__name__} = 0 0 {total_ms}",
- )
-
- return response
-
- endpoint = func.__module__.replace(".", "_") + "_" + func.__name__
- wrapper.__name__ = func.__name__
- wrapper.__doc__ = func.__doc__
- wrapper.__annotations__["endpoint"] = _BLUEPRINT_NAME + "." + endpoint
-
- decorated = auth.require(auth.Requirements.committer)(wrapper)
- _BLUEPRINT.add_url_rule(path, endpoint=endpoint, view_func=decorated, methods=["POST"])
- _register(func)
-
- return decorated
-
- return decorator
-
-
-def empty() -> Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]:
- # This means that instead of:
- #
- # @post.form(form.Empty)
- # async def test_empty(
- # session: web.Public,
- # form: form.Empty,
- # ) -> web.WerkzeugResponse:
- # pass
- #
- # We can use:
- #
- # @post.empty()
- # async def test_empty(
- # session: web.Public,
- # ) -> web.WerkzeugResponse:
- # pass
- def decorator(func: Callable[..., Awaitable[Any]]) -> Callable[..., Awaitable[Any]]:
- async def wrapper(session: web.Committer | None, *args: Any, **kwargs: Any) -> Any:
- match session:
- case web.Committer() as committer:
- form_data = await committer.form_data()
- case None:
- form_data = await atr.form.quart_request()
- try:
- context = {
- "args": args,
- "kwargs": kwargs,
- "session": session,
- }
- match session:
- case web.Committer() as committer:
- await committer.form_validate(atr.form.Empty, context=context)
- case None:
- atr.form.validate(atr.form.Empty, form_data, context=context)
- return await func(session, *args, **kwargs)
- except pydantic.ValidationError:
- # This could happen if the form was tampered with
- # It should not happen if the CSRF token is invalid
- msg = "Sorry, there was an empty form validation error. Please try again."
- await quart.flash(msg, "error")
- return quart.redirect(quart.request.path)
- wrapper.__annotations__ = func.__annotations__.copy()
- wrapper.__doc__ = func.__doc__
- wrapper.__module__ = func.__module__
- wrapper.__name__ = func.__name__
- return wrapper
-
- return decorator
-
-
-def form(
- form_cls: Any,
-) -> Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]:
- def decorator(func: Callable[..., Awaitable[Any]]) -> Callable[..., Awaitable[Any]]:
- async def wrapper(session: web.Committer | None, *args: Any, **kwargs: Any) -> Any:
- match session:
- case web.Committer() as committer:
- form_data = await committer.form_data()
- case None:
- form_data = await atr.form.quart_request()
+def typed(func: Callable[..., Any]) -> web.RouteFunction[Any]:
+ """Decorator that derives the URL path from the function's type annotations.
+
+ - Arguments after session are joined with / to make the web path
+ - Literal["..."] parameters become literal path segments
+ - safe.ProjectName / safe.VersionName parameters are validated via cache/DB
+ - int, float use Quart's built-in type converters
+ - str parameters pass through as-is
+ - A single form.Form subclass parameter is validated from the request body and injected
+ - check_access is called automatically for committer routes with project_name
+ """
+ path, validated_params, literal_params, form_param, public = common.build_path(func)
+ project_name_var = next((name for name, t in validated_params if t is safe.ProjectName), None)
+ check_access = not public and (project_name_var is not None)
+
+ async def wrapper(*_args: Any, **kwargs: Any) -> Any:
+ enhanced_session = await common.authenticate_public() if public else await common.authenticate()
+ await common.run_validators(kwargs, validated_params)
+ kwargs.update(literal_params)
+
+ if check_access and enhanced_session is not None and project_name_var is not None:
+ await enhanced_session.check_access(str(kwargs[project_name_var]))
+
+ if form_param is not None:
+ form_param_name, form_cls = form_param
+ context: dict[str, Any] = {"kwargs": kwargs, "session": enhanced_session}
try:
- context = {
- "args": args,
- "kwargs": kwargs,
- "session": session,
- }
- match session:
+ match enhanced_session:
case web.Committer() as committer:
- validated_form = await committer.form_validate(form_cls, context)
+ kwargs[form_param_name] = await committer.form_validate(form_cls, context)
case None:
- validated_form = atr.form.validate(form_cls, form_data, context=context)
- return await func(session, validated_form, *args, **kwargs)
+ form_data = await atr.form.quart_request()
+ kwargs[form_param_name] = atr.form.validate(form_cls, form_data, context=context)
except pydantic.ValidationError as e:
errors = e.errors()
if len(errors) == 0:
raise RuntimeError("Validation failed, but no errors were reported")
- flash_data = atr.form.flash_error_data(form_cls, errors, form_data)
+ form_data_raw = await atr.form.quart_request()
+ flash_data = atr.form.flash_error_data(form_cls, errors, form_data_raw)
summary = atr.form.flash_error_summary(errors, flash_data)
-
- # TODO: Centralise all uses of markupsafe.Markup
- # log.info(f"Flash data: {flash_data}")
await quart.flash(summary, category="error")
await quart.flash(json.dumps(flash_data), category="form-error-data")
return quart.redirect(quart.request.path)
- wrapper.__annotations__ = func.__annotations__.copy()
- wrapper.__doc__ = func.__doc__
- wrapper.__module__ = func.__module__
- wrapper.__name__ = func.__name__
- return wrapper
+ start_time_ns = time.perf_counter_ns()
+ response = await func(enhanced_session, **kwargs)
+ end_time_ns = time.perf_counter_ns()
+ total_ns = end_time_ns - start_time_ns
+ total_ms = total_ns // 1_000_000
- return decorator
+ log.performance(f"POST {path} {func.__name__} = 0 0 {total_ms}")
+ return response
-def public(path: str) -> Callable[[Callable[..., Awaitable[Any]]], web.RouteFunction[Any]]:
- def decorator(func: Callable[..., Awaitable[Any]]) -> web.RouteFunction[Any]:
- async def wrapper(*args: Any, **kwargs: Any) -> Any:
- web_session = await asfquart.session.read()
- enhanced_session = web.Committer(web_session) if web_session else None
- return await func(enhanced_session, *args, **kwargs)
+ endpoint = func.__module__.replace(".", "_") + "_" + func.__name__
+ wrapper.__name__ = func.__name__
+ wrapper.__doc__ = func.__doc__
+ wrapper.__annotations__["endpoint"] = _BLUEPRINT_NAME + "." + endpoint
- endpoint = func.__module__.replace(".", "_") + "_" + func.__name__
- wrapper.__annotations__["endpoint"] = _BLUEPRINT_NAME + "." + endpoint
- wrapper.__doc__ = func.__doc__
- wrapper.__module__ = func.__module__
- wrapper.__name__ = func.__name__
+ decorated = wrapper if public else auth.require(auth.Requirements.committer)(wrapper)
+ _BLUEPRINT.add_url_rule(path, endpoint=endpoint, view_func=decorated, methods=["POST"])
+ common.register_route(func, "post", _routes)
- _BLUEPRINT.add_url_rule(path, endpoint=endpoint, view_func=wrapper, methods=["POST"])
- _register(func)
+ return decorated
- return wrapper
-
- return decorator
-
-
-def register(app: base.QuartApp) -> tuple[ModuleType, list[str]]:
- import atr.post as post
- app.register_blueprint(_BLUEPRINT)
- return post, _routes
+async def _authenticate() -> web.Committer:
+ web_session = await asfquart.session.read()
+ if web_session is None:
+ raise base.ASFQuartException("Not authenticated", errorcode=401)
+ if (web_session.uid is None) or (not await ldap.is_active(web_session.uid)):
+ asfquart.session.clear()
+ raise base.ASFQuartException("Account is disabled", errorcode=401)
+ return web.Committer(web_session)
diff --git a/atr/get/distribution.py b/atr/get/distribution.py
index 82017a2f..3b608ca2 100644
--- a/atr/get/distribution.py
+++ b/atr/get/distribution.py
@@ -120,7 +120,9 @@ async def list_get(
delete_form = form.render(
model_cls=shared.distribution.DeleteForm,
- action=util.as_url(post.distribution.delete, project=str(project_name), version=str(version_name)),
+ action=util.as_url(
+ post.distribution.delete, project_name=str(project_name), version_name=str(version_name)
+ ),
form_classes=".d-inline-block.m-0",
submit_classes="btn-danger btn-sm",
submit_label="Delete",
@@ -202,9 +204,9 @@ async def _automate_form_page(project: str, version: str, staging: bool) -> str:
# Determine the action based on staging
action = (
- util.as_url(post.distribution.stage_automate_selected, project=project, version=version)
+ util.as_url(post.distribution.stage_automate_selected, project_name=project, version_name=version)
if staging
- else util.as_url(post.distribution.automate_selected, project=project, version=version)
+ else util.as_url(post.distribution.automate_selected, project_name=project, version_name=version)
)
# TODO: Reuse the same form for now - maybe we can combine this and the function below adding an automate=True arg
@@ -275,9 +277,9 @@ async def _record_form_page(project: str, version: str, staging: bool) -> str:
# Determine the action based on staging
action = (
- util.as_url(post.distribution.stage_record_selected, project=project, version=version)
+ util.as_url(post.distribution.stage_record_selected, project_name=project, version_name=version)
if staging
- else util.as_url(post.distribution.record_selected, project=project, version=version)
+ else util.as_url(post.distribution.record_selected, project_name=project, version_name=version)
)
# Render the distribution form
diff --git a/atr/post/announce.py b/atr/post/announce.py
index 1513e94a..417c0dd6 100644
--- a/atr/post/announce.py
+++ b/atr/post/announce.py
@@ -17,10 +17,12 @@
from __future__ import annotations
-# TODO: Improve upon the routes_release pattern
+from typing import Literal
+
import atr.blueprints.post as post
import atr.construct as construct
import atr.get as get
+import atr.models.safe as safe
import atr.models.sql as sql
import atr.shared as shared
import atr.storage as storage
@@ -28,14 +30,18 @@ import atr.util as util
import atr.web as web
-@post.committer("/announce/<project_name>/<version_name>")
-@post.form(shared.announce.AnnounceForm)
+@post.typed
async def selected(
- session: web.Committer, announce_form: shared.announce.AnnounceForm, project_name: str, version_name: str
+ session: web.Committer,
+ _announce: Literal["announce"],
+ project_name: safe.ProjectName,
+ version_name: safe.VersionName,
+ announce_form: shared.announce.AnnounceForm,
) -> web.WerkzeugResponse:
- """Handle the announcement form submission and promote the preview to release."""
- await session.check_access(project_name)
-
+ """
+ URL: /announce/<project_name>/<version_name>
+ Handle the announcement form submission and promote the preview to release.
+ """
permitted_recipients = util.permitted_announce_recipients(session.uid)
# Validate that the recipient is permitted
@@ -47,8 +53,8 @@ async def selected(
# Get the release to find the revision number
release = await session.release(
- project_name,
- version_name,
+ str(project_name),
+ str(version_name),
with_committee=True,
phase=sql.ReleasePhase.RELEASE_PREVIEW,
with_distributions=True,
@@ -63,8 +69,8 @@ async def selected(
get.announce.selected,
error=f"The release has been updated since you loaded the form. "
f"Please review the current revision ({preview_revision_number}) and submit the form again.",
- project_name=project_name,
- version_name=version_name,
+ project_name=str(project_name),
+ version_name=str(version_name),
)
policy = release.release_policy or release.project.release_policy
@@ -81,12 +87,12 @@ async def selected(
error=f"This release cannot be announced until the following distributions have been recorded: {
', '.join(missing)
}",
- project_name=project_name,
- version_name=version_name,
+ project_name=str(project_name),
+ version_name=str(version_name),
)
# Validate that the subject template hasn't changed
- subject_template = await construct.announce_release_subject_default(project_name)
+ subject_template = await construct.announce_release_subject_default(str(project_name))
current_hash = construct.template_hash(subject_template)
if current_hash != announce_form.subject_template_hash:
return await session.form_error(
@@ -95,10 +101,10 @@ async def selected(
)
try:
- async with storage.write_as_project_committee_member(project_name, session) as wacm:
+ async with storage.write_as_project_committee_member(str(project_name), session) as wacm:
await wacm.announce.release(
- project_name=project_name,
- version_name=version_name,
+ project_name=str(project_name),
+ version_name=str(version_name),
preview_revision_number=preview_revision_number,
recipient=announce_form.mailing_list,
body=announce_form.body,
@@ -109,12 +115,12 @@ async def selected(
)
except storage.AccessError as e:
return await session.redirect(
- get.announce.selected, error=str(e), project_name=project_name, version_name=version_name
+ get.announce.selected, error=str(e), project_name=str(project_name), version_name=str(version_name)
)
routes_release_finished = get.release.finished
return await session.redirect(
routes_release_finished,
success="Preview successfully announced",
- project_name=project_name,
+ project_name=str(project_name),
)
diff --git a/atr/post/distribution.py b/atr/post/distribution.py
index e3936821..e31f77dc 100644
--- a/atr/post/distribution.py
+++ b/atr/post/distribution.py
@@ -17,13 +17,14 @@
from __future__ import annotations
-from typing import Final
+from typing import Final, Literal
import atr.blueprints.post as post
import atr.db as db
import atr.db.interaction as interaction
import atr.get as get
import atr.models.distribution as distribution
+import atr.models.safe as safe
import atr.models.sql as sql
import atr.shared as shared
import atr.storage as storage
@@ -50,8 +51,8 @@ async def automate_form_process_page(
platform_str = form_data.platform.value
return await session.redirect(
get.distribution.stage_automate if staging else get.distribution.automate,
- project=project,
- version=version,
+ project_name=project,
+ version_name=version,
error=f"Platform {platform_str} is not supported for automated distribution",
)
sql_platform = form_data.platform.to_sql() # type: ignore[attr-defined]
@@ -92,8 +93,8 @@ async def automate_form_process_page(
# Instead of calling record_form_page_new, redirect with error message
return await session.redirect(
get.distribution.stage_automate if staging else get.distribution.automate,
- project=project,
- version=version,
+ project_name=project,
+ version_name=version,
error=str(e),
)
@@ -107,19 +108,33 @@ async def automate_form_process_page(
)
-@post.committer("/distribution/automate/<project>/<version>")
-@post.form(shared.distribution.DistributeForm)
+@post.typed
async def automate_selected(
- session: web.Committer, distribute_form: shared.distribution.DistributeForm, project: str, version: str
+ session: web.Committer,
+ _distribution_automate: Literal["distribution/automate"],
+ project_name: safe.ProjectName,
+ version_name: safe.VersionName,
+ distribute_form: shared.distribution.DistributeForm,
) -> web.WerkzeugResponse:
- return await automate_form_process_page(session, distribute_form, project, version, staging=False)
+ """
+ URL: /distribution/automate/<project_name>/<version_name>
+ """
+ return await automate_form_process_page(
+ session, distribute_form, str(project_name), str(version_name), staging=False
+ )
-@post.committer("/distribution/delete/<project>/<version>")
-@post.form(shared.distribution.DeleteForm)
+@post.typed
async def delete(
- session: web.Committer, delete_form: shared.distribution.DeleteForm, project: str, version: str
+ session: web.Committer,
+ _distribution_delete: Literal["distribution/delete"],
+ project_name: safe.ProjectName,
+ version_name: safe.VersionName,
+ delete_form: shared.distribution.DeleteForm,
) -> web.WerkzeugResponse:
+ """
+ URL: /distribution/delete/<project_name>/<version_name>
+ """
sql_platform = delete_form.platform.to_sql() # type: ignore[attr-defined]
# Validate the submitted data, and obtain the committee for its name
@@ -142,8 +157,8 @@ async def delete(
)
return await session.redirect(
get.distribution.list_get,
- project_name=project,
- version_name=version,
+ project_name=str(project_name),
+ version_name=str(version_name),
success="Distribution deleted",
)
@@ -182,8 +197,8 @@ async def record_form_process_page(
# Instead of calling record_form_page_new, redirect with error message
return await session.redirect(
get.distribution.stage_record if staging else get.distribution.record,
- project=project,
- version=version,
+ project_name=project,
+ version_name=version,
error=str(e),
)
@@ -197,25 +212,45 @@ async def record_form_process_page(
)
-@post.committer("/distribution/record/<project>/<version>")
-@post.form(shared.distribution.DistributeForm)
+@post.typed
async def record_selected(
- session: web.Committer, distribute_form: shared.distribution.DistributeForm, project: str, version: str
+ session: web.Committer,
+ _distribution_record: Literal["distribution/record"],
+ project_name: safe.ProjectName,
+ version_name: safe.VersionName,
+ distribute_form: shared.distribution.DistributeForm,
) -> web.WerkzeugResponse:
- return await record_form_process_page(session, distribute_form, project, version, staging=False)
+ """
+ URL: /distribution/record/<project_name>/<version_name>
+ """
+ return await record_form_process_page(session, distribute_form, str(project_name), str(version_name), staging=False)
-@post.committer("/distribution/stage/automate/<project>/<version>")
-@post.form(shared.distribution.DistributeForm)
+@post.typed
async def stage_automate_selected(
- session: web.Committer, distribute_form: shared.distribution.DistributeForm, project: str, version: str
+ session: web.Committer,
+ _distribution_stage_automate: Literal["distribution/stage/automate"],
+ project_name: safe.ProjectName,
+ version_name: safe.VersionName,
+ distribute_form: shared.distribution.DistributeForm,
) -> web.WerkzeugResponse:
- return await automate_form_process_page(session, distribute_form, project, version, staging=True)
+ """
+ URL: /distribution/stage/automate/<project_name>/<version_name>
+ """
+ return await automate_form_process_page(
+ session, distribute_form, str(project_name), str(version_name), staging=True
+ )
-@post.committer("/distribution/stage/record/<project>/<version>")
-@post.form(shared.distribution.DistributeForm)
+@post.typed
async def stage_record_selected(
- session: web.Committer, distribute_form: shared.distribution.DistributeForm, project: str, version: str
+ session: web.Committer,
+ _distribution_stage_record: Literal["distribution/stage/record"],
+ project_name: safe.ProjectName,
+ version_name: safe.VersionName,
+ distribute_form: shared.distribution.DistributeForm,
) -> web.WerkzeugResponse:
- return await record_form_process_page(session, distribute_form, project, version, staging=True)
+ """
+ URL: /distribution/stage/record/<project_name>/<version_name>
+ """
+ return await record_form_process_page(session, distribute_form, str(project_name), str(version_name), staging=True)
diff --git a/atr/post/draft.py b/atr/post/draft.py
index c35a3234..af36550b 100644
--- a/atr/post/draft.py
+++ b/atr/post/draft.py
@@ -17,7 +17,7 @@
from __future__ import annotations
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Literal
import aiofiles.os
import asfquart.base as base
@@ -28,7 +28,9 @@ import atr.db.interaction as interaction
import atr.form as form
import atr.get as get
import atr.log as log
+import atr.models.safe as safe
import atr.models.sql as sql
+import atr.models.unsafe as unsafe
import atr.shared as shared
import atr.storage as storage
import atr.util as util
@@ -38,20 +40,27 @@ if TYPE_CHECKING:
import pathlib
-@post.committer("/draft/reset/<project_name>/<version_name>")
-@post.empty()
-async def cache_reset(session: web.Committer, project_name: str, version_name: str) -> web.WerkzeugResponse:
- """Start a new draft revision and switch this release to global caching"""
- await session.check_access(project_name)
+@post.typed
+async def cache_reset(
+ session: web.Committer,
+ _draft_reset: Literal["draft/reset"],
+ project_name: safe.ProjectName,
+ version_name: safe.VersionName,
+ _form: form.Empty,
+) -> web.WerkzeugResponse:
+ """
+ URL: /draft/reset/<project_name>/<version_name>
+ Start a new draft revision and switch this release to global caching.
+ """
if not session.is_admin:
raise base.ASFQuartException("Admin access required", errorcode=403)
description = "Empty revision to restart all checks without cache for the whole release candidate draft"
async with storage.write(session) as write:
- wacp = await write.as_project_committee_participant(project_name)
+ wacp = await write.as_project_committee_participant(str(project_name))
await wacp.revision.create_revision(
- project_name,
- version_name,
+ str(project_name),
+ str(version_name),
session.uid,
description=description,
reset_to_global_cache=True,
@@ -59,106 +68,144 @@ async def cache_reset(session: web.Committer, project_name: str, version_name: s
return await session.redirect(
get.compose.selected,
- project_name=project_name,
- version_name=version_name,
+ project_name=str(project_name),
+ version_name=str(version_name),
success="Release set back to global caching",
)
-@post.committer("/compose/<project_name>/<version_name>")
-@post.empty()
-async def delete(session: web.Committer, project_name: str, version_name: str) -> web.WerkzeugResponse:
- """Delete a candidate draft and all its associated files."""
-
- await session.check_access(project_name)
-
+@post.typed
+async def delete(
+ session: web.Committer,
+ _compose: Literal["compose"],
+ project_name: safe.ProjectName,
+ version_name: safe.VersionName,
+ _form: form.Empty,
+) -> web.WerkzeugResponse:
+ """
+ URL: /compose/<project_name>/<version_name>
+ Delete a candidate draft and all its associated files.
+ """
# Delete the metadata from the database
async with storage.write(session) as write:
- wacp = await write.as_project_committee_participant(project_name)
+ wacp = await write.as_project_committee_participant(str(project_name))
error = await wacp.release.delete(
- project_name, version_name, phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT, include_downloads=False
+ str(project_name),
+ str(version_name),
+ phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+ include_downloads=False,
)
# Ensure that deletion errors are reported to the user
if error is not None:
await quart.flash(f"Error deleting candidate draft: {error}", "error")
- return await session.redirect(get.compose.selected, project_name=project_name, version_name=version_name)
+ return await session.redirect(
+ get.compose.selected, project_name=str(project_name), version_name=str(version_name)
+ )
return await session.redirect(get.root.index, success="Candidate draft deleted successfully")
-@post.committer("/draft/delete-file/<project_name>/<version_name>")
-@post.form(shared.draft.DeleteFileForm)
+@post.typed
async def delete_file(
- session: web.Committer, delete_file_form: shared.draft.DeleteFileForm, project_name: str, version_name: str
+ session: web.Committer,
+ _draft_delete_file: Literal["draft/delete-file"],
+ project_name: safe.ProjectName,
+ version_name: safe.VersionName,
+ delete_file_form: shared.draft.DeleteFileForm,
) -> web.WerkzeugResponse:
- """Delete a specific file from the release candidate, creating a new revision."""
- await session.check_access(project_name)
-
+ """
+ URL: /draft/delete-file/<project_name>/<version_name>
+ Delete a specific file from the release candidate, creating a new revision.
+ """
rel_path_to_delete = delete_file_form.file_path
if rel_path_to_delete is None:
await quart.flash("No file path specified", "error")
- return await session.redirect(get.compose.selected, project_name=project_name, version_name=version_name)
+ return await session.redirect(
+ get.compose.selected, project_name=str(project_name), version_name=str(version_name)
+ )
try:
async with storage.write(session) as write:
- wacp = await write.as_project_committee_participant(project_name)
- metadata_files_deleted = await wacp.release.delete_file(project_name, version_name, rel_path_to_delete)
+ wacp = await write.as_project_committee_participant(str(project_name))
+ metadata_files_deleted = await wacp.release.delete_file(
+ str(project_name), str(version_name), rel_path_to_delete
+ )
except Exception as e:
log.exception("Error deleting file:")
await quart.flash(f"Error deleting file: {e!s}", "error")
- return await session.redirect(get.compose.selected, project_name=project_name, version_name=version_name)
+ return await session.redirect(
+ get.compose.selected, project_name=str(project_name), version_name=str(version_name)
+ )
success_message = f"File '{rel_path_to_delete.name}' deleted successfully"
if metadata_files_deleted:
success_message += f", and {util.plural(metadata_files_deleted, 'associated metadata file')} deleted"
return await session.redirect(
- get.compose.selected, success=success_message, project_name=project_name, version_name=version_name
+ get.compose.selected, success=success_message, project_name=str(project_name), version_name=str(version_name)
)
-@post.committer("/draft/hashgen/<project_name>/<version_name>/<path:file_path>")
-@post.empty()
-async def hashgen(session: web.Committer, project_name: str, version_name: str, file_path: str) -> web.WerkzeugResponse:
- """Generate an sha512 hash file for a candidate draft file, creating a new revision."""
- await session.check_access(project_name)
-
+@post.typed
+async def hashgen(
+ session: web.Committer,
+ _draft_hashgen: Literal["draft/hashgen"],
+ project_name: safe.ProjectName,
+ version_name: safe.VersionName,
+ file_path: unsafe.Path,
+ empty_form: form.Empty,
+) -> web.WerkzeugResponse:
+ """
+ URL: /draft/hashgen/<project_name>/<version_name>/<file_path>
+ Generate an sha512 hash file for a candidate draft file, creating a new revision.
+ """
rel_path = form.to_relpath(file_path)
if rel_path is None:
await quart.flash("Invalid file path", "error")
- return await session.redirect(get.compose.selected, project_name=project_name, version_name=version_name)
+ return await session.redirect(
+ get.compose.selected, project_name=str(project_name), version_name=str(version_name)
+ )
try:
async with storage.write(session) as write:
- wacp = await write.as_project_committee_participant(project_name)
- await wacp.release.generate_hash_file(project_name, version_name, rel_path)
+ wacp = await write.as_project_committee_participant(str(project_name))
+ await wacp.release.generate_hash_file(str(project_name), str(version_name), rel_path)
except Exception as e:
log.exception("Error generating hash file:")
await quart.flash(f"Error generating hash file: {e!s}", "error")
- return await session.redirect(get.compose.selected, project_name=project_name, version_name=version_name)
+ return await session.redirect(
+ get.compose.selected, project_name=str(project_name), version_name=str(version_name)
+ )
return await session.redirect(
get.compose.selected,
success="SHA512 file generated successfully",
- project_name=project_name,
- version_name=version_name,
+ project_name=str(project_name),
+ version_name=str(version_name),
)
-@post.committer("/draft/recheck/<project_name>/<version_name>")
-@post.empty()
-async def recheck(session: web.Committer, project_name: str, version_name: str) -> web.WerkzeugResponse:
- """Start a new draft revision and switch this release to release-local caching"""
- await session.check_access(project_name)
+@post.typed
+async def recheck(
+ session: web.Committer,
+ _draft_recheck: Literal["draft/recheck"],
+ project_name: safe.ProjectName,
+ version_name: safe.VersionName,
+ empty_form: form.Empty,
+) -> web.WerkzeugResponse:
+ """
+ URL: /draft/recheck/<project_name>/<version_name>
+ Start a new draft revision and switch this release to release-local caching.
+ """
if not session.is_admin:
raise base.ASFQuartException("Admin access required", errorcode=403)
description = "Empty revision to restart all checks without cache for the whole release candidate draft"
async with storage.write(session) as write:
- wacp = await write.as_project_committee_participant(project_name)
+ wacp = await write.as_project_committee_participant(str(project_name))
await wacp.revision.create_revision(
- project_name,
- version_name,
+ str(project_name),
+ str(version_name),
session.uid,
description=description,
set_local_cache=True,
@@ -166,22 +213,31 @@ async def recheck(session: web.Committer, project_name: str, version_name: str)
return await session.redirect(
get.compose.selected,
- project_name=project_name,
- version_name=version_name,
+ project_name=str(project_name),
+ version_name=str(version_name),
success="All checks restarted with release-local cache",
)
-@post.committer("/draft/sbomgen/<project_name>/<version_name>/<path:file_path>")
-@post.empty()
-async def sbomgen(session: web.Committer, project_name: str, version_name: str, file_path: str) -> web.WerkzeugResponse:
- """Generate a CycloneDX SBOM file for a candidate draft file, creating a new revision."""
- await session.check_access(project_name)
-
+@post.typed
+async def sbomgen(
+ session: web.Committer,
+ _draft_sbomgen: Literal["draft/sbomgen"],
+ project_name: safe.ProjectName,
+ version_name: safe.VersionName,
+ file_path: unsafe.Path,
+ empty_form: form.Empty,
+) -> web.WerkzeugResponse:
+ """
+ URL: /draft/sbomgen/<project_name>/<version_name>/<file_path>
+ Generate a CycloneDX SBOM file for a candidate draft file, creating a new revision.
+ """
rel_path = form.to_relpath(file_path)
if rel_path is None:
await quart.flash("Invalid file path", "error")
- return await session.redirect(get.compose.selected, project_name=project_name, version_name=version_name)
+ return await session.redirect(
+ get.compose.selected, project_name=str(project_name), version_name=str(version_name)
+ )
# Check that the file is a .tar.gz archive before creating a revision
if not (
@@ -197,7 +253,7 @@ async def sbomgen(session: web.Committer, project_name: str, version_name: str,
try:
description = "SBOM generation through web interface"
async with storage.write(session) as write:
- wacp = await write.as_project_committee_participant(project_name)
+ wacp = await write.as_project_committee_participant(str(project_name))
async def modify(path: pathlib.Path, old_rev: sql.Revision | None) -> None:
path_in_new_revision = path / rel_path
@@ -219,24 +275,30 @@ async def sbomgen(session: web.Committer, project_name: str, version_name: str,
# Create and queue the task, using paths within the new revision
sbom_task = await wacp.sbom.generate_cyclonedx(
- project_name, version_name, old_rev.number, path_in_new_revision, sbom_path_in_new_revision
+ str(project_name),
+ str(version_name),
+ old_rev.number,
+ path_in_new_revision,
+ sbom_path_in_new_revision,
)
success = await interaction.wait_for_task(sbom_task)
if not success:
raise web.FlashError("Internal error: SBOM generation timed out")
await wacp.revision.create_revision(
- project_name, version_name, session.uid, description=description, modify=modify
+ str(project_name), str(version_name), session.uid, description=description, modify=modify
)
except Exception as e:
log.exception("Error generating SBOM:")
await quart.flash(f"Error generating SBOM: {e!s}", "error")
- return await session.redirect(get.compose.selected, project_name=project_name, version_name=version_name)
+ return await session.redirect(
+ get.compose.selected, project_name=str(project_name), version_name=str(version_name)
+ )
return await session.redirect(
get.compose.selected,
success=f"SBOM generated for {rel_path.name}",
- project_name=project_name,
- version_name=version_name,
+ project_name=str(project_name),
+ version_name=str(version_name),
)
diff --git a/atr/post/finish.py b/atr/post/finish.py
index 7408891d..fa438c87 100644
--- a/atr/post/finish.py
+++ b/atr/post/finish.py
@@ -15,31 +15,40 @@
# specific language governing permissions and limitations
# under the License.
+from typing import Literal
+
import quart
import atr.blueprints.post as post
import atr.log as log
+import atr.models.safe as safe
import atr.shared as shared
import atr.storage as storage
import atr.util as util
import atr.web as web
-@post.committer("/finish/<project_name>/<version_name>")
-@post.form(shared.finish.FinishForm)
+@post.typed
async def selected(
- session: web.Committer, finish_form: shared.finish.FinishForm, project_name: str, version_name: str
+ session: web.Committer,
+ _finish: Literal["finish"],
+ project_name: safe.ProjectName,
+ version_name: safe.VersionName,
+ finish_form: shared.finish.FinishForm,
) -> tuple[web.QuartResponse, int] | web.WerkzeugResponse:
+ """
+ URL: /finish/<project_name>/<version_name>
+ """
wants_json = quart.request.accept_mimetypes.best_match(["application/json", "text/html"]) == "application/json"
- respond = _respond_helper(session, project_name, version_name, wants_json)
+ respond = _respond_helper(session, str(project_name), str(version_name), wants_json)
match finish_form:
case shared.finish.DeleteEmptyDirectoryForm() as delete_form:
- return await _delete_empty_directory(delete_form, session, project_name, version_name, respond)
+ return await _delete_empty_directory(delete_form, session, str(project_name), str(version_name), respond)
case shared.finish.MoveFileForm() as move_form:
- return await _move_file_to_revision(move_form, session, project_name, version_name, respond)
+ return await _move_file_to_revision(move_form, session, str(project_name), str(version_name), respond)
case shared.finish.RemoveRCTagsForm():
- return await _remove_rc_tags(session, project_name, version_name, respond)
+ return await _remove_rc_tags(session, str(project_name), str(version_name), respond)
async def _delete_empty_directory(
diff --git a/atr/post/ignores.py b/atr/post/ignores.py
index 974dbb2d..853942cb 100644
--- a/atr/post/ignores.py
+++ b/atr/post/ignores.py
@@ -16,28 +16,36 @@
# under the License.
+from typing import Literal
+
import atr.blueprints.post as post
import atr.get as get
+import atr.models.safe as safe
import atr.shared as shared
import atr.storage as storage
import atr.web as web
-@post.committer("/ignores/<project_name>")
-@post.form(shared.ignores.IgnoreForm)
+@post.typed
async def ignores(
- session: web.Committer, ignore_form: shared.ignores.IgnoreForm, project_name: str
+ session: web.Committer,
+ _ignores: Literal["ignores"],
+ project_name: safe.ProjectName,
+ ignore_form: shared.ignores.IgnoreForm,
) -> web.WerkzeugResponse:
- """Handle forms on the ignores page."""
+ """
+ URL: /ignores/<project_name>
+ Handle forms on the ignores page.
+ """
match ignore_form:
case shared.ignores.AddIgnoreForm() as add_form:
- return await _add_ignore(session, add_form, project_name)
+ return await _add_ignore(session, add_form, str(project_name))
case shared.ignores.DeleteIgnoreForm() as delete_form:
- return await _delete_ignore(session, delete_form, project_name)
+ return await _delete_ignore(session, delete_form, str(project_name))
case shared.ignores.UpdateIgnoreForm() as update_form:
- return await _update_ignore(session, update_form, project_name)
+ return await _update_ignore(session, update_form, str(project_name))
async def _add_ignore(
diff --git a/atr/post/keys.py b/atr/post/keys.py
index 82b6aff2..1e651914 100644
--- a/atr/post/keys.py
+++ b/atr/post/keys.py
@@ -17,7 +17,7 @@
import asyncio
-from typing import Final
+from typing import Final, Literal
import aiohttp
import asfquart.base as base
@@ -25,9 +25,11 @@ import quart
import atr.blueprints.post as post
import atr.db as db
+import atr.form as form
import atr.get as get
import atr.htm as htm
import atr.log as log
+import atr.models.safe as safe
import atr.models.sql as sql
import atr.shared as shared
import atr.storage as storage
@@ -39,10 +41,16 @@ import atr.web as web
_KEYS_BASE_URL: Final[str] = "https://downloads.apache.org"
-@post.committer("/keys/add")
-@post.form(shared.keys.AddOpenPGPKeyForm)
-async def add(session: web.Committer, add_openpgp_key_form: shared.keys.AddOpenPGPKeyForm) -> web.WerkzeugResponse:
- """Add a new public signing key to the user's account."""
+@post.typed
+async def add(
+ session: web.Committer,
+ _keys_add: Literal["keys/add"],
+ add_openpgp_key_form: shared.keys.AddOpenPGPKeyForm,
+) -> web.WerkzeugResponse:
+ """
+ URL: /keys/add
+ Add a new public signing key to the user's account.
+ """
try:
key_text = add_openpgp_key_form.public_key
selected_committee_names = add_openpgp_key_form.selected_committees
@@ -81,12 +89,17 @@ async def add(session: web.Committer, add_openpgp_key_form: shared.keys.AddOpenP
return await session.redirect(get.keys.keys)
-@post.committer("/keys/details/<fingerprint>")
-@post.form(shared.keys.UpdateKeyCommitteesForm)
+@post.typed
async def details(
- session: web.Committer, update_form: shared.keys.UpdateKeyCommitteesForm, fingerprint: str
+ session: web.Committer,
+ _keys_details: Literal["keys/details"],
+ fingerprint: str,
+ update_form: shared.keys.UpdateKeyCommitteesForm,
) -> web.WerkzeugResponse:
- """Update committee associations for an OpenPGP key."""
+ """
+ URL: /keys/details/<fingerprint>
+ Update committee associations for an OpenPGP key.
+ """
fingerprint = fingerprint.lower()
try:
@@ -125,14 +138,20 @@ async def details(
return await session.redirect(get.keys.details, fingerprint=fingerprint)
-@post.committer("/keys/import/<project_name>/<version_name>")
-@post.empty()
+@post.typed
async def import_selected_revision(
- session: web.Committer, project_name: str, version_name: str
+ session: web.Committer,
+ _keys_import: Literal["keys/import"],
+ project_name: safe.ProjectName,
+ version_name: safe.VersionName,
+ _form: form.Empty,
) -> web.WerkzeugResponse:
+ """
+ URL: /keys/import/<project_name>/<version_name>
+ """
async with storage.write() as write:
- wacm = await write.as_project_committee_member(project_name)
- outcomes: outcome.List[types.Key] = await wacm.keys.import_keys_file(project_name, version_name)
+ wacm = await write.as_project_committee_member(str(project_name))
+ outcomes: outcome.List[types.Key] = await wacm.keys.import_keys_file(str(project_name), str(version_name))
message = f"Uploaded {util.plural(outcomes.result_count, 'key')}"
if outcomes.error_count > 0:
@@ -140,15 +159,21 @@ async def import_selected_revision(
return await session.redirect(
get.compose.selected,
success=message,
- project_name=project_name,
- version_name=version_name,
+ project_name=str(project_name),
+ version_name=str(version_name),
)
-@post.committer("/keys")
-@post.form(shared.keys.KeysForm)
-async def keys(session: web.Committer, keys_form: shared.keys.KeysForm) -> web.WerkzeugResponse:
- """Handle forms on the keys management page."""
+@post.typed
+async def keys(
+ session: web.Committer,
+ _keys: Literal["keys"],
+ keys_form: shared.keys.KeysForm,
+) -> web.WerkzeugResponse:
+ """
+ URL: /keys
+ Handle forms on the keys management page.
+ """
match keys_form:
case shared.keys.DeleteOpenPGPKeyForm() as delete_openpgp_form:
return await _delete_openpgp_key(session, delete_openpgp_form)
@@ -160,10 +185,16 @@ async def keys(session: web.Committer, keys_form: shared.keys.KeysForm) -> web.W
return await _update_committee_keys(session, update_committee_form)
-@post.committer("/keys/ssh/add")
-@post.form(shared.keys.AddSSHKeyForm)
-async def ssh_add(session: web.Committer, add_ssh_key_form: shared.keys.AddSSHKeyForm) -> web.WerkzeugResponse:
- """Add a new SSH key to the user's account."""
+@post.typed
+async def ssh_add(
+ session: web.Committer,
+ _keys_ssh_add: Literal["keys/ssh/add"],
+ add_ssh_key_form: shared.keys.AddSSHKeyForm,
+) -> web.WerkzeugResponse:
+ """
+ URL: /keys/ssh/add
+ Add a new SSH key to the user's account.
+ """
try:
async with storage.write(session) as write:
wafc = write.as_foundation_committer()
@@ -179,10 +210,16 @@ async def ssh_add(session: web.Committer, add_ssh_key_form: shared.keys.AddSSHKe
return await session.redirect(get.keys.keys)
-@post.committer("/keys/upload")
-@post.form(shared.keys.UploadKeysForm)
-async def upload(session: web.Committer, upload_form: shared.keys.UploadKeysForm) -> str:
- """Upload or fetch a KEYS file containing multiple OpenPGP keys."""
+@post.typed
+async def upload(
+ session: web.Committer,
+ _keys_upload: Literal["keys/upload"],
+ upload_form: shared.keys.UploadKeysForm,
+) -> str:
+ """
+ URL: /keys/upload
+ Upload or fetch a KEYS file containing multiple OpenPGP keys.
+ """
match upload_form:
case shared.keys.UploadFileForm() as upload_file_form:
return await _upload_file_keys(upload_file_form)
diff --git a/atr/post/manual.py b/atr/post/manual.py
index 4beb8f59..c3c34612 100644
--- a/atr/post/manual.py
+++ b/atr/post/manual.py
@@ -15,10 +15,14 @@
# specific language governing permissions and limitations
# under the License.
+from typing import Literal
+
import atr.blueprints.post as post
import atr.db as db
import atr.db.interaction as interaction
+import atr.form as form
import atr.get as get
+import atr.models.safe as safe
import atr.models.sql as sql
import atr.shared as shared
import atr.storage as storage
@@ -26,19 +30,21 @@ import atr.util as util
import atr.web as web
-@post.committer("/manual/resolve/<project_name>/<version_name>")
-@post.form(shared.manual.ResolveVoteForm)
+@post.typed
async def resolve_selected(
session: web.Committer,
+ _manual_resolve: Literal["manual/resolve"],
+ project_name: safe.ProjectName,
+ version_name: safe.VersionName,
resolve_vote_form: shared.manual.ResolveVoteForm,
- project_name: str,
- version_name: str,
) -> web.WerkzeugResponse | str:
- """Post the manual vote resolution page."""
- await session.check_access(project_name)
+ """
+ URL: /manual/resolve/<project_name>/<version_name>
+ Post the manual vote resolution page.
+ """
release = await session.release(
- project_name,
- version_name,
+ str(project_name),
+ str(version_name),
phase=sql.ReleasePhase.RELEASE_CANDIDATE,
with_release_policy=True,
with_project_release_policy=True,
@@ -51,8 +57,8 @@ async def resolve_selected(
except RuntimeError as e:
return await session.redirect(
get.manual.resolve_selected,
- project_name=project_name,
- version_name=version_name,
+ project_name=str(project_name),
+ version_name=str(version_name),
error=str(e),
)
@@ -64,31 +70,40 @@ async def resolve_selected(
vote_result = "failed"
destination = get.compose.selected
- async with storage.write_as_project_committee_member(project_name) as wacm:
- success_message = await wacm.vote.resolve_manually(project_name, release, vote_result)
+ async with storage.write_as_project_committee_member(str(project_name)) as wacm:
+ success_message = await wacm.vote.resolve_manually(str(project_name), release, vote_result)
return await session.redirect(
- destination, project_name=project_name, version_name=version_name, success=success_message
+ destination,
+ project_name=str(project_name),
+ version_name=str(version_name),
+ success=success_message,
)
-@post.committer("/manual/start/<project_name>/<version_name>/<revision>")
-@post.empty()
+@post.typed
async def start_selected_revision(
- session: web.Committer, project_name: str, version_name: str, revision: str
+ session: web.Committer,
+ _manual_start: Literal["manual/start"],
+ project_name: safe.ProjectName,
+ version_name: safe.VersionName,
+ revision: str,
+ _form: form.Empty,
) -> web.WerkzeugResponse | str:
- await session.check_access(project_name)
+ """
+ URL: /manual/start/<project_name>/<version_name>/<revision>
+ """
async with db.session() as data:
match await interaction.release_ready_for_vote(
- session, project_name, version_name, revision, data, manual_vote=True
+ session, str(project_name), str(version_name), revision, data, manual_vote=True
):
case str() as error:
return await session.redirect(
get.vote.selected,
error=error,
- project_name=project_name,
- version_name=version_name,
+ project_name=str(project_name),
+ version_name=str(version_name),
)
case (release, _committee):
pass
@@ -101,15 +116,15 @@ async def start_selected_revision(
return await session.redirect(
get.vote.selected,
error=error,
- project_name=project_name,
- version_name=version_name,
+ project_name=str(project_name),
+ version_name=str(version_name),
)
return await session.redirect(
get.vote.selected,
success="The manual vote process has been started.",
- project_name=project_name,
- version_name=version_name,
+ project_name=str(project_name),
+ version_name=str(version_name),
)
diff --git a/atr/post/projects.py b/atr/post/projects.py
index b3007626..9a2295d7 100644
--- a/atr/post/projects.py
+++ b/atr/post/projects.py
@@ -17,6 +17,8 @@
from __future__ import annotations
+from typing import Literal
+
import asfquart.base as base
import quart
@@ -29,11 +31,16 @@ import atr.storage as storage
import atr.web as web
-@post.committer("/project/add/<committee_name>")
-@post.form(shared.projects.AddProjectForm)
+@post.typed
async def add_project(
- session: web.Committer, project_form: shared.projects.AddProjectForm, committee_name: str
+ session: web.Committer,
+ _project_add: Literal["project/add"],
+ committee_name: str,
+ project_form: shared.projects.AddProjectForm,
) -> web.WerkzeugResponse:
+ """
+ URL: /project/add/<committee_name>
+ """
display_name = project_form.display_name
label = project_form.label
@@ -51,12 +58,16 @@ async def add_project(
)
-@post.committer("/project/delete")
-@post.form(shared.projects.DeleteSelectedProject)
+@post.typed
async def delete(
- session: web.Committer, delete_selected_project_form: shared.projects.DeleteSelectedProject
+ session: web.Committer,
+ _project_delete: Literal["project/delete"],
+ delete_selected_project_form: shared.projects.DeleteSelectedProject,
) -> web.WerkzeugResponse:
- """Delete a project created by the user."""
+ """
+ URL: /project/delete
+ Delete a project created by the user.
+ """
project_name = delete_selected_project_form.project_name
async with storage.write(session) as write:
@@ -71,11 +82,16 @@ async def delete(
return await session.redirect(get.projects.projects, success=f"Project '{project_name}' deleted successfully.")
-@post.committer("/projects/<name>")
-@post.form(shared.projects.ProjectViewForm)
+@post.typed
async def view(
- session: web.Committer, project_form: shared.projects.ProjectViewForm, name: str
+ session: web.Committer,
+ _projects: Literal["projects"],
+ name: str,
+ project_form: shared.projects.ProjectViewForm,
) -> web.WerkzeugResponse:
+ """
+ URL: /projects/<name>
+ """
match project_form:
case shared.projects.AddCategoryForm() as add_category_form:
return await _process_add_category(session, add_category_form)
diff --git a/atr/post/resolve.py b/atr/post/resolve.py
index 9df73d71..a631f6f5 100644
--- a/atr/post/resolve.py
+++ b/atr/post/resolve.py
@@ -15,12 +15,15 @@
# specific language governing permissions and limitations
# under the License.
+from typing import Literal
+
import quart
import atr.blueprints.post as post
import atr.db.interaction as interaction
import atr.form
import atr.get as get
+import atr.models.safe as safe
import atr.models.sql as sql
import atr.shared as shared
import atr.storage as storage
@@ -30,19 +33,23 @@ import atr.util as util
import atr.web as web
-@post.committer("/resolve/<project_name>/<version_name>")
-@post.form(shared.resolve.ResolveForm)
+@post.typed
async def selected(
- session: web.Committer, resolve_form: shared.resolve.ResolveForm, project_name: str, version_name: str
+ session: web.Committer,
+ _resolve: Literal["resolve"],
+ project_name: safe.ProjectName,
+ version_name: safe.VersionName,
+ resolve_form: shared.resolve.ResolveForm,
) -> web.WerkzeugResponse | str:
- await session.check_access(project_name)
-
+ """
+ URL: /resolve/<project_name>/<version_name>
+ """
match resolve_form:
case shared.resolve.SubmitForm() as submit_form:
- return await _submit(session, submit_form, project_name, version_name)
+ return await _submit(session, submit_form, str(project_name), str(version_name))
case shared.resolve.TabulateForm():
- return await _tabulate(session, project_name, version_name)
+ return await _tabulate(session, str(project_name), str(version_name))
async def _submit(
diff --git a/atr/post/revisions.py b/atr/post/revisions.py
index 5906200c..1c994030 100644
--- a/atr/post/revisions.py
+++ b/atr/post/revisions.py
@@ -14,30 +14,36 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
+from typing import Literal
import asfquart.base as base
import atr.blueprints.post as post
import atr.db as db
import atr.get as get
+import atr.models.safe as safe
import atr.models.sql as sql
import atr.shared as shared
import atr.storage as storage
import atr.web as web
-@post.committer("/revisions/<project_name>/<version_name>")
-@post.form(shared.revisions.RevisionForm)
+@post.typed
async def selected_post(
- session: web.Committer, revision_form: shared.revisions.RevisionForm, project_name: str, version_name: str
+ session: web.Committer,
+ _revisions: Literal["revisions"],
+ project_name: safe.ProjectName,
+ version_name: safe.VersionName,
+ revision_form: shared.revisions.RevisionForm,
) -> web.WerkzeugResponse:
- await session.check_access(project_name)
-
+ """
+ URL: /revisions/<project_name>/<version_name>
+ """
match revision_form:
case shared.revisions.SetRevisionForm():
- return await _set_revision(session, revision_form, project_name, version_name)
+ return await _set_revision(session, revision_form, str(project_name), str(version_name))
case shared.revisions.SetTagForm():
- return await _set_tag(session, revision_form, project_name, version_name)
+ return await _set_tag(session, revision_form, str(project_name), str(version_name))
async def _set_revision(
diff --git a/atr/post/sbom.py b/atr/post/sbom.py
index 564f0537..4f614b0e 100644
--- a/atr/post/sbom.py
+++ b/atr/post/sbom.py
@@ -17,7 +17,7 @@
from __future__ import annotations
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Literal
import asfquart.base as base
import quart
@@ -27,6 +27,8 @@ import atr.db as db
import atr.form as form
import atr.get as get
import atr.log as log
+import atr.models.safe as safe
+import atr.models.unsafe as unsafe
import atr.shared as shared
import atr.storage as storage
import atr.util as util
@@ -36,12 +38,18 @@ if TYPE_CHECKING:
import pathlib
-@post.committer("/sbom/report/<project>/<version>/<path:file_path>")
-@post.form(shared.sbom.SBOMForm)
+@post.typed
async def report(
- session: web.Committer, sbom_form: shared.sbom.SBOMForm, project: str, version: str, file_path: str
+ session: web.Committer,
+ _sbom_report: Literal["sbom/report"],
+ project_name: safe.ProjectName,
+ version_name: safe.VersionName,
+ file_path: unsafe.Path,
+ sbom_form: shared.sbom.SBOMForm,
) -> web.WerkzeugResponse:
- await session.check_access(project)
+ """
+ URL: /sbom/report/<project_name>/<version_name>/<path:file_path>
+ """
validated_path = form.to_relpath(file_path)
if validated_path is None:
@@ -49,10 +57,10 @@ async def report(
match sbom_form:
case shared.sbom.AugmentSBOMForm():
- return await _augment(session, project, version, validated_path)
+ return await _augment(session, str(project_name), str(version_name), validated_path)
case shared.sbom.ScanSBOMForm():
- return await _scan(session, project, version, validated_path)
+ return await _scan(session, str(project_name), str(version_name), validated_path)
async def _augment(
diff --git a/atr/post/start.py b/atr/post/start.py
index ea3e52c0..fea9a2f2 100644
--- a/atr/post/start.py
+++ b/atr/post/start.py
@@ -14,38 +14,44 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
+from typing import Literal
import asfquart.base as base
import quart
import atr.blueprints.post as post
import atr.get as get
+import atr.models.safe as safe
import atr.shared as shared
import atr.storage as storage
import atr.web as web
-@post.committer("/start/<project_name>")
-@post.form(shared.start.StartReleaseForm)
+@post.typed
async def selected(
- session: web.Committer, start_release_form: shared.start.StartReleaseForm, project_name: str
+ session: web.Committer,
+ _start: Literal["start"],
+ project_name: safe.ProjectName,
+ start_release_form: shared.start.StartReleaseForm,
) -> web.WerkzeugResponse:
- await session.check_access(project_name)
+ """
+ URL: /start/<project_name>
+ """
try:
async with storage.write(session) as write:
- wacp = await write.as_project_committee_participant(project_name)
+ wacp = await write.as_project_committee_participant(str(project_name))
new_release, _project = await wacp.release.start(
- project_name,
+ str(project_name),
start_release_form.version_name,
)
return await session.redirect(
get.compose.selected,
- project_name=project_name,
+ project_name=str(project_name),
version_name=new_release.version,
success="Release candidate draft created successfully",
)
except (web.FlashError, base.ASFQuartException) as e:
await quart.flash(str(e), "error")
- return await session.redirect(get.start.selected, project_name=project_name)
+ return await session.redirect(get.start.selected, project_name=str(project_name))
diff --git a/atr/post/test.py b/atr/post/test.py
index b2945df6..4fd4ee97 100644
--- a/atr/post/test.py
+++ b/atr/post/test.py
@@ -14,29 +14,39 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
+from typing import Literal
import quart
import atr.blueprints.post as post
+import atr.form as form
import atr.get as get
import atr.log as log
import atr.shared as shared
import atr.web as web
-@post.public("/test/empty")
-@post.empty()
-async def test_empty(session: web.Public) -> web.WerkzeugResponse:
+@post.typed
+async def test_empty(
+ session: web.Public, _test_empty: Literal["test/empty"], _form: form.Empty
+) -> web.WerkzeugResponse:
+ """
+ URL: /test/empty
+ """
msg = "Empty form submitted successfully"
log.info(msg)
await quart.flash(msg, "success")
return await web.redirect(get.test.test_empty)
-@post.public("/test/multiple")
-@post.form(shared.test.MultipleForm)
-async def test_multiple(session: web.Public, form: shared.test.MultipleForm) -> web.WerkzeugResponse:
- match form:
+@post.typed
+async def test_multiple(
+ session: web.Public, _test_multiple: Literal["test/multiple"], multiple_form: shared.test.MultipleForm
+) -> web.WerkzeugResponse:
+ """
+ URL: /test/multiple
+ """
+ match multiple_form:
case shared.test.AppleForm() as apple:
msg = f"Apple order received: variety={apple.variety}, quantity={apple.quantity}, organic={apple.organic}"
log.info(msg)
@@ -50,24 +60,28 @@ async def test_multiple(session: web.Public, form: shared.test.MultipleForm) ->
return await web.redirect(get.test.test_multiple)
-@post.public("/test/single")
-@post.form(shared.test.SingleForm)
-async def test_single(session: web.Public, form: shared.test.SingleForm) -> web.WerkzeugResponse:
- file_names = [f.filename for f in form.files] if form.files else []
- compatibility_names = [f.value for f in form.compatibility] if form.compatibility else []
- if (form.message == "Forbidden message!") and (session is not None):
+@post.typed
+async def test_single(
+ session: web.Public, _test_single: Literal["test/single"], single_form: shared.test.SingleForm
+) -> web.WerkzeugResponse:
+ """
+ URL: /test/single
+ """
+ file_names = [f.filename for f in single_form.files] if single_form.files else []
+ compatibility_names = [f.value for f in single_form.compatibility] if single_form.compatibility else []
+ if (single_form.message == "Forbidden message!") and (session is not None):
return await session.form_error(
"message",
"You are not permitted to submit the forbidden message",
)
msg = (
f"Single form received:"
- f" name={form.name},"
- f" email={form.email},"
- f" message={form.message},"
+ f" name={single_form.name},"
+ f" email={single_form.email},"
+ f" message={single_form.message},"
f" files={file_names},"
f" compatibility={compatibility_names},"
- f" vote={form.vote}"
+ f" vote={single_form.vote}"
)
log.info(msg)
await quart.flash(msg, "success")
diff --git a/atr/post/tokens.py b/atr/post/tokens.py
index 6d55f4ae..157b8023 100644
--- a/atr/post/tokens.py
+++ b/atr/post/tokens.py
@@ -18,12 +18,13 @@
import datetime
import hashlib
import secrets
-from typing import Final
+from typing import Final, Literal
import quart
import quart_rate_limiter as rate_limiter
import atr.blueprints.post as post
+import atr.form as form
import atr.get as get
import atr.htm as htm
import atr.jwtoken as jwtoken
@@ -34,18 +35,24 @@ import atr.web as web
_EXPIRY_DAYS: Final[int] = 180
-@post.committer("/tokens/jwt")
+@post.typed
@rate_limiter.rate_limit(10, datetime.timedelta(hours=1))
-@post.empty()
-async def jwt_post(session: web.Committer) -> web.QuartResponse:
+async def jwt_post(session: web.Committer, _tokens_jwt: Literal["tokens/jwt"], _form: form.Empty) -> web.QuartResponse:
+ """
+ URL: /tokens/jwt
+ """
jwt_token = jwtoken.issue(session.uid)
return web.TextResponse(jwt_token)
-@post.committer("/tokens")
+@post.typed
@rate_limiter.rate_limit(10, datetime.timedelta(hours=1))
-@post.form(shared.tokens.TokenForm)
-async def tokens(session: web.Committer, token_form: shared.tokens.TokenForm) -> web.WerkzeugResponse:
+async def tokens(
+ session: web.Committer, _tokens: Literal["tokens"], token_form: shared.tokens.TokenForm
+) -> web.WerkzeugResponse:
+ """
+ URL: /tokens
+ """
match token_form:
case shared.tokens.AddTokenForm() as add_form:
return await _add_token(session, add_form)
diff --git a/atr/post/upload.py b/atr/post/upload.py
index 7b4f9f50..60dcd84a 100644
--- a/atr/post/upload.py
+++ b/atr/post/upload.py
@@ -18,7 +18,7 @@
import asyncio
import json
import pathlib
-from typing import Final
+from typing import Final, Literal
import aiofiles
import aiofiles.os
@@ -31,6 +31,7 @@ import atr.db as db
import atr.form as form
import atr.get as get
import atr.log as log
+import atr.models.safe as safe
import atr.models.sql as sql
import atr.paths as paths
import atr.shared as shared
@@ -42,11 +43,17 @@ import atr.web as web
_SVN_BASE_URL: Final[str] = "https://dist.apache.org/repos/dist"
-@post.committer("/upload/finalise/<project_name>/<version_name>/<upload_session>")
+@post.typed
async def finalise(
- session: web.Committer, project_name: str, version_name: str, upload_session: str
+ session: web.Committer,
+ _upload_finalise: Literal["upload/finalise"],
+ project_name: safe.ProjectName,
+ version_name: safe.VersionName,
+ upload_session: str,
) -> web.WerkzeugResponse:
- await session.check_access(project_name)
+ """
+ URL: /upload/finalise/<project_name>/<version_name>/<upload_session>
+ """
try:
staging_dir = paths.get_upload_staging_dir(upload_session)
@@ -67,7 +74,7 @@ async def finalise(
try:
async with storage.write(session) as write:
- wacp = await write.as_project_committee_participant(project_name)
+ wacp = await write.as_project_committee_participant(str(project_name))
number_of_files = len(staged_files)
description = f"Upload of {util.plural(number_of_files, 'file')} through web interface"
@@ -78,7 +85,7 @@ async def finalise(
await aioshutil.move(str(src), str(dst))
await wacp.revision.create_revision(
- project_name, version_name, session.uid, description=description, modify=modify
+ str(project_name), str(version_name), session.uid, description=description, modify=modify
)
await aioshutil.rmtree(staging_dir)
@@ -86,42 +93,53 @@ async def finalise(
return await session.redirect(
get.compose.selected,
success=f"{util.plural(number_of_files, 'file')} added successfully",
- project_name=project_name,
- version_name=version_name,
+ project_name=str(project_name),
+ version_name=str(version_name),
)
except types.FailedError as e:
await aioshutil.rmtree(staging_dir)
await quart.flash(str(e), "error")
return await session.redirect(
get.upload.selected,
- project_name=project_name,
- version_name=version_name,
+ project_name=str(project_name),
+ version_name=str(version_name),
)
except Exception as e:
log.exception("Error finalising upload:")
return _json_error(f"Error finalising upload: {e!s}", 500)
-@post.committer("/upload/<project_name>/<version_name>")
-@post.form(shared.upload.UploadForm)
+@post.typed
async def selected(
- session: web.Committer, upload_form: shared.upload.UploadForm, project_name: str, version_name: str
+ session: web.Committer,
+ _upload: Literal["upload"],
+ project_name: safe.ProjectName,
+ version_name: safe.VersionName,
+ upload_form: shared.upload.UploadForm,
) -> web.WerkzeugResponse:
- await session.check_access(project_name)
+ """
+ URL: /upload/<project_name>/<version_name>
+ """
match upload_form:
case shared.upload.AddFilesForm() as add_form:
- return await _add_files(session, add_form, project_name, version_name)
+ return await _add_files(session, add_form, str(project_name), str(version_name))
case shared.upload.SvnImportForm() as svn_form:
- return await _svn_import(session, svn_form, project_name, version_name)
+ return await _svn_import(session, svn_form, str(project_name), str(version_name))
-@post.committer("/upload/stage/<project_name>/<version_name>/<upload_session>")
+@post.typed
async def stage(
- session: web.Committer, project_name: str, version_name: str, upload_session: str
+ session: web.Committer,
+ _upload_stage: Literal["upload/stage"],
+ project_name: safe.ProjectName,
+ version_name: safe.VersionName,
+ upload_session: str,
) -> web.WerkzeugResponse:
- await session.check_access(project_name)
+ """
+ URL: /upload/stage/<project_name>/<version_name>/<upload_session>Z
+ """
try:
staging_dir = paths.get_upload_staging_dir(upload_session)
diff --git a/atr/post/user.py b/atr/post/user.py
index a414c10d..06ec280a 100644
--- a/atr/post/user.py
+++ b/atr/post/user.py
@@ -14,6 +14,7 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
+from typing import Literal
import quart
@@ -24,9 +25,13 @@ import atr.util as util
import atr.web as web
-@post.committer("/user/cache")
-@post.form(shared.user.UserCacheForm)
-async def session_post(session: web.Committer, user_cache_form: shared.user.UserCacheForm) -> web.WerkzeugResponse:
+@post.typed
+async def session_post(
+ session: web.Committer, _user_cache: Literal["user/cache"], user_cache_form: shared.user.UserCacheForm
+) -> web.WerkzeugResponse:
+ """
+ URL: /user/cache
+ """
match user_cache_form:
case shared.user.CacheUserForm():
await _cache_session(session)
diff --git a/atr/post/vote.py b/atr/post/vote.py
index bed25c79..894fbc69 100644
--- a/atr/post/vote.py
+++ b/atr/post/vote.py
@@ -14,11 +14,13 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
+from typing import Literal
import quart
import atr.blueprints.post as post
import atr.get as get
+import atr.models.safe as safe
import atr.models.sql as sql
import atr.shared as shared
import atr.storage as storage
@@ -26,14 +28,19 @@ import atr.user as user
import atr.web as web
-@post.committer("/vote/<project_name>/<version_name>")
-@post.form(shared.vote.CastVoteForm)
+@post.typed
async def selected_post(
- session: web.Committer, cast_vote_form: shared.vote.CastVoteForm, project_name: str, version_name: str
+ session: web.Committer,
+ _vote: Literal["vote"],
+ project_name: safe.ProjectName,
+ version_name: safe.VersionName,
+ cast_vote_form: shared.vote.CastVoteForm,
) -> web.WerkzeugResponse:
- await session.check_access(project_name)
+ """
+ URL: /vote/<project_name>/<version_name>
+ """
- release = await session.release(project_name, version_name, phase=sql.ReleasePhase.RELEASE_CANDIDATE)
+ release = await session.release(str(project_name), str(version_name), phase=sql.ReleasePhase.RELEASE_CANDIDATE)
if release.committee is None:
raise ValueError("Release has no committee")
@@ -51,8 +58,8 @@ async def selected_post(
if error_message:
await quart.flash(error_message, "error")
- return await session.redirect(get.vote.selected, project_name=project_name, version_name=version_name)
+ return await session.redirect(get.vote.selected, project_name=str(project_name), version_name=str(version_name))
success_message = f"Sending your vote to {email_recipient}."
await quart.flash(success_message, "success")
- return await session.redirect(get.vote.selected, project_name=project_name, version_name=version_name)
+ return await session.redirect(get.vote.selected, project_name=str(project_name), version_name=str(version_name))
diff --git a/atr/post/voting.py b/atr/post/voting.py
index a5430793..26a701b4 100644
--- a/atr/post/voting.py
+++ b/atr/post/voting.py
@@ -17,6 +17,8 @@
from __future__ import annotations
+from typing import Literal
+
import atr.blueprints.post as post
import atr.construct as construct
import atr.db as db
@@ -24,6 +26,7 @@ import atr.db.interaction as interaction
import atr.form as form
import atr.get as get
import atr.log as log
+import atr.models.safe as safe
import atr.shared as shared
import atr.storage as storage
import atr.util as util
@@ -34,25 +37,27 @@ class BodyPreviewForm(form.Form):
vote_duration: form.Int = form.label("Vote duration")
-@post.committer("/voting/body/preview/<project_name>/<version_name>/<revision_number>")
-@post.form(BodyPreviewForm)
+@post.typed
async def body_preview(
session: web.Committer,
- preview_form: BodyPreviewForm,
- project_name: str,
- version_name: str,
+ _voting_body_preview: Literal["voting/body/preview"],
+ project_name: safe.ProjectName,
+ version_name: safe.VersionName,
revision_number: str,
+ preview_form: BodyPreviewForm,
) -> web.QuartResponse:
- await session.check_access(project_name)
+ """
+ URL: /voting/body/preview/<project_name>/<version_name>/<revision_number>
+ """
- default_subject_template = await construct.start_vote_subject_default(project_name)
- default_body_template = await construct.start_vote_default(project_name)
+ default_subject_template = await construct.start_vote_subject_default(str(project_name))
+ default_body_template = await construct.start_vote_default(str(project_name))
options = construct.StartVoteOptions(
asfuid=session.uid,
fullname=session.fullname,
- project_name=project_name,
- version_name=version_name,
+ project_name=str(project_name),
+ version_name=str(version_name),
revision_number=revision_number,
vote_duration=preview_form.vote_duration,
)
@@ -61,27 +66,29 @@ async def body_preview(
return web.TextResponse(body)
-@post.committer("/voting/<project_name>/<version_name>/<revision>")
-@post.form(shared.voting.StartVotingForm)
+@post.typed
async def selected_revision(
session: web.Committer,
- start_voting_form: shared.voting.StartVotingForm,
- project_name: str,
- version_name: str,
+ _voting: Literal["voting"],
+ project_name: safe.ProjectName,
+ version_name: safe.VersionName,
revision: str,
+ start_voting_form: shared.voting.StartVotingForm,
) -> web.WerkzeugResponse | str:
- await session.check_access(project_name)
+ """
+ URL: /voting/<project_name>/<version_name>/<revision>
+ """
async with db.session() as data:
match await interaction.release_ready_for_vote(
- session, project_name, version_name, revision, data, manual_vote=False
+ session, str(project_name), str(version_name), revision, data, manual_vote=False
):
case str() as error:
return await session.redirect(
get.compose.selected,
error=error,
- project_name=project_name,
- version_name=version_name,
+ project_name=str(project_name),
+ version_name=str(version_name),
revision=revision,
)
case (release, committee):
@@ -94,7 +101,7 @@ async def selected_revision(
f"Invalid mailing list selection: {start_voting_form.mailing_list}",
)
- subject_template = await construct.start_vote_subject_default(project_name)
+ subject_template = await construct.start_vote_subject_default(str(project_name))
current_hash = construct.template_hash(subject_template)
if current_hash != start_voting_form.subject_template_hash:
return await session.form_error(
@@ -106,8 +113,8 @@ async def selected_revision(
options = construct.StartVoteOptions(
asfuid=session.uid,
fullname=session.fullname,
- project_name=project_name,
- version_name=version_name,
+ project_name=str(project_name),
+ version_name=str(version_name),
revision_number=revision,
vote_duration=start_voting_form.vote_duration,
)
@@ -116,8 +123,8 @@ async def selected_revision(
async with storage.write_as_committee_participant(committee.name) as wacp:
_task = await wacp.vote.start(
start_voting_form.mailing_list,
- project_name,
- version_name,
+ str(project_name),
+ str(version_name),
revision,
start_voting_form.vote_duration,
subject,
@@ -133,6 +140,6 @@ async def selected_revision(
return await session.redirect(
get.vote.selected,
success=f"The vote announcement email will soon be sent to {start_voting_form.mailing_list}.",
- project_name=project_name,
- version_name=version_name,
+ project_name=str(project_name),
+ version_name=str(version_name),
)
diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml
index 8a2d4000..4f1ecb88 100644
--- a/tests/docker-compose.yml
+++ b/tests/docker-compose.yml
@@ -10,6 +10,8 @@ services:
- SSH_HOST=0.0.0.0
networks:
- test-network
+ ports:
+ - 8080:8080
healthcheck:
test: ["CMD", "curl", "-k", "-f", "https://127.0.0.1:8080/"]
interval: 2s
diff --git a/tests/run-playwright.sh b/tests/run-playwright.sh
index eedd3ba8..b63749aa 100755
--- a/tests/run-playwright.sh
+++ b/tests/run-playwright.sh
@@ -4,7 +4,7 @@ set -eu
cd "$(dirname "$0")"
echo "Building and running ATR integration tests..."
-docker compose up atr playwright --build --abort-on-container-exit --exit-code-from playwright
+docker compose up atr playwright --build #--abort-on-container-exit --exit-code-from playwright
# Clean up
-docker compose down -v
+#docker compose down -v
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org