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 6b0dc6dfc545861cebb857137c8d4ab64a41586e Author: Alastair McFarlane <[email protected]> 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 +++++++------ 22 files changed, 698 insertions(+), 485 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..8224d281 100644 --- a/atr/blueprints/post.py +++ b/atr/blueprints/post.py @@ -19,17 +19,17 @@ 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 -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 +37,82 @@ _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(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) +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] + - 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 - - return decorator - + 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 -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) + log.performance(f"POST {path} {func.__name__} = 0 0 {total_ms}") - endpoint = func.__module__.replace(".", "_") + "_" + func.__name__ - wrapper.__annotations__["endpoint"] = _BLUEPRINT_NAME + "." + endpoint - wrapper.__doc__ = func.__doc__ - wrapper.__module__ = func.__module__ - wrapper.__name__ = func.__name__ + return response - _BLUEPRINT.add_url_rule(path, endpoint=endpoint, view_func=wrapper, methods=["POST"]) - _register(func) + endpoint = func.__module__.replace(".", "_") + "_" + func.__name__ + wrapper.__name__ = func.__name__ + wrapper.__doc__ = func.__doc__ + wrapper.__annotations__["endpoint"] = _BLUEPRINT_NAME + "." + endpoint - return wrapper + 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) - return decorator - - -def register(app: base.QuartApp) -> tuple[ModuleType, list[str]]: - import atr.post as post - - app.register_blueprint(_BLUEPRINT) - return post, _routes + return decorated 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 [email protected]("/announce/<project_name>/<version_name>") [email protected](shared.announce.AnnounceForm) [email protected] 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( ) [email protected]("/distribution/automate/<project>/<version>") [email protected](shared.distribution.DistributeForm) [email protected] 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 + ) [email protected]("/distribution/delete/<project>/<version>") [email protected](shared.distribution.DeleteForm) [email protected] 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( ) [email protected]("/distribution/record/<project>/<version>") [email protected](shared.distribution.DistributeForm) [email protected] 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) [email protected]("/distribution/stage/automate/<project>/<version>") [email protected](shared.distribution.DistributeForm) [email protected] 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 + ) [email protected]("/distribution/stage/record/<project>/<version>") [email protected](shared.distribution.DistributeForm) [email protected] 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 [email protected]("/draft/reset/<project_name>/<version_name>") [email protected]() -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) [email protected] +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", ) [email protected]("/compose/<project_name>/<version_name>") [email protected]() -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) - [email protected] +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") [email protected]("/draft/delete-file/<project_name>/<version_name>") [email protected](shared.draft.DeleteFileForm) [email protected] 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) ) [email protected]("/draft/hashgen/<project_name>/<version_name>/<path:file_path>") [email protected]() -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) - [email protected] +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), ) [email protected]("/draft/recheck/<project_name>/<version_name>") [email protected]() -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) [email protected] +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", ) [email protected]("/draft/sbomgen/<project_name>/<version_name>/<path:file_path>") [email protected]() -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) - [email protected] +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 [email protected]("/finish/<project_name>/<version_name>") [email protected](shared.finish.FinishForm) [email protected] 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 [email protected]("/ignores/<project_name>") [email protected](shared.ignores.IgnoreForm) [email protected] 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" [email protected]("/keys/add") [email protected](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.""" [email protected] +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) [email protected]("/keys/details/<fingerprint>") [email protected](shared.keys.UpdateKeyCommitteesForm) [email protected] 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) [email protected]("/keys/import/<project_name>/<version_name>") [email protected]() [email protected] 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), ) [email protected]("/keys") [email protected](shared.keys.KeysForm) -async def keys(session: web.Committer, keys_form: shared.keys.KeysForm) -> web.WerkzeugResponse: - """Handle forms on the keys management page.""" [email protected] +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) [email protected]("/keys/ssh/add") [email protected](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.""" [email protected] +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) [email protected]("/keys/upload") [email protected](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.""" [email protected] +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 [email protected]("/manual/resolve/<project_name>/<version_name>") [email protected](shared.manual.ResolveVoteForm) [email protected] 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, ) [email protected]("/manual/start/<project_name>/<version_name>/<revision>") [email protected]() [email protected] 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 [email protected]("/project/add/<committee_name>") [email protected](shared.projects.AddProjectForm) [email protected] 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( ) [email protected]("/project/delete") [email protected](shared.projects.DeleteSelectedProject) [email protected] 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.") [email protected]("/projects/<name>") [email protected](shared.projects.ProjectViewForm) [email protected] 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 [email protected]("/resolve/<project_name>/<version_name>") [email protected](shared.resolve.ResolveForm) [email protected] 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 [email protected]("/revisions/<project_name>/<version_name>") [email protected](shared.revisions.RevisionForm) [email protected] 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 [email protected]("/sbom/report/<project>/<version>/<path:file_path>") [email protected](shared.sbom.SBOMForm) [email protected] 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 [email protected]("/start/<project_name>") [email protected](shared.start.StartReleaseForm) [email protected] 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 [email protected]("/test/empty") [email protected]() -async def test_empty(session: web.Public) -> web.WerkzeugResponse: [email protected] +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) [email protected]("/test/multiple") [email protected](shared.test.MultipleForm) -async def test_multiple(session: web.Public, form: shared.test.MultipleForm) -> web.WerkzeugResponse: - match form: [email protected] +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) [email protected]("/test/single") [email protected](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): [email protected] +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 [email protected]("/tokens/jwt") [email protected] @rate_limiter.rate_limit(10, datetime.timedelta(hours=1)) [email protected]() -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) [email protected]("/tokens") [email protected] @rate_limiter.rate_limit(10, datetime.timedelta(hours=1)) [email protected](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" [email protected]("/upload/finalise/<project_name>/<version_name>/<upload_session>") [email protected] 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) [email protected]("/upload/<project_name>/<version_name>") [email protected](shared.upload.UploadForm) [email protected] 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)) [email protected]("/upload/stage/<project_name>/<version_name>/<upload_session>") [email protected] 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 [email protected]("/user/cache") [email protected](shared.user.UserCacheForm) -async def session_post(session: web.Committer, user_cache_form: shared.user.UserCacheForm) -> web.WerkzeugResponse: [email protected] +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 [email protected]("/vote/<project_name>/<version_name>") [email protected](shared.vote.CastVoteForm) [email protected] 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") [email protected]("/voting/body/preview/<project_name>/<version_name>/<revision_number>") [email protected](BodyPreviewForm) [email protected] 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) [email protected]("/voting/<project_name>/<version_name>/<revision>") [email protected](shared.voting.StartVotingForm) [email protected] 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), ) --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
