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 ac1ff3c024a767131ba0316df2747af1be6d3202 Author: Alastair McFarlane <[email protected]> AuthorDate: Wed Feb 25 11:56:34 2026 +0000 Add taint tracking types to get endpoints --- atr/blueprints/common.py | 148 +++++++++++++++++++++++++ atr/blueprints/get.py | 93 ++++++++-------- atr/blueprints/post.py | 36 +++--- atr/cache.py | 62 +++++++++++ atr/docs/user-interface.md | 18 +-- atr/get/announce.py | 41 ++++--- atr/get/checklist.py | 25 +++-- atr/get/checks.py | 50 ++++++--- atr/get/committees.py | 17 ++- atr/get/compose.py | 23 ++-- atr/get/distribution.py | 100 ++++++++++++----- atr/get/docs.py | 12 +- atr/get/download.py | 112 ++++++++++++++----- atr/get/draft.py | 38 +++++-- atr/get/file.py | 49 +++++--- atr/get/finish.py | 28 +++-- atr/get/ignores.py | 20 +++- atr/get/keys.py | 55 ++++++--- atr/get/manual.py | 42 ++++--- atr/get/projects.py | 43 +++++-- atr/get/published.py | 24 ++-- atr/get/ref.py | 10 +- atr/get/release.py | 39 ++++--- atr/get/report.py | 34 ++++-- atr/get/result.py | 21 ++-- atr/get/revisions.py | 26 +++-- atr/get/root.py | 45 +++++--- atr/get/sbom.py | 31 ++++-- atr/get/start.py | 14 ++- atr/get/test.py | 71 ++++++++---- atr/get/tokens.py | 9 +- atr/get/upload.py | 28 +++-- atr/get/user.py | 9 +- atr/get/vote.py | 19 +++- atr/get/voting.py | 32 ++++-- atr/models/safe.py | 62 +++++++++++ atr/models/unsafe.py | 32 ++++++ atr/post/projects.py | 38 ++++--- atr/post/sbom.py | 16 +-- atr/post/test.py | 6 +- atr/server.py | 4 + atr/shared/web.py | 14 +-- atr/templates/check-selected-path-table.html | 2 +- atr/templates/check-selected-release-info.html | 2 +- atr/templates/check-selected.html | 4 +- atr/templates/committee-directory.html | 4 +- atr/templates/committee-view.html | 2 +- atr/templates/includes/topnav.html | 2 +- atr/templates/index-committer.html | 2 +- atr/templates/projects.html | 4 +- atr/web.py | 8 +- 51 files changed, 1180 insertions(+), 446 deletions(-) diff --git a/atr/blueprints/common.py b/atr/blueprints/common.py new file mode 100644 index 00000000..0428b83d --- /dev/null +++ b/atr/blueprints/common.py @@ -0,0 +1,148 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import inspect +from collections.abc import Callable +from typing import Any, Literal, 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.ldap as ldap +import atr.models.safe as safe +import atr.models.sql as sql +import atr.models.unsafe as unsafe +import atr.web as web + +QUART_CONVERTERS: dict[Any, str] = { + int: "int", + float: "float", + unsafe.Path: "path", +} + +VALIDATED_TYPES: set[Any] = {safe.ProjectName, safe.VersionName} + + +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) + + +async def authenticate_public() -> web.Public: + web_session = await asfquart.session.read() + if web_session is None: + return None + else: + try: + return await authenticate() + except base.ASFQuartException: + return None + + +def build_path(func: Callable[..., Any]) -> tuple[str, list[tuple[str, type]], dict[str, str], 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 + """ + hints = get_type_hints(func, include_extras=True) + params = list(inspect.signature(func).parameters.keys()) + public = False + segments: list[str] = [] + validated_params: list[tuple[str, type]] = [] + literal_params: dict[str, str] = {} + + 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 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}>") + 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}") + + path = "/" + "/".join(segments) + return path, validated_params, literal_params, public + + +def register_route(func: Callable[..., Any], prefix: str, routes: list[str]) -> None: + module_name = func.__module__.split(".")[-1] + routes.append(f"{prefix}.{module_name}.{func.__name__}") + + +async def run_validators(kwargs: dict[str, Any], validated_params: list[tuple[str, type]]) -> None: + """Validate URL parameters in order, using the cache/DB validators.""" + for param_name, param_type in validated_params: + raw = kwargs[param_name] + if param_type is safe.ProjectName: + kwargs[param_name] = await validate_project(raw) + elif param_type is safe.VersionName: + project_name = kwargs.get("project_name", "") + kwargs[param_name] = await validate_version(project_name, raw) + + +async def validate_project(raw: str) -> safe.ProjectName: + if cache.project_version_has_project(raw): + return safe.ProjectName(raw) + async with db.session() as data: + project = await data.project(name=raw, status=sql.ProjectStatus.ACTIVE, _committee=False).get() + if project is None: + raise base.ASFQuartException(f"Project {raw!r} not found", errorcode=404) + return safe.ProjectName(project.name) + + +async def validate_version(project_name: safe.ProjectName, raw: str) -> safe.VersionName: + if cache.project_version_has_version(project_name, raw): + return safe.VersionName(raw) + async with db.session() as data: + release = await data.release( + project_name=str(project_name), + version=raw, + _project=False, + _committee=False, + ).get() + 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) diff --git a/atr/blueprints/get.py b/atr/blueprints/get.py index f715defd..c978add0 100644 --- a/atr/blueprints/get.py +++ b/atr/blueprints/get.py @@ -18,82 +18,77 @@ import time from collections.abc import Awaitable, Callable from types import ModuleType -from typing import Any +from typing import Any, Concatenate, ParamSpec, TypeVar, overload import asfquart.auth as auth import asfquart.base as base -import asfquart.session import quart -import atr.ldap as ldap +import atr.blueprints.common as common import atr.log as log +import atr.models.safe as safe import atr.web as web _BLUEPRINT_NAME = "get_blueprint" _BLUEPRINT = quart.Blueprint(_BLUEPRINT_NAME, __name__) _routes: list[str] = [] +_P = ParamSpec("_P") +_R = TypeVar("_R") -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: - 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) - enhanced_session = web.Committer(web_session) - 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 +@overload +def typed[**P, R](func: Callable[Concatenate[web.Committer, P], Awaitable[R]]) -> web.RouteFunction[R]: ... - # TODO: Make this configurable in config.py - log.performance( - f"GET {path} {func.__name__} = 0 0 {total_ms}", - ) - return response +@overload +def typed[**P, R](func: Callable[Concatenate[web.Public, P], Awaitable[R]]) -> web.RouteFunction[R]: ... # pyright: ignore[reportOverlappingOverload] - 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=["GET"]) +def typed(func: Callable[..., Any]) -> web.RouteFunction[Any]: + """Decorator that derives the URL path from the function's type annotations. - module_name = func.__module__.split(".")[-1] - _routes.append(f"get.{module_name}.{func.__name__}") + - 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 + - check_access is called automatically for committer routes with project_name + """ + 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) - return decorated + 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) - return decorator + 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])) + 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"GET {path} {func.__name__} = 0 0 {total_ms}", + ) - endpoint = func.__module__.replace(".", "_") + "_" + func.__name__ - wrapper.__name__ = func.__name__ - wrapper.__doc__ = func.__doc__ - wrapper.__annotations__["endpoint"] = _BLUEPRINT_NAME + "." + endpoint + return response - _BLUEPRINT.add_url_rule(path, endpoint=endpoint, view_func=wrapper, methods=["GET"]) + endpoint = func.__module__.replace(".", "_") + "_" + func.__name__ + wrapper.__name__ = func.__name__ + wrapper.__doc__ = func.__doc__ + wrapper.__annotations__["endpoint"] = _BLUEPRINT_NAME + "." + endpoint - module_name = func.__module__.split(".")[-1] - _routes.append(f"get.{module_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=["GET"]) + common.register_route(func, "get", _routes) - return wrapper - - return decorator + return decorated def register(app: base.QuartApp) -> tuple[ModuleType, list[str]]: diff --git a/atr/blueprints/post.py b/atr/blueprints/post.py index 8c9133a0..ac20af18 100644 --- a/atr/blueprints/post.py +++ b/atr/blueprints/post.py @@ -37,17 +37,25 @@ _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: - 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) - - enhanced_session = web.Committer(web_session) + 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() @@ -68,9 +76,7 @@ def committer(path: str) -> Callable[[web.CommitterRouteFunction[Any]], web.Rout decorated = auth.require(auth.Requirements.committer)(wrapper) _BLUEPRINT.add_url_rule(path, endpoint=endpoint, view_func=decorated, methods=["POST"]) - - module_name = func.__module__.split(".")[-1] - _routes.append(f"post.{module_name}.{func.__name__}") + _register(func) return decorated @@ -82,7 +88,7 @@ def empty() -> Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable # # @post.form(form.Empty) # async def test_empty( - # session: web.Committer | None, + # session: web.Public, # form: form.Empty, # ) -> web.WerkzeugResponse: # pass @@ -91,7 +97,7 @@ def empty() -> Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable # # @post.empty() # async def test_empty( - # session: web.Committer | None, + # session: web.Public, # ) -> web.WerkzeugResponse: # pass def decorator(func: Callable[..., Awaitable[Any]]) -> Callable[..., Awaitable[Any]]: @@ -187,9 +193,7 @@ def public(path: str) -> Callable[[Callable[..., Awaitable[Any]]], web.RouteFunc wrapper.__name__ = func.__name__ _BLUEPRINT.add_url_rule(path, endpoint=endpoint, view_func=wrapper, methods=["POST"]) - - module_name = func.__module__.split(".")[-1] - _routes.append(f"post.{module_name}.{func.__name__}") + _register(func) return wrapper diff --git a/atr/cache.py b/atr/cache.py index f652a02d..e578f0aa 100644 --- a/atr/cache.py +++ b/atr/cache.py @@ -27,11 +27,14 @@ import pydantic import atr.config as config import atr.ldap as ldap import atr.log as log +import atr.models.safe as safe import atr.models.schema as schema # Fifth prime after 3600 ADMINS_POLL_INTERVAL_SECONDS: Final[int] = 3631 +PROJECT_VERSION_POLL_INTERVAL_SECONDS: Final[int] = 307 + class AdminsCache(schema.Strict): refreshed: datetime.datetime = schema.description("When the cache was last refreshed") @@ -92,6 +95,39 @@ async def admins_startup_load() -> None: log.warning(f"Failed to fetch admin users from LDAP at startup: {e}") +def project_version_get() -> dict[str, set[str]]: + if asfquart.APP is not None: + return asfquart.APP.extensions.get("project_versions", {}) + return {} + + +def project_version_has_project(project_name: str) -> bool: + return project_name in project_version_get() + + +def project_version_has_version(project_name: safe.ProjectName, version_name: str) -> bool: + projects = project_version_get() + if str(project_name) not in projects: + return False + return version_name in projects[str(project_name)] + + +async def project_version_refresh_loop() -> None: + while True: + await asyncio.sleep(PROJECT_VERSION_POLL_INTERVAL_SECONDS) + try: + await _project_version_refresh() + except Exception as e: + log.warning(f"Project/version cache refresh failed: {e}") + + +async def project_version_startup_load() -> None: + try: + await _project_version_refresh() + except Exception as e: + log.warning(f"Failed to populate project/version cache at startup: {e}") + + def _admins_path() -> pathlib.Path: return pathlib.Path(config.get().STATE_DIR) / "cache" / "admins.json" @@ -134,3 +170,29 @@ def _admins_update_app_extensions(admins: frozenset[str]) -> None: app = asfquart.APP app.extensions["admins"] = admins app.extensions["admins_refreshed"] = datetime.datetime.now(datetime.UTC) + + +async def _project_version_fetch_from_db() -> dict[str, set[str]]: + import atr.db as db + import atr.models.sql as sql + + projects: dict[str, set[str]] = {} + async with db.session() as data: + all_projects = await data.project(status=sql.ProjectStatus.ACTIVE, _committee=False).all() + for project in all_projects: + all_releases = await data.release(project_name=project.name, _project=False, _committee=False).all() + projects[project.name] = {release.version for release in all_releases} + return projects + + +async def _project_version_refresh() -> None: + projects = await _project_version_fetch_from_db() + _project_version_update_app_extensions(projects) + total_versions = sum(len(v) for v in projects.values()) + log.info(f"Project/version cache refreshed: {len(projects)} projects, {total_versions} versions") + + +def _project_version_update_app_extensions(projects: dict[str, set[str]]) -> None: + app = asfquart.APP + app.extensions["project_versions"] = projects + app.extensions["project_versions_refreshed"] = datetime.datetime.now(datetime.UTC) diff --git a/atr/docs/user-interface.md b/atr/docs/user-interface.md index e5f901a2..a27b984a 100644 --- a/atr/docs/user-interface.md +++ b/atr/docs/user-interface.md @@ -226,21 +226,23 @@ The block class also adds a `data-src` attribute to elements, which records whic A typical route that renders UI first authenticates the user, loads data from the database, builds HTML using htpy, and renders it using a template. GET and POST requests are handled by separate routes, with form validation automatically handled by the [`@post.form()`](/ref/atr/blueprints/post.py:form) decorator. Here is a simplified example from [`get/keys.py`](/ref/atr/get/keys.py:add): ```python [email protected]("/keys/add") -async def add(session: web.Committer) -> str: - """Add a new public signing key to the user's account.""" [email protected] +async def add(session: web.Committer, _keys_add: Literal["keys/add"]) -> str: + """ + URL: /keys/add + Add a new public signing key to the user's account. + """ async with storage.write() as write: participant_of_committees = await write.participant_of_committees() committee_choices = [(c.name, c.display_name or c.name) for c in participant_of_committees] page = htm.Block() - page.p[htm.a(".atr-back-link", href=util.as_url(keys))["← Back to Manage keys"]] + page.p[htm.a(".atr-back-link", href=util.as_url(keys))["← Back to Manage keys"],] page.div(".my-4")[ htm.h1(".mb-4")["Add your OpenPGP key"], htm.p["Add your public key to use for signing release artifacts."], ] - form.render_block( page, model_cls=shared.keys.AddOpenPGPKeyForm, @@ -251,15 +253,17 @@ async def add(session: web.Committer) -> str: "selected_committees": committee_choices, }, ) - ... + return await template.blank( "Add your OpenPGP key", content=page.collect(), description="Add your public signing key to your ATR account.", + javascripts=["keys-add-toggle"], ) ``` -The route is decorated with [`@get.committer()`](/ref/atr/blueprints/get.py:committer), which handles authentication and provides a `session` object that is an instance of [`web.Committer`](/ref/atr/web.py:Committer) with a range of useful properties and methods. +The route is decorated with [`@get.typed`](/ref/atr/blueprints/get.py:typed), which handles authentication and provides a `session` object that is an instance of [`web.Committer`](/ref/atr/web.py:Committer) with a range of useful properties and methods. +[`@get.typed`](/ref/atr/blueprints/get.py:typed) also validates `project_name` and `version_name` route params, and checks that the user is authorised to access the project (if a project name is required). The function builds the UI using an [`htm.Block`](/ref/atr/htm.py:Block) object, which provides a convenient API for incrementally building HTML. The form is rendered directly into the block using [`form.render_block()`](/ref/atr/form.py:render_block), which generates all the necessary HTML with Bootstrap styling. diff --git a/atr/get/announce.py b/atr/get/announce.py index 235c8463..022ef3e5 100644 --- a/atr/get/announce.py +++ b/atr/get/announce.py @@ -14,7 +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 htpy import markupsafe @@ -26,6 +26,7 @@ import atr.construct as construct import atr.form as form import atr.get.projects as projects import atr.htm as htm +import atr.models.safe as safe import atr.models.sql as sql import atr.post as post import atr.render as render @@ -35,12 +36,18 @@ import atr.util as util import atr.web as web [email protected]("/announce/<project_name>/<version_name>") -async def selected(session: web.Committer, project_name: str, version_name: str) -> str | web.WerkzeugResponse: - """Allow the user to announce a release preview.""" - await session.check_access(project_name) - - release = await _get_page_data(project_name, session, version_name) [email protected] +async def selected( + session: web.Committer, + _announce: Literal["announce"], + project_name: safe.ProjectName, + version_name: safe.VersionName, +) -> str | web.WerkzeugResponse: + """ + URL: /announce/<project_name>/<version_name> + Allow the user to announce a release preview. + """ + release = await _get_page_data(session, project_name, version_name) latest_revision_number = release.latest_revision_number if latest_revision_number is None: @@ -51,16 +58,16 @@ async def selected(session: web.Committer, project_name: str, version_name: str) ) # Get the templates from the release policy - default_subject_template = await construct.announce_release_subject_default(project_name) - default_body_template = await construct.announce_release_default(project_name) + default_subject_template = await construct.announce_release_subject_default(str(project_name)) + default_body_template = await construct.announce_release_default(str(project_name)) subject_template_hash = construct.template_hash(default_subject_template) # Expand the templates options = construct.AnnounceReleaseOptions( 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=latest_revision_number, ) default_subject, default_body = await construct.announce_release_subject_and_body( @@ -102,10 +109,12 @@ async def selected(session: web.Committer, project_name: str, version_name: str) ) -async def _get_page_data(project_name: str, session: web.Committer, version_name: str) -> sql.Release: +async def _get_page_data( + session: web.Committer, project_name: safe.ProjectName, version_name: safe.VersionName +) -> sql.Release: 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, @@ -123,7 +132,7 @@ def _render_body_field(default_body: str, project_name: str) -> htm.Element: rows="12", )[default_body] - settings_url = util.as_url(projects.view, name=project_name) + "#announce_release_template" + settings_url = util.as_url(projects.view, project_name=project_name) + "#announce_release_template" link = htm.div(".form-text.text-muted.mt-2")[ "To edit the template, go to the ", htm.a(href=settings_url)["project settings"], @@ -288,7 +297,7 @@ def _render_release_card(release: sql.Release) -> htm.Element: def _render_subject_field(default_subject: str, project_name: str) -> htm.Element: - settings_url = util.as_url(projects.view, name=project_name) + "#announce_release_subject" + settings_url = util.as_url(projects.view, project_name=project_name) + "#announce_release_subject" return htm.div[ htpy.input( type="text", diff --git a/atr/get/checklist.py b/atr/get/checklist.py index d33db173..3153114e 100644 --- a/atr/get/checklist.py +++ b/atr/get/checklist.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 cmarkgfm import markupsafe @@ -25,18 +26,24 @@ import atr.db as db import atr.db.interaction as interaction import atr.get.vote as vote import atr.htm as htm +import atr.models.safe as safe import atr.render as render import atr.template as template import atr.util as util import atr.web as web [email protected]("/checklist/<project_name>/<version_name>") -async def selected(session: web.Committer | None, project_name: str, version_name: str) -> str: [email protected] +async def selected( + session: web.Public, + _checklist: Literal["checklist"], + project_name: safe.ProjectName, + version_name: safe.VersionName, +) -> str: async with db.session() as data: release = await data.release( - project_name=project_name, - version=version_name, + project_name=str(project_name), + version=str(version_name), _project=True, _committee=True, _project_release_policy=True, @@ -60,7 +67,7 @@ async def selected(session: web.Committer | None, project_name: str, version_nam substituted_markdown = construct.checklist_body( checklist_markdown, project=project, - version_name=version_name, + version_name=str(version_name), committee=committee, revision=latest_revision, ) @@ -69,8 +76,8 @@ async def selected(session: web.Committer | None, project_name: str, version_nam page = htm.Block() render.html_nav( page, - back_url=util.as_url(vote.selected, project_name=project_name, version_name=version_name), - back_anchor=f"Vote on {project.short_display_name} {version_name}", + back_url=util.as_url(vote.selected, project_name=str(project_name), version_name=str(version_name)), + back_anchor=f"Vote on {project.short_display_name} {version_name!s}", phase="VOTE", ) page.h1["Release checklist"] @@ -78,12 +85,12 @@ async def selected(session: web.Committer | None, project_name: str, version_nam "Checklist for ", htm.strong[project.short_display_name], " version ", - version_name, + str(version_name), ":", ] page.div(".checklist-content.mt-4")[checklist_html] return await template.blank( - title=f"Release checklist for {project.short_display_name} {version_name}", + title=f"Release checklist for {project.short_display_name} {version_name!s}", content=page.collect(), ) diff --git a/atr/get/checks.py b/atr/get/checks.py index 0105d977..df41705c 100644 --- a/atr/get/checks.py +++ b/atr/get/checks.py @@ -17,7 +17,7 @@ import pathlib from collections.abc import Callable -from typing import NamedTuple +from typing import Literal, NamedTuple import asfquart.base as base import htpy @@ -33,6 +33,7 @@ import atr.get.report as report import atr.get.sbom as sbom import atr.get.vote as vote import atr.htm as htm +import atr.models.safe as safe import atr.models.sql as sql import atr.paths as paths import atr.post as post @@ -97,13 +98,18 @@ async def get_file_totals(release: sql.Release, session: web.Committer | None) - return totals [email protected]("/checks/<project_name>/<version_name>") -async def selected(session: web.Committer | None, project_name: str, version_name: str) -> str: - """Show the file checks for a release candidate.""" [email protected] +async def selected( + session: web.Public, _checks: Literal["checks"], project_name: safe.ProjectName, version_name: safe.VersionName +) -> str: + """ + URL: /checks/<project_name>/<version_name> + Show the file checks for a release candidate. + """ async with db.session() as data: release = await data.release( - project_name=project_name, - version=version_name, + project_name=str(project_name), + version=str(version_name), phase=sql.ReleasePhase.RELEASE_CANDIDATE, _committee=True, ).demand(base.ASFQuartException("Release does not exist", errorcode=404)) @@ -134,18 +140,22 @@ async def selected(session: web.Committer | None, project_name: str, version_nam ) [email protected]("/checks/<project_name>/<version_name>/<revision_number>") [email protected] async def selected_revision( session: web.Committer, - project_name: str, - version_name: str, + _checks: Literal["checks"], + project_name: safe.ProjectName, + version_name: safe.VersionName, revision_number: str, ) -> web.QuartResponse: - """Return JSON with ongoing count and HTML fragments for dynamic updates.""" + """ + URL: /checks/<project_name>/<version_name>/<revision_number> + Return JSON with ongoing count and HTML fragments for dynamic updates. + """ async with db.session() as data: release = await data.release( - project_name=project_name, - version=version_name, + project_name=str(project_name), + version=str(version_name), _committee=True, # _project=True is included in _project_release_policy=True _project_release_policy=True, @@ -159,9 +169,9 @@ async def selected_revision( ragp = read.as_general_public() info = await ragp.releases.path_info(release, all_paths) - ongoing_count = await interaction.tasks_ongoing(project_name, version_name, revision_number) + ongoing_count = await interaction.tasks_ongoing(str(project_name), str(version_name), revision_number) - checks_summary_elem = shared.web._render_checks_summary(info, project_name, version_name) + checks_summary_elem = shared.web.render_checks_summary(info, str(project_name), str(version_name)) checks_summary_html = str(checks_summary_elem) if checks_summary_elem else "" delete_file_forms: dict[str, str] = {} @@ -170,7 +180,9 @@ async def selected_revision( delete_file_forms[str(path)] = str( form.render( model_cls=draft.DeleteFileForm, - action=util.as_url(post.draft.delete_file, project_name=project_name, version_name=version_name), + action=util.as_url( + post.draft.delete_file, project_name=str(project_name), version_name=str(version_name) + ), form_classes=".d-inline-block.m-0", submit_classes="btn-sm btn-outline-danger", submit_label="Delete", @@ -188,8 +200,8 @@ async def selected_revision( "check-selected-path-table.html", paths=all_paths, info=info, - project_name=project_name, - version_name=version_name, + project_name=str(project_name), + version_name=str(version_name), release=release, phase=release.phase.value, delete_file_forms=delete_file_forms, @@ -422,7 +434,9 @@ def _render_file_row( version_name=release.version, file_path=path_str, ) - sbom_url = util.as_url(sbom.report, project=release.project.name, version=release.version, file_path=path_str) + sbom_url = util.as_url( + sbom.report, project_name=release.project.name, version_name=release.version, file_path=path_str + ) if not has_checks_before: path_display = htpy.code(".text-muted")[path_str] diff --git a/atr/get/committees.py b/atr/get/committees.py index a660aea8..098c1b55 100644 --- a/atr/get/committees.py +++ b/atr/get/committees.py @@ -16,6 +16,7 @@ # under the License. import datetime +from typing import Literal import asfquart.base as base @@ -30,9 +31,12 @@ import atr.util as util import atr.web as web [email protected]("/committees") -async def directory(session: web.Committer | None) -> str: - """Main committee directory page.""" [email protected] +async def directory(session: web.Public, _committees: Literal["committees"]) -> str: + """ + URL: /committees + Main committee directory page. + """ async with db.session() as data: committees = await data.committee(_projects=True).order_by(sql.Committee.name).all() return await template.render( @@ -43,8 +47,11 @@ async def directory(session: web.Committer | None) -> str: ) [email protected]("/committees/<name>") -async def view(session: web.Committer | None, name: str) -> str: [email protected] +async def view(session: web.Public, _committees: Literal["committees"], name: str) -> str: + """ + URL: /committees/<name> + """ # TODO: Could also import this from keys.py async with db.session() as data: committee = await data.committee( diff --git a/atr/get/compose.py b/atr/get/compose.py index 6e400872..18d9ce69 100644 --- a/atr/get/compose.py +++ b/atr/get/compose.py @@ -15,25 +15,34 @@ # specific language governing permissions and limitations # under the License. +from typing import Literal + import asfquart.base as base import atr.blueprints.get as get import atr.db as db import atr.mapping as mapping +import atr.models.safe as safe import atr.models.sql as sql import atr.shared as shared import atr.web as web [email protected]("/compose/<project_name>/<version_name>") -async def selected(session: web.Committer, project_name: str, version_name: str) -> web.WerkzeugResponse | str: - """Show the contents of the release candidate draft.""" - await session.check_access(project_name) - [email protected] +async def selected( + session: web.Committer, + _compose: Literal["compose"], + project_name: safe.ProjectName, + version_name: safe.VersionName, +) -> web.WerkzeugResponse | str: + """ + URL: /compose/<project_name>/<version_name> + Show the contents of the release candidate draft. + """ async with db.session() as data: release = await data.release( - project_name=project_name, - version=version_name, + project_name=str(project_name), + version=str(version_name), _committee=True, _project_release_policy=True, ).demand(base.ASFQuartException("Release does not exist", errorcode=404)) diff --git a/atr/get/distribution.py b/atr/get/distribution.py index 4bbf9225..82017a2f 100644 --- a/atr/get/distribution.py +++ b/atr/get/distribution.py @@ -15,6 +15,7 @@ # specific language governing permissions and limitations # under the License. from collections.abc import Sequence +from typing import Literal import asfquart.base as base import htpy @@ -23,6 +24,7 @@ import atr.blueprints.get as get import atr.db as db import atr.form as form import atr.htm as htm +import atr.models.safe as safe import atr.models.sql as sql import atr.post as post import atr.render as render @@ -33,35 +35,51 @@ import atr.web as web from atr.tasks import gha [email protected]("/distribution/automate/<project>/<version>") -async def automate(session: web.Committer, project: str, version: str) -> str: - return await _automate_form_page(project, version, staging=False) - - [email protected]("/distributions/list/<project_name>/<version_name>") -async def list_get(session: web.Committer, project_name: str, version_name: str) -> str: - distributions, tasks = await _get_page_data(project_name, version_name) [email protected] +async def automate( + session: web.Committer, + _distribution: Literal["distribution/automate"], + project_name: safe.ProjectName, + version_name: safe.VersionName, +) -> str: + """ + URL: /distribution/automate/<project_name>/<version> + """ + return await _automate_form_page(str(project_name), str(version_name), staging=False) + + [email protected] +async def list_get( + session: web.Committer, + _distribution: Literal["distribution/list"], + project_name: safe.ProjectName, + version_name: safe.VersionName, +) -> str: + """ + URL: /distribution/list/<project_name>/<version_name> + """ + distributions, tasks = await _get_page_data(str(project_name), str(version_name)) block = htm.Block() - release = await shared.distribution.release_validated(project_name, version_name, staging=None) + release = await shared.distribution.release_validated(str(project_name), str(version_name), staging=None) staging = release.phase == sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT - render.html_nav_phase(block, project_name, version_name, staging) + render.html_nav_phase(block, str(project_name), str(version_name), staging) record_a_distribution = htm.a( ".btn.btn-primary", href=util.as_url( stage_record if staging else record, - project=project_name, - version=version_name, + project_name=str(project_name), + version_name=str(version_name), ), )["Record a distribution"] # Distribution list for project-version - block.h1["Distribution list for ", htm.em[f"{project_name}-{version_name}"]] + block.h1["Distribution list for ", htm.em[f"{project_name!s}-{version_name!s}"]] if len(tasks) > 0: - _render_distribution_tasks(tasks, block, project_name, version_name) + _render_distribution_tasks(tasks, block, str(project_name), str(version_name)) if not distributions: block.p["No distributions found."] @@ -102,7 +120,7 @@ async def list_get(session: web.Committer, project_name: str, version_name: str) delete_form = form.render( model_cls=shared.distribution.DeleteForm, - action=util.as_url(post.distribution.delete, project=project_name, version=version_name), + action=util.as_url(post.distribution.delete, project=str(project_name), version=str(version_name)), form_classes=".d-inline-block.m-0", submit_classes="btn-danger btn-sm", submit_label="Delete", @@ -118,23 +136,47 @@ async def list_get(session: web.Committer, project_name: str, version_name: str) ) block.append(htm.div(".mb-3")[delete_form]) - title = f"Distribution list for {project_name} {version_name}" + title = f"Distribution list for {project_name!s} {version_name!s}" return await template.blank(title, content=block.collect()) [email protected]("/distribution/record/<project>/<version>") -async def record(session: web.Committer, project: str, version: str) -> str: - return await _record_form_page(project, version, staging=False) - - [email protected]("/distribution/stage/automate/<project>/<version>") -async def stage_automate(session: web.Committer, project: str, version: str) -> str: - return await _automate_form_page(project, version, staging=True) - - [email protected]("/distribution/stage/record/<project>/<version>") -async def stage_record(session: web.Committer, project: str, version: str) -> str: - return await _record_form_page(project, version, staging=True) [email protected] +async def record( + session: web.Committer, + _distribution: Literal["distribution/record"], + project_name: safe.ProjectName, + version_name: safe.VersionName, +) -> str: + """ + URL: /distribution/record/<project_name>/<version_name> + """ + return await _record_form_page(str(project_name), str(version_name), staging=False) + + [email protected] +async def stage_automate( + session: web.Committer, + _distribution: Literal["distribution/stage/automate"], + project_name: safe.ProjectName, + version_name: safe.VersionName, +) -> str: + """ + URL: /distribution/stage/automate/<project_name>/<version_name> + """ + return await _automate_form_page(str(project_name), str(version_name), staging=True) + + [email protected] +async def stage_record( + session: web.Committer, + _distribution: Literal["distribution/stage/record"], + project_name: safe.ProjectName, + version_name: safe.VersionName, +) -> str: + """ + URL: /distribution/stage/record/<project_name>/<version_name> + """ + return await _record_form_page(str(project_name), str(version_name), staging=True) async def _automate_form_page(project: str, version: str, staging: bool) -> str: diff --git a/atr/get/docs.py b/atr/get/docs.py index dfaab4aa..1e2e5b33 100644 --- a/atr/get/docs.py +++ b/atr/get/docs.py @@ -17,6 +17,7 @@ import pathlib from html.parser import HTMLParser +from typing import Literal import aiofiles import aiofiles.os @@ -26,6 +27,7 @@ import quart import atr.blueprints.get as get import atr.config as config import atr.form as form +import atr.models.unsafe as unsafe import atr.template as template import atr.web as web @@ -49,14 +51,14 @@ class H1Parser(HTMLParser): self.h1_content = data.strip() [email protected]("/docs/") -async def index(session: web.Committer | None) -> str: [email protected] +async def index(session: web.Public, _docs: Literal["docs"]) -> str: return await _serve_docs_page("index") [email protected]("/docs/<path:page>") -async def page(session: web.Committer | None, page: str) -> str: - validated_page = form.to_relpath(page) [email protected] +async def page(session: web.Public, _docs: Literal["docs"], path: unsafe.Path) -> str: + validated_page = form.to_relpath(path) if validated_page is None: quart.abort(400) return await _serve_docs_page(str(validated_page)) diff --git a/atr/get/download.py b/atr/get/download.py index bb8d5145..b694bd75 100644 --- a/atr/get/download.py +++ b/atr/get/download.py @@ -17,6 +17,7 @@ import pathlib from collections.abc import AsyncGenerator +from typing import Literal import aiofiles import aiofiles.os @@ -30,20 +31,32 @@ import atr.db as db import atr.form as form import atr.htm as htm import atr.mapping as mapping +import atr.models.safe as safe import atr.models.sql as sql +import atr.models.unsafe as unsafe import atr.paths as paths import atr.template as template import atr.util as util import atr.web as web [email protected]("/download/all/<project_name>/<version_name>") -async def all_selected(session: web.Committer, project_name: str, version_name: str) -> web.WerkzeugResponse | str: - """Display download commands for a release.""" [email protected] +async def all_selected( + session: web.Committer, + _download_all: Literal["download/all"], + project_name: safe.ProjectName, + version_name: safe.VersionName, +) -> web.WerkzeugResponse | str: + """ + URL: /download/all/<project_name>/<version_name> + Display download commands for a release. + """ import atr.get.root as root async with db.session() as data: - release = await session.release(project_name=project_name, version_name=version_name, phase=None, data=data) + release = await session.release( + project_name=str(project_name), version_name=str(version_name), phase=None, data=data + ) if not release: return await session.redirect(root.index, error="Release not found") user_ssh_keys = await data.ssh_key(asf_uid=session.uid).all() @@ -52,8 +65,8 @@ async def all_selected(session: web.Committer, project_name: str, version_name: return await template.render( "download-all.html", - project_name=project_name, - version_name=version_name, + project_name=str(project_name), + version_name=str(version_name), release=release, asf_id=session.uid, server_domain=session.app_host.split(":", 1)[0], @@ -64,28 +77,53 @@ async def all_selected(session: web.Committer, project_name: str, version_name: ) [email protected]("/download/path/<project_name>/<version_name>/<path:file_path>") -async def path(session: web.Committer | None, project_name: str, version_name: str, file_path: str) -> web.Response: - """Download a file or list a directory from a release in any phase.""" - return await _download_or_list(project_name, version_name, file_path) - - [email protected]("/download/path/<project_name>/<version_name>/") -async def path_empty(session: web.Committer | None, project_name: str, version_name: str) -> web.Response: - """List files at the root of a release directory for download.""" - return await _download_or_list(project_name, version_name, ".") - - [email protected]("/download/sh/<project_name>/<version_name>") -async def sh_selected(session: web.Committer | None, project_name: str, version_name: str) -> web.Response: - """Shell script to download a release.""" [email protected] +async def path( + session: web.Public, + _download_path: Literal["download/path"], + project_name: safe.ProjectName, + version_name: safe.VersionName, + file_path: unsafe.Path, +) -> web.Response: + """ + URL: /download/path/<project_name>/<version_name>/<path:file_path> + Download a file or list a directory from a release in any phase. + """ + return await _download_or_list(str(project_name), str(version_name), str(file_path)) + + [email protected] +async def path_empty( + session: web.Public, + _download_path: Literal["download/path"], + project_name: safe.ProjectName, + version_name: safe.VersionName, +) -> web.Response: + """ + URL: /download/path/<project_name>/<version_name>/ + List files at the root of a release directory for download. + """ + return await _download_or_list(str(project_name), str(version_name), ".") + + [email protected] +async def sh_selected( + session: web.Public, + _download_sh: Literal["download/sh"], + project_name: safe.ProjectName, + version_name: safe.VersionName, +) -> web.Response: + """ + URL: /download/sh/<project_name>/<version_name> + Shell script to download a release. + """ conf = config.get() app_host = conf.APP_HOST script_path = (pathlib.Path(__file__).parent / "../static/sh/download-urls.sh").resolve() async with aiofiles.open(script_path) as f: content = await f.read() - download_urls_selected = util.as_url(urls_selected, project_name=project_name, version_name=version_name) - download_path = util.as_url(path, project_name=project_name, version_name=version_name, file_path="") + download_urls_selected = util.as_url(urls_selected, project_name=str(project_name), version_name=str(version_name)) + download_path = util.as_url(path, project_name=str(project_name), version_name=str(version_name), file_path="") curl_options = "--insecure" if util.is_dev_environment() else "--proto =https --tlsv1.2" content = content.replace("[CURL_EXTRA]", curl_options) content = content.replace("[URL_OF_URLS]", f"https://{app_host}{download_urls_selected}") @@ -93,11 +131,19 @@ async def sh_selected(session: web.Committer | None, project_name: str, version_ return web.ShellResponse(content) [email protected]("/download/urls/<project_name>/<version_name>") -async def urls_selected(session: web.Committer | None, project_name: str, version_name: str) -> web.Response: [email protected] +async def urls_selected( + session: web.Public, + _download_urls: Literal["download/urls"], + project_name: safe.ProjectName, + version_name: safe.VersionName, +) -> web.Response: + """ + URL: /download/urls/<project_name>/<version_name> + """ try: async with db.session() as data: - release = await data.release(project_name=project_name, version=version_name).demand( + release = await data.release(project_name=str(project_name), version=str(version_name)).demand( ValueError("Release not found") ) url_list_str = await _generate_file_url_list(release) @@ -108,10 +154,18 @@ async def urls_selected(session: web.Committer | None, project_name: str, versio return web.TextResponse(f"Internal server error: {e}", status=500) [email protected]("/download/zip/<project_name>/<version_name>") -async def zip_selected(session: web.Committer, project_name: str, version_name: str) -> web.Response: [email protected] +async def zip_selected( + session: web.Committer, + _download_zip: Literal["download/zip"], + project_name: safe.ProjectName, + version_name: safe.VersionName, +) -> web.Response: + """ + URL: /download/zip/<project_name>/<version_name> + """ try: - release = await session.release(project_name=project_name, version_name=version_name, phase=None) + release = await session.release(project_name=str(project_name), version_name=str(version_name), phase=None) except ValueError as e: return web.TextResponse(f"Error: {e}", status=404) except Exception as e: diff --git a/atr/get/draft.py b/atr/get/draft.py index 16a091ac..04ce860b 100644 --- a/atr/get/draft.py +++ b/atr/get/draft.py @@ -18,12 +18,15 @@ from __future__ import annotations import datetime +from typing import Literal import aiofiles.os import asfquart.base as base import atr.blueprints.get as get import atr.form as form +import atr.models.safe as safe +import atr.models.unsafe as unsafe import atr.paths as paths import atr.post as post import atr.shared as shared @@ -32,16 +35,23 @@ import atr.util as util import atr.web as web [email protected]("/draft/tools/<project_name>/<version_name>/<path:file_path>") -async def tools(session: web.Committer, project_name: str, version_name: str, file_path: str) -> str: - """Show the tools for a specific file.""" - await session.check_access(project_name) - - validated_path = form.to_relpath(file_path) [email protected] +async def tools( + session: web.Committer, + _draft_tools: Literal["draft/tools"], + project_name: safe.ProjectName, + version_name: safe.VersionName, + file_path: unsafe.Path, +) -> str: + """ + URL: /draft/tools/<project_name>/<version_name>/<path:file_path> + Show the tools for a specific file. + """ + validated_path = form.to_relpath(str(file_path)) if validated_path is None: raise base.ASFQuartException("Invalid file path", errorcode=400) - release = await session.release(project_name, version_name) + release = await session.release(str(project_name), str(version_name)) full_path = str(paths.release_directory(release) / validated_path) # Check that the file exists @@ -60,7 +70,10 @@ async def tools(session: web.Committer, project_name: str, version_name: str, fi } hashgen_action = util.as_url( - post.draft.hashgen, project_name=project_name, version_name=version_name, file_path=validated_path_str + post.draft.hashgen, + project_name=str(project_name), + version_name=str(version_name), + file_path=validated_path_str, ) sha512_form = form.render( model_cls=shared.draft.HashGen, @@ -72,7 +85,10 @@ async def tools(session: web.Committer, project_name: str, version_name: str, fi sbom_form = form.render( model_cls=form.Empty, action=util.as_url( - post.draft.sbomgen, project_name=project_name, version_name=version_name, file_path=validated_path_str + post.draft.sbomgen, + project_name=str(project_name), + version_name=str(version_name), + file_path=validated_path_str, ), submit_label="Generate CycloneDX SBOM (.cdx.json)", submit_classes="btn-outline-secondary", @@ -82,8 +98,8 @@ async def tools(session: web.Committer, project_name: str, version_name: str, fi return await template.render( "draft-tools.html", asf_id=session.uid, - project_name=project_name, - version_name=version_name, + project_name=str(project_name), + version_name=str(version_name), file_path=validated_path_str, file_data=file_data, release=release, diff --git a/atr/get/file.py b/atr/get/file.py index 2c004b55..c0f3f9c4 100644 --- a/atr/get/file.py +++ b/atr/get/file.py @@ -23,7 +23,9 @@ import atr.get.compose as compose import atr.get.finish as finish import atr.get.vote as vote import atr.htm as htm +import atr.models.safe as safe import atr.models.sql as sql +import atr.models.unsafe as unsafe import atr.paths as paths import atr.render as render import atr.template as template @@ -33,21 +35,31 @@ import atr.web as web type Phase = Literal["COMPOSE", "VOTE", "FINISH"] [email protected]("/file/<project_name>/<version_name>") -async def selected(session: web.Committer, project_name: str, version_name: str) -> str: - """View all the files in a release (any phase).""" - await session.check_access(project_name) - - release = await session.release(project_name, version_name, phase=None) [email protected] +async def selected( + session: web.Committer, + _file: Literal["file"], + project_name: safe.ProjectName, + version_name: safe.VersionName, +) -> str: + """ + URL: /file/<project_name>/<version_name> + View all the files in a release (any phase). + """ + release = await session.release(str(project_name), str(version_name), phase=None) revision_number = release.latest_revision_number file_stats = [] if release.phase == sql.ReleasePhase.RELEASE: - file_stats = [stat async for stat in util.content_list(paths.get_finished_dir(), project_name, version_name)] + file_stats = [ + stat async for stat in util.content_list(paths.get_finished_dir(), str(project_name), str(version_name)) + ] elif revision_number is not None: file_stats = [ stat - async for stat in util.content_list(paths.get_unfinished_dir(), project_name, version_name, revision_number) + async for stat in util.content_list( + paths.get_unfinished_dir(), str(project_name), str(version_name), revision_number + ) ] else: raise ValueError("No revision number found for unfinished release") @@ -125,16 +137,23 @@ async def selected(session: web.Committer, project_name: str, version_name: str) return await template.blank(f"Files in {release.short_display_name}", content=block.collect()) [email protected]("/file/<project_name>/<version_name>/<path:file_path>") -async def selected_path(session: web.Committer, project_name: str, version_name: str, file_path: str) -> str: - """View the content of a specific file in a release (any phase).""" - await session.check_access(project_name) - - validated_path = form.to_relpath(file_path) [email protected] +async def selected_path( + session: web.Committer, + _file: Literal["file"], + project_name: safe.ProjectName, + version_name: safe.VersionName, + file_path: unsafe.Path, +) -> str: + """ + URL: /file/<project_name>/<version_name>/<path:file_path> + View the content of a specific file in a release (any phase). + """ + validated_path = form.to_relpath(str(file_path)) if validated_path is None: raise web.FlashError("Invalid file path") - release = await session.release(project_name, version_name, phase=None) + release = await session.release(str(project_name), str(version_name), phase=None) _max_view_size = 512 * 1024 full_path = paths.release_directory(release) / validated_path content_listing = await util.archive_listing(full_path) diff --git a/atr/get/finish.py b/atr/get/finish.py index ac95b2c1..7a975900 100644 --- a/atr/get/finish.py +++ b/atr/get/finish.py @@ -19,6 +19,7 @@ import dataclasses import pathlib from collections.abc import Sequence +from typing import Literal import aiofiles.os import asfquart.base as base @@ -39,6 +40,7 @@ import atr.get.revisions as revisions import atr.get.root as root import atr.htm as htm import atr.mapping as mapping +import atr.models.safe as safe import atr.models.sql as sql import atr.paths as paths import atr.render as render @@ -56,20 +58,26 @@ class RCTagAnalysisResult: total_paths: int [email protected]("/finish/<project_name>/<version_name>") [email protected] async def selected( - session: web.Committer, project_name: str, version_name: str + session: web.Committer, + _finish: Literal["finish"], + project_name: safe.ProjectName, + version_name: safe.VersionName, ) -> tuple[web.QuartResponse, int] | web.WerkzeugResponse | str: - """Finish a release preview.""" + """ + URL: /finish/<project_name>/<version_name> + Finish a release preview. + """ try: (release, source_files_rel, target_dirs, deletable_dirs, rc_analysis, tasks) = await _get_page_data( - project_name, version_name + str(project_name), str(version_name) ) except ValueError: async with db.session() as data: release_fallback = await data.release( - project_name=project_name, - version=version_name, + project_name=str(project_name), + version=str(version_name), _committee=True, ).get() if release_fallback: @@ -224,16 +232,16 @@ def _render_distribution_buttons(release: sql.Release) -> htm.Element: ".btn.btn-primary.me-2", href=util.as_url( distribution.automate, - project=release.project.name, - version=release.version, + project_name=release.project.name, + version_name=release.version, ), )["Distribute"], htm.a( ".btn.btn-secondary.me-2", href=util.as_url( distribution.record, - project=release.project.name, - version=release.version, + project_name=release.project.name, + version_name=release.version, ), )["Record a manual distribution"], ], diff --git a/atr/get/ignores.py b/atr/get/ignores.py index 5c741d37..45c23c01 100644 --- a/atr/get/ignores.py +++ b/atr/get/ignores.py @@ -15,9 +15,12 @@ # specific language governing permissions and limitations # under the License. +from typing import Literal + import atr.blueprints.get as get import atr.form as form import atr.htm as htm +import atr.models.safe as safe import atr.models.sql as sql import atr.post as post import atr.shared as shared @@ -27,16 +30,23 @@ import atr.util as util import atr.web as web [email protected]("/ignores/<project_name>") -async def ignores(session: web.Committer, project_name: str) -> str | web.WerkzeugResponse: [email protected] +async def ignores( + session: web.Committer, + _ignores: Literal["ignores"], + project_name: safe.ProjectName, +) -> str | web.WerkzeugResponse: + """ + URL: /ignores/<project_name> + """ async with storage.read() as read: ragp = read.as_general_public() - ignores = await ragp.checks.ignores(project_name) + ignores = await ragp.checks.ignores(str(project_name)) content = htm.div[ htm.h1["Ignored checks"], - htm.p[f"Manage ignored checks for project {project_name}."], - _add_ignore(project_name), + htm.p[f"Manage ignored checks for project {project_name!s}."], + _add_ignore(str(project_name)), _existing_ignores(ignores), ] diff --git a/atr/get/keys.py b/atr/get/keys.py index 16da02db..b82c1b7f 100644 --- a/atr/get/keys.py +++ b/atr/get/keys.py @@ -17,6 +17,7 @@ import datetime +from typing import Literal import htpy import quart @@ -34,9 +35,12 @@ import atr.util as util import atr.web as web [email protected]("/keys/add") -async def add(session: web.Committer) -> str: - """Add a new public signing key to the user's account.""" [email protected] +async def add(session: web.Committer, _keys_add: Literal["keys/add"]) -> str: + """ + URL: /keys/add + Add a new public signing key to the user's account. + """ async with storage.write() as write: participant_of_committees = await write.participant_of_committees() @@ -67,9 +71,12 @@ async def add(session: web.Committer) -> str: ) [email protected]("/keys/details/<fingerprint>") -async def details(session: web.Committer, fingerprint: str) -> str: - """Display details for a specific OpenPGP key.""" [email protected] +async def details(session: web.Committer, _keys_details: Literal["keys/details"], fingerprint: str) -> str: + """ + URL: /keys/details/<fingerprint> + Display details for a specific OpenPGP key. + """ fingerprint = fingerprint.lower() async with db.session() as data: key, is_owner = await _key_and_is_owner(data, session, fingerprint) @@ -173,9 +180,12 @@ async def details(session: web.Committer, fingerprint: str) -> str: ) [email protected]("/keys/export/<committee_name>") -async def export(session: web.Committer, committee_name: str) -> web.TextResponse: - """Export a KEYS file for a specific committee.""" [email protected] +async def export(session: web.Committer, _keys_export: Literal["keys/export"], committee_name: str) -> web.TextResponse: + """ + URL: /keys/export/<committee_name> + Export a KEYS file for a specific committee. + """ async with storage.write() as write: wafc = write.as_foundation_committer() keys_file_text = await wafc.keys.keys_file_text(committee_name) @@ -183,9 +193,12 @@ async def export(session: web.Committer, committee_name: str) -> web.TextRespons return web.TextResponse(keys_file_text) [email protected]("/keys") -async def keys(session: web.Committer) -> str: - """View all keys associated with the user's account.""" [email protected] +async def keys(session: web.Committer, _keys: Literal["keys"]) -> str: + """ + URL: /keys + View all keys associated with the user's account. + """ committees_to_query = list(set(session.committees + session.projects)) async with db.session() as data: @@ -220,9 +233,12 @@ async def keys(session: web.Committer) -> str: ) [email protected]("/keys/ssh/add") -async def ssh_add(session: web.Committer) -> str: - """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"]) -> str: + """ + URL: /keys/ssh/add + Add a new SSH key to the user's account. + """ page = htm.Block() page.p[htm.a(".atr-back-link", href=util.as_url(keys))["← Back to Manage keys"]] page.h1["Add your SSH key"] @@ -249,9 +265,12 @@ async def ssh_add(session: web.Committer) -> str: ) [email protected]("/keys/upload") -async def upload(session: web.Committer) -> str: - """Upload a KEYS file containing multiple OpenPGP keys.""" [email protected] +async def upload(session: web.Committer, _keys_upload: Literal["keys/upload"]) -> str: + """ + URL: /keys/upload + Upload a KEYS file containing multiple OpenPGP keys. + """ return await shared.keys.render_upload_page() diff --git a/atr/get/manual.py b/atr/get/manual.py index ee88a1ea..76786aa2 100644 --- a/atr/get/manual.py +++ b/atr/get/manual.py @@ -15,6 +15,8 @@ # specific language governing permissions and limitations # under the License. +from typing import Literal + import atr.blueprints.get as get import atr.db as db import atr.db.interaction as interaction @@ -22,6 +24,7 @@ import atr.form as form import atr.get.compose as compose import atr.get.vote as vote import atr.htm as htm +import atr.models.safe as safe import atr.models.sql as sql import atr.post as post import atr.render as render @@ -31,14 +34,20 @@ import atr.util as util import atr.web as web [email protected]("/manual/resolve/<project_name>/<version_name>") -async def resolve_selected(session: web.Committer, project_name: str, version_name: str) -> str: - """Get the manual vote resolution page.""" - await session.check_access(project_name) - [email protected] +async def resolve_selected( + session: web.Committer, + _manual_resolve: Literal["manual/resolve"], + project_name: safe.ProjectName, + version_name: safe.VersionName, +) -> str: + """ + URL: /manual/resolve/<project_name>/<version_name> + Get 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, @@ -55,22 +64,27 @@ async def resolve_selected(session: web.Committer, project_name: str, version_na ) [email protected]("/manual/start/<project_name>/<version_name>/<revision>") [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, ) -> 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( 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): diff --git a/atr/get/projects.py b/atr/get/projects.py index 30753756..ae2435b6 100644 --- a/atr/get/projects.py +++ b/atr/get/projects.py @@ -17,6 +17,8 @@ from __future__ import annotations +from typing import Literal + import asfquart.base as base import htpy import strictyaml @@ -31,6 +33,7 @@ import atr.get.committees as committees import atr.get.file as file import atr.get.start as start import atr.htm as htm +import atr.models.safe as safe import atr.models.sql as sql import atr.post as post import atr.registry as registry @@ -41,8 +44,13 @@ import atr.util as util import atr.web as web [email protected]("/project/add/<committee_name>") -async def add_project(session: web.Committer, committee_name: str) -> web.WerkzeugResponse | str: [email protected] +async def add_project( + session: web.Committer, _project_add: Literal["project/add"], committee_name: str +) -> web.WerkzeugResponse | str: + """ + URL: /project/add/<committee_name> + """ await session.check_access_committee(committee_name) async with db.session() as data: @@ -85,9 +93,12 @@ async def add_project(session: web.Committer, committee_name: str) -> web.Werkze ) [email protected]("/projects") -async def projects(session: web.Committer | None) -> str: - """Main project directory page.""" [email protected] +async def projects(session: web.Public, _projects: Literal["projects"]) -> str: + """ + URL: /projects + Main project directory page. + """ async with db.session() as data: projects = await data.project(_committee=True).order_by(sql.Project.full_name).all() @@ -107,9 +118,12 @@ async def projects(session: web.Committer | None) -> str: return await template.render("projects.html", projects=projects, delete_forms=delete_forms) [email protected]("/project/select") -async def select(session: web.Committer) -> str: - """Select a project to work on.""" [email protected] +async def select(session: web.Committer, _project_select: Literal["project/select"]) -> str: + """ + URL: /project/select + Select a project to work on. + """ user_projects = [] if session.uid: async with db.session() as data: @@ -133,12 +147,17 @@ async def select(session: web.Committer) -> str: return await template.render("project-select.html", user_projects=user_projects) [email protected]("/projects/<name>") -async def view(session: web.Committer, name: str) -> web.WerkzeugResponse | str: [email protected] +async def view( + session: web.Committer, _projects: Literal["projects"], project_name: safe.ProjectName +) -> web.WerkzeugResponse | str: + """ + URL: /projects/<project_name> + """ async with db.session() as data: project = await data.project( - name=name, _committee=True, _committee_public_signing_keys=True, _release_policy=True - ).demand(base.ASFQuartException(f"Project {name} not found", errorcode=404)) + name=str(project_name), _committee=True, _committee_public_signing_keys=True, _release_policy=True + ).demand(base.ASFQuartException(f"Project {project_name} not found", errorcode=404)) is_committee_member = project.committee and (user.is_committee_member(project.committee, session.uid)) is_privileged = session.is_admin diff --git a/atr/get/published.py b/atr/get/published.py index fe6dacff..b8316a14 100644 --- a/atr/get/published.py +++ b/atr/get/published.py @@ -18,6 +18,7 @@ import pathlib import stat from datetime import datetime +from typing import Literal import aiofiles.os import quart @@ -25,25 +26,32 @@ import quart import atr.blueprints.get as get import atr.form as form import atr.htm as htm +import atr.models.unsafe as unsafe import atr.paths as paths import atr.util as util import atr.web as web [email protected]("/published/<path:path>") -async def path(session: web.Committer, path: str) -> web.QuartResponse: - """View the content of a specific file in the downloads directory.""" [email protected] +async def path(session: web.Committer, _published: Literal["published"], file_path: unsafe.Path) -> web.QuartResponse: + """ + URL: /published/<path:file_path> + View the content of a specific file in the downloads directory. + """ # This route is for debugging # When developing locally, there is no proxy to view the downloads directory # Therefore this path acts as a way to check the contents of that directory - validated_path = form.to_relpath(path) + validated_path = form.to_relpath(str(file_path)) if validated_path is None: return quart.abort(400) return await _path(session, str(validated_path)) [email protected]("/published/") -async def root(session: web.Committer) -> web.QuartResponse: [email protected] +async def root(session: web.Committer, _published: Literal["published/"]) -> web.QuartResponse: + """ + URL: /published/ + """ return await _path(session, "") @@ -63,7 +71,7 @@ async def _directory_listing_pre(full_path: pathlib.Path, current_path: str, pre parent_path = pathlib.Path(current_path).parent parent_url_path = str(parent_path) if (str(parent_path) != ".") else "" if parent_url_path: - pre.a(href=util.as_url(path, path=parent_url_path))["../"] + pre.a(href=util.as_url(path, file_path=parent_url_path))["../"] else: pre.a(href=util.as_url(root))["../"] pre.text("\n\n") @@ -92,7 +100,7 @@ async def _directory_listing_pre(full_path: pathlib.Path, current_path: str, pre entry_path = str(pathlib.Path(current_path) / entry["name"]) display_name = f"{entry['name']}/" if is_dir else entry["name"] pre.text(f"{mode} {nlink} {size} {mtime} ") - pre.a(href=util.as_url(path, path=entry_path))[display_name] + pre.a(href=util.as_url(path, file_path=entry_path))[display_name] pre.text("\n") diff --git a/atr/get/ref.py b/atr/get/ref.py index fab3e898..24123181 100644 --- a/atr/get/ref.py +++ b/atr/get/ref.py @@ -17,6 +17,7 @@ import ast import pathlib +from typing import Literal import aiofiles import aiofiles.os @@ -25,6 +26,7 @@ import quart import atr.blueprints.get as get import atr.config as config import atr.form as form +import atr.models.unsafe as unsafe import atr.web as web # Perhaps GitHub will get around to implementing symbol permalinks: @@ -32,8 +34,12 @@ import atr.web as web # Then this code will be easier, but we should still keep our own links [email protected]("/ref/<path:ref_path>") -async def resolve(session: web.Committer | None, ref_path: str) -> web.WerkzeugResponse: [email protected] +async def resolve(session: web.Public, _ref: Literal["ref"], ref_path: unsafe.Path) -> web.WerkzeugResponse: + """ + URL: /ref/<ref_path> + Resolve a code reference to a GitHub permalink. + """ project_root = pathlib.Path(config.get().PROJECT_ROOT) if ":" in ref_path: diff --git a/atr/get/release.py b/atr/get/release.py index 10a7061b..114a9e3d 100644 --- a/atr/get/release.py +++ b/atr/get/release.py @@ -16,23 +16,30 @@ # under the License. import datetime +from typing import Literal import asfquart.base as base import atr.blueprints.get as get import atr.db as db import atr.db.interaction as interaction +import atr.models.safe as safe import atr.models.sql as sql import atr.template as template import atr.util as util import atr.web as web [email protected]("/releases/finished/<project_name>") -async def finished(session: web.Committer | None, project_name: str) -> str: - """View all finished releases for a project.""" [email protected] +async def finished( + session: web.Public, _releases_finished: Literal["releases/finished"], project_name: safe.ProjectName +) -> str: + """ + URL: /releases/finished/<project_name> + View all finished releases for a project. + """ async with db.session() as data: - project = await data.project(name=project_name, status=sql.ProjectStatus.ACTIVE).demand( + project = await data.project(name=str(project_name), status=sql.ProjectStatus.ACTIVE).demand( base.ASFQuartException(f"Project {project_name} not found", errorcode=404) ) @@ -52,9 +59,12 @@ async def finished(session: web.Committer | None, project_name: str) -> str: ) [email protected]("/releases") -async def releases(session: web.Committer | None) -> str: - """View all releases.""" [email protected] +async def releases(session: web.Public, _releases: Literal["releases"]) -> str: + """ + URL: /releases + View all releases. + """ # Releases are public, so we don't need to filter by user async with db.session() as data: releases = await data.release( @@ -77,13 +87,16 @@ async def releases(session: web.Committer | None) -> str: ) [email protected]("/release/select/<project_name>") -async def select(session: web.Committer, project_name: str) -> str: - """Show releases in progress for a project.""" - await session.check_access(project_name) - [email protected] +async def select( + session: web.Committer, _release_select: Literal["release/select"], project_name: safe.ProjectName +) -> str: + """ + URL: /release/select/<project_name> + Show releases in progress for a project. + """ async with db.session() as data: - project = await data.project(name=project_name, status=sql.ProjectStatus.ACTIVE, _releases=True).demand( + project = await data.project(name=str(project_name), status=sql.ProjectStatus.ACTIVE, _releases=True).demand( base.ASFQuartException(f"Project {project_name} not found", errorcode=404) ) releases = await interaction.releases_in_progress(project) diff --git a/atr/get/report.py b/atr/get/report.py index a3d194c9..33b99686 100644 --- a/atr/get/report.py +++ b/atr/get/report.py @@ -16,13 +16,16 @@ # under the License. import datetime +from typing import Literal import aiofiles.os import asfquart.base as base import atr.blueprints.get as get import atr.form as form +import atr.models.safe as safe import atr.models.sql as sql +import atr.models.unsafe as unsafe import atr.paths as paths import atr.storage as storage import atr.template as template @@ -30,11 +33,18 @@ import atr.util as util import atr.web as web [email protected]("/report/<project_name>/<version_name>/<path:rel_path>") -async def selected_path(session: web.Committer, project_name: str, version_name: str, rel_path: str) -> str: - """Show the report for a specific file.""" - await session.check_access(project_name) - [email protected] +async def selected_path( + session: web.Committer, + _report: Literal["report"], + project_name: safe.ProjectName, + version_name: safe.VersionName, + rel_path: unsafe.Path, +) -> str: + """ + URL: /report/<project_name>/<version_name>/<rel_path> + Show the report for a specific file. + """ validated_path = form.to_relpath(rel_path) if validated_path is None: raise base.ASFQuartException("Invalid file path", errorcode=400) @@ -42,12 +52,16 @@ async def selected_path(session: web.Committer, project_name: str, version_name: # If the draft is not found, we try to get the release candidate try: release = await session.release( - project_name, version_name, with_committee=True, with_release_policy=True, with_project_release_policy=True + str(project_name), + str(version_name), + with_committee=True, + with_release_policy=True, + with_project_release_policy=True, ) except base.ASFQuartException: release = await session.release( - project_name, - version_name, + str(project_name), + str(version_name), phase=sql.ReleasePhase.RELEASE_CANDIDATE, with_committee=True, with_release_policy=True, @@ -82,8 +96,8 @@ async def selected_path(session: web.Committer, project_name: str, version_name: return await template.render( "report-selected-path.html", - project_name=project_name, - version_name=version_name, + project_name=str(project_name), + version_name=str(version_name), rel_path=str(validated_path), package=file_data, release=release, diff --git a/atr/get/result.py b/atr/get/result.py index 5fccc29c..607987bd 100644 --- a/atr/get/result.py +++ b/atr/get/result.py @@ -16,34 +16,39 @@ # under the License. import json +from typing import Literal import asfquart.base as base import atr.blueprints.get as get import atr.db as db +import atr.models.safe as safe import atr.models.sql as sql import atr.web as web [email protected]("/result/data/<project_name>/<version_name>/<int:check_id>") [email protected] async def data( session: web.Committer, - project_name: str, - version_name: str, + _result_data: Literal["result/data"], + project_name: safe.ProjectName, + version_name: safe.VersionName, check_id: int, ) -> web.TextResponse: - """Show a check result as formatted JSON.""" + """ + URL: /result/data/<project_name>/<version_name>/<check_id> + Show a check result as formatted JSON. + """ async with db.session() as data: release = await data.release( - project_name=project_name, - version=version_name, + project_name=str(project_name), + version=str(version_name), phase=sql.ReleasePhase.RELEASE_CANDIDATE, _committee=True, ).get() if release is None: - await session.check_access(project_name) - release = await session.release(project_name, version_name, with_committee=True) + release = await session.release(str(project_name), str(version_name), with_committee=True) if release.committee is None: raise base.ASFQuartException("Release has no committee", errorcode=500) diff --git a/atr/get/revisions.py b/atr/get/revisions.py index 680227a1..be0d7b02 100644 --- a/atr/get/revisions.py +++ b/atr/get/revisions.py @@ -17,6 +17,7 @@ import asyncio import pathlib +from typing import Literal import aiofiles.os import asfquart.base as base @@ -31,6 +32,7 @@ import atr.get.compose as compose import atr.get.finish as finish import atr.get.root as root import atr.htm as htm +import atr.models.safe as safe import atr.models.schema as schema import atr.models.sql as sql import atr.paths as paths @@ -47,16 +49,22 @@ class FilesDiff(schema.Strict): modified: list[pathlib.Path] [email protected]("/revisions/<project_name>/<version_name>") -async def selected(session: web.Committer, project_name: str, version_name: str) -> str: - """Show the revision history for a release candidate draft or release preview.""" - await session.check_access(project_name) - [email protected] +async def selected( + session: web.Committer, + _revisions: Literal["revisions"], + project_name: safe.ProjectName, + version_name: safe.VersionName, +) -> str: + """ + URL: /revisions/<project_name>/<version_name> + Show the revision history for a release candidate draft or release preview. + """ try: - release = await session.release(project_name, version_name) + release = await session.release(str(project_name), str(version_name)) phase_key = "draft" except base.ASFQuartException: - release = await session.release(project_name, version_name, phase=sql.ReleasePhase.RELEASE_PREVIEW) + release = await session.release(str(project_name), str(version_name), phase=sql.ReleasePhase.RELEASE_PREVIEW) phase_key = "preview" release_dir = paths.release_directory_base(release) @@ -96,8 +104,8 @@ async def selected(session: web.Committer, project_name: str, version_name: str) phase_key, list(reversed(revision_history)), latest_revision_number, - project_name, - version_name, + str(project_name), + str(version_name), ) return await template.blank( diff --git a/atr/get/root.py b/atr/get/root.py index 808680f6..38b85352 100644 --- a/atr/get/root.py +++ b/atr/get/root.py @@ -16,7 +16,7 @@ # under the License. import pathlib -from typing import Final +from typing import Final, Literal import aiofiles import asfquart.session @@ -58,15 +58,21 @@ _POLICIES: Final = htm.div[ ] [email protected]("/about") -async def about(session: web.Committer) -> str: - """About page.""" [email protected] +async def about(session: web.Committer, _about: Literal["about"]) -> str: + """ + URL: /about + About page. + """ return await template.render("about.html") [email protected]("/") -async def index(session: web.Committer | None) -> quart_response.Response | str: - """Show public info or an entry portal for participants.""" [email protected] +async def index(session: web.Public, _root: Literal[""]) -> quart_response.Response | str: + """ + URL: / + Show public info or an entry portal for participants. + """ session_data = await asfquart.session.read() if session_data: uid = session_data.uid @@ -139,20 +145,31 @@ async def index(session: web.Committer | None) -> quart_response.Response | str: return await template.render("index-public.html") [email protected]("/policies") -async def policies(session: web.Committer | None) -> str: [email protected] +async def policies(session: web.Public, _policies: Literal["policies"]) -> str: + """ + URL: /policies + """ return await template.blank("Policies", content=_POLICIES) [email protected]("/miscellaneous/resolved.json") -async def resolved_json(session: web.Committer | None) -> quart_response.Response: [email protected] +async def resolved_json( + session: web.Public, _miscellaneous_resolved_json: Literal["miscellaneous/resolved.json"] +) -> quart_response.Response: + """ + URL: /miscellaneous/resolved.json + """ json_path = pathlib.Path(config.get().PROJECT_ROOT) / "atr" / "static" / "json" / "resolved.json" async with aiofiles.open(json_path) as f: content = await f.read() return quart_response.Response(content, mimetype="application/json") [email protected]("/tutorial") -async def tutorial(session: web.Committer) -> str: - """Tutorial page.""" [email protected] +async def tutorial(session: web.Committer, _tutorial: Literal["tutorial"]) -> str: + """ + URL: /tutorial + Tutorial page. + """ return await template.render("tutorial.html") diff --git a/atr/get/sbom.py b/atr/get/sbom.py index 9a16a479..277a6c46 100644 --- a/atr/get/sbom.py +++ b/atr/get/sbom.py @@ -31,7 +31,9 @@ import atr.get.compose as compose import atr.get.vote as vote import atr.htm as htm import atr.models.results as results +import atr.models.safe as safe import atr.models.sql as sql +import atr.models.unsafe as unsafe import atr.render as render import atr.sbom as sbom import atr.shared as shared @@ -43,15 +45,24 @@ if TYPE_CHECKING: from collections.abc import Sequence [email protected]("/sbom/report/<project>/<version>/<path:file_path>") -async def report(session: web.Committer, project: str, version: str, file_path: str) -> str: - await session.check_access(project) - [email protected] +async def report( + session: web.Committer, + _sbom_report: Literal["sbom/report"], + project_name: safe.ProjectName, + version_name: safe.VersionName, + file_path: unsafe.Path, +) -> str: + """ + URL: /sbom/report/<project_name>/<version_name>/<file_path> + """ # If the draft is not found, we try to get the release candidate try: - release = await session.release(project, version, with_committee=True) + release = await session.release(str(project_name), str(version_name), with_committee=True) except base.ASFQuartException: - release = await session.release(project, version, phase=sql.ReleasePhase.RELEASE_CANDIDATE, with_committee=True) + release = await session.release( + str(project_name), str(version_name), phase=sql.ReleasePhase.RELEASE_CANDIDATE, with_committee=True + ) block = htm.Block() @@ -84,7 +95,9 @@ async def report(session: web.Committer, project: str, version: str, file_path: raise base.ASFQuartException("Invalid file path", errorcode=400) validated_path_str = str(validated_path) - task, augment_tasks, osv_tasks = await _fetch_tasks(validated_path_str, project, release, version) + task, augment_tasks, osv_tasks = await _fetch_tasks( + validated_path_str, str(project_name), release, str(version_name) + ) task_status = await _report_task_results(block, task) if task_status: @@ -112,7 +125,9 @@ async def report(session: web.Committer, project: str, version: str, file_path: _conformance_section(block, task_result) _license_section(block, task_result) - _vulnerability_scan_section(block, project, version, file_path, task_result, osv_tasks, is_release_candidate) + _vulnerability_scan_section( + block, str(project_name), str(version_name), str(file_path), task_result, osv_tasks, is_release_candidate + ) _outdated_tool_section(block, task_result) diff --git a/atr/get/start.py b/atr/get/start.py index 25c5ffc9..b82aa0d7 100644 --- a/atr/get/start.py +++ b/atr/get/start.py @@ -15,6 +15,8 @@ # specific language governing permissions and limitations # under the License. +from typing import Literal + import asfquart.base as base import htpy @@ -24,6 +26,7 @@ import atr.db.interaction as interaction import atr.form as form import atr.get.root as root import atr.htm as htm +import atr.models.safe as safe import atr.models.sql as sql import atr.shared as shared import atr.template as template @@ -31,12 +34,13 @@ import atr.util as util import atr.web as web [email protected]("/start/<project_name>") -async def selected(session: web.Committer, project_name: str) -> str: - await session.check_access(project_name) - [email protected] +async def selected(session: web.Committer, _start: Literal["start"], project_name: safe.ProjectName) -> str: + """ + URL: /start/<project_name> + """ async with db.session() as data: - project = await data.project(name=project_name, status=sql.ProjectStatus.ACTIVE).demand( + project = await data.project(name=str(project_name), status=sql.ProjectStatus.ACTIVE).demand( base.ASFQuartException(f"Project {project_name} not found", errorcode=404) ) diff --git a/atr/get/test.py b/atr/get/test.py index ad70cd64..3a6d2368 100644 --- a/atr/get/test.py +++ b/atr/get/test.py @@ -17,6 +17,7 @@ import json import pathlib +from typing import Literal import aiofiles import asfquart.base as base @@ -29,6 +30,7 @@ import atr.form as form import atr.get.root as root import atr.get.vote as vote import atr.htm as htm +import atr.models.safe as safe import atr.models.session import atr.models.sql as sql import atr.paths as paths @@ -39,8 +41,11 @@ import atr.util as util import atr.web as web [email protected]("/test/empty") -async def test_empty(session: web.Committer | None) -> str: [email protected] +async def test_empty(session: web.Public, _test_empty: Literal["test/empty"]) -> str: + """ + URL: /test/empty + """ empty_form = form.render( model_cls=form.Empty, submit_label="Submit empty form", @@ -56,8 +61,11 @@ async def test_empty(session: web.Committer | None) -> str: return await template.blank(title="Test empty form", content=forms_html) [email protected]("/test/login") -async def test_login(session: web.Committer | None) -> web.WerkzeugResponse: [email protected] +async def test_login(session: web.Public, _test_login: Literal["test/login"]) -> web.WerkzeugResponse: + """ + URL: /test/login + """ if not config.get().ALLOW_TESTS: raise base.ASFQuartException("Test login not enabled", errorcode=404) @@ -76,36 +84,44 @@ async def test_login(session: web.Committer | None) -> web.WerkzeugResponse: return await web.redirect(root.index) [email protected]("/test/merge/<project_name>/<version_name>") -async def test_merge(session: web.Committer, project_name: str, version_name: str) -> web.WerkzeugResponse: [email protected] +async def test_merge( + session: web.Committer, + _test_merge: Literal["test/merge"], + project_name: safe.ProjectName, + version_name: safe.VersionName, +) -> web.WerkzeugResponse: + """ + URL: /test/merge/<project_name>/<version_name> + """ if not config.get().ALLOW_TESTS: raise base.ASFQuartException("Test routes not enabled", errorcode=404) async with storage.write(session) as write_n: - wacp_n = await write_n.as_project_committee_participant(project_name) + wacp_n = await write_n.as_project_committee_participant(str(project_name)) async def modify_new(path_new: pathlib.Path, _old_rev_new: sql.Revision | None) -> None: async with aiofiles.open(path_new / "from_new.txt", "w") as f: await f.write("new content") async with storage.write(session) as write_p: - wacp_p = await write_p.as_project_committee_participant(project_name) + wacp_p = await write_p.as_project_committee_participant(str(project_name)) async def modify_prior(path_prior: pathlib.Path, _old_rev_prior: sql.Revision | None) -> None: async with aiofiles.open(path_prior / "from_prior.txt", "w") as f: await f.write("prior content") await wacp_p.revision.create_revision( - project_name, - version_name, + str(project_name), + str(version_name), session.uid, description="Test merge: prior revision", modify=modify_prior, ) await wacp_n.revision.create_revision( - project_name, - version_name, + str(project_name), + str(version_name), session.uid, description="Test merge: new revision", modify=modify_new, @@ -113,7 +129,7 @@ async def test_merge(session: web.Committer, project_name: str, version_name: st files: list[str] = [] async with db.session() as data: - release_name = sql.release_name(project_name, version_name) + release_name = sql.release_name(str(project_name), str(version_name)) release = await data.release(name=release_name, _project=True).demand( RuntimeError("Release not found after merge test") ) @@ -125,8 +141,11 @@ async def test_merge(session: web.Committer, project_name: str, version_name: st return response.Response(result, status=200, mimetype="application/json") [email protected]("/test/multiple") -async def test_multiple(session: web.Committer | None) -> str: [email protected] +async def test_multiple(session: web.Public, _test_multiple: Literal["test/multiple"]) -> str: + """ + URL: /test/multiple + """ apple_form = form.render( model_cls=shared.test.AppleForm, submit_label="Order apples", @@ -149,8 +168,11 @@ async def test_multiple(session: web.Committer | None) -> str: return await template.blank(title="Test multiple forms", content=forms_html) [email protected]("/test/single") -async def test_single(session: web.Committer | None) -> str: [email protected] +async def test_single(session: web.Public, _test_single: Literal["test/single"]) -> str: + """ + URL: /test/single + """ import htpy vote_widget = htpy.div(class_="btn-group", role="group")[ @@ -177,8 +199,17 @@ async def test_single(session: web.Committer | None) -> str: return await template.blank(title="Test single form", content=forms_html) [email protected]("/test/vote/<category>/<project_name>/<version_name>") -async def test_vote(session: web.Committer | None, category: str, project_name: str, version_name: str) -> str: [email protected] +async def test_vote( + session: web.Public, + _test_vote: Literal["test/vote"], + category: str, + project_name: safe.ProjectName, + version_name: safe.VersionName, +) -> str: + """ + URL: /test/vote/<category>/<project_name>/<version_name> + """ if not config.get().ALLOW_TESTS: raise base.ASFQuartException("Test routes not enabled", errorcode=404) @@ -200,7 +231,7 @@ async def test_vote(session: web.Committer | None, category: str, project_name: if (user_category != vote.UserCategory.UNAUTHENTICATED) and (session is None): raise base.ASFQuartException("You must be logged in to preview authenticated views", errorcode=401) - _, release, latest_vote_task = await vote.category_and_release(session, project_name, version_name) + _, release, latest_vote_task = await vote.category_and_release(session, str(project_name), str(version_name)) if release.phase != sql.ReleasePhase.RELEASE_CANDIDATE: raise base.ASFQuartException("Release is not a candidate", errorcode=404) diff --git a/atr/get/tokens.py b/atr/get/tokens.py index 0f647322..388b517e 100644 --- a/atr/get/tokens.py +++ b/atr/get/tokens.py @@ -15,6 +15,8 @@ # specific language governing permissions and limitations # under the License. +from typing import Literal + import atr.blueprints.get as get import atr.form as form import atr.htm as htm @@ -27,8 +29,11 @@ import atr.util as util import atr.web as web [email protected]("/tokens") -async def tokens(session: web.Committer) -> str: [email protected] +async def tokens(session: web.Committer, _tokens: Literal["tokens"]) -> str: + """ + URL: /tokens + """ async with storage.read_as_foundation_committer() as rafc: tokens_list = await rafc.tokens.own_personal_access_tokens() most_recent_pat = await rafc.tokens.most_recent_jwt_pat() diff --git a/atr/get/upload.py b/atr/get/upload.py index 5e57258d..c4b36454 100644 --- a/atr/get/upload.py +++ b/atr/get/upload.py @@ -17,6 +17,7 @@ import secrets from collections.abc import Sequence +from typing import Literal import htpy @@ -26,6 +27,7 @@ import atr.form as form import atr.get.compose as compose import atr.get.keys as keys import atr.htm as htm +import atr.models.safe as safe import atr.models.sql as sql import atr.post as post import atr.render as render @@ -35,12 +37,18 @@ import atr.util as util import atr.web as web [email protected]("/upload/<project_name>/<version_name>") -async def selected(session: web.Committer, project_name: str, version_name: str) -> str: - await session.check_access(project_name) - [email protected] +async def selected( + session: web.Committer, + _upload: Literal["upload"], + project_name: safe.ProjectName, + version_name: safe.VersionName, +) -> str: + """ + URL: /upload/<project_name>/<version_name> + """ async with db.session() as data: - release = await session.release(project_name, version_name, data=data) + release = await session.release(str(project_name), str(version_name), data=data) user_ssh_keys = await data.ssh_key(asf_uid=session.uid).all() block = htm.Block() @@ -70,10 +78,16 @@ async def selected(session: web.Committer, project_name: str, version_name: str) upload_session_token = secrets.token_hex(16) stage_url = util.as_url( - post.upload.stage, upload_session=upload_session_token, project_name=project_name, version_name=version_name + post.upload.stage, + upload_session=upload_session_token, + project_name=str(project_name), + version_name=str(version_name), ) finalise_url = util.as_url( - post.upload.finalise, upload_session=upload_session_token, project_name=project_name, version_name=version_name + post.upload.finalise, + upload_session=upload_session_token, + project_name=str(project_name), + version_name=str(version_name), ) block.append( diff --git a/atr/get/user.py b/atr/get/user.py index a28f6aa6..2f3f3541 100644 --- a/atr/get/user.py +++ b/atr/get/user.py @@ -15,6 +15,8 @@ # specific language governing permissions and limitations # under the License. +from typing import Literal + import atr.blueprints.get as get import atr.form as form import atr.htm as htm @@ -24,8 +26,11 @@ import atr.util as util import atr.web as web [email protected]("/user/cache") -async def cache_get(session: web.Committer) -> str: [email protected] +async def cache_get(session: web.Committer, _user_cache: Literal["user/cache"]) -> str: + """ + URL: /user/cache + """ cache_data = await util.session_cache_read() user_cached = session.uid in cache_data diff --git a/atr/get/vote.py b/atr/get/vote.py index 4fa1df28..22890514 100644 --- a/atr/get/vote.py +++ b/atr/get/vote.py @@ -19,7 +19,7 @@ from __future__ import annotations import enum import urllib.parse -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal import asfquart.base as base import htpy @@ -35,6 +35,7 @@ import atr.get.keys as keys import atr.get.root as root import atr.htm as htm import atr.mapping as mapping +import atr.models.safe as safe import atr.models.sql as sql import atr.post as post import atr.render as render @@ -171,10 +172,18 @@ async def render_vote_closed_page(release: sql.Release) -> str: ) [email protected]("/vote/<project_name>/<version_name>") -async def selected(session: web.Committer | None, project_name: str, version_name: str) -> web.WerkzeugResponse | str: - """Show voting options for a release candidate.""" - user_category, release, latest_vote_task = await category_and_release(session, project_name, version_name) [email protected] +async def selected( + session: web.Public, + _vote: Literal["vote"], + project_name: safe.ProjectName, + version_name: safe.VersionName, +) -> web.WerkzeugResponse | str: + """ + URL: /vote/<project_name>/<version_name> + Show voting options for a release candidate. + """ + user_category, release, latest_vote_task = await category_and_release(session, str(project_name), str(version_name)) if release.phase != sql.ReleasePhase.RELEASE_CANDIDATE: if session is None: diff --git a/atr/get/voting.py b/atr/get/voting.py index 665cff66..c678fbc6 100644 --- a/atr/get/voting.py +++ b/atr/get/voting.py @@ -16,6 +16,8 @@ # under the License. +from typing import Literal + import aiofiles.os import htpy @@ -28,6 +30,7 @@ import atr.get.compose as compose import atr.get.keys as keys import atr.get.projects as projects import atr.htm as htm +import atr.models.safe as safe import atr.models.sql as sql import atr.paths as paths import atr.post as post @@ -38,22 +41,27 @@ import atr.util as util import atr.web as web [email protected]("/voting/<project_name>/<version_name>/<revision>") [email protected] async def selected_revision( - session: web.Committer, project_name: str, version_name: str, revision: str + session: web.Committer, + _voting: Literal["voting"], + project_name: safe.ProjectName, + version_name: safe.VersionName, + revision: str, ) -> 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( 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): @@ -65,14 +73,14 @@ async def selected_revision( if release.release_policy and (release.release_policy.min_hours is not None): min_hours = release.release_policy.min_hours - 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)) subject_template_hash = construct.template_hash(default_subject_template) options = construct.StartVoteOptions( asfuid=session.uid, fullname=session.fullname, - project_name=project_name, + project_name=str(project_name), version_name=release.version, revision_number=revision, vote_duration=min_hours, @@ -118,7 +126,7 @@ def _render_body_field(default_body: str, project_name: str) -> htm.Element: rows="12", )[default_body] - settings_url = util.as_url(projects.view, name=project_name) + "#start_vote_template" + settings_url = util.as_url(projects.view, project_name=project_name) + "#start_vote_template" link = htm.div(".form-text.text-muted.mt-2")[ "To edit the template, go to the ", htm.a(href=settings_url)["project settings"], @@ -213,7 +221,7 @@ async def _render_page( def _render_subject_field(default_subject: str, project_name: str) -> htm.Element: - settings_url = util.as_url(projects.view, name=project_name) + "#start_vote_subject" + settings_url = util.as_url(projects.view, project_name=project_name) + "#start_vote_subject" return htm.div[ htpy.input( type="text", diff --git a/atr/models/safe.py b/atr/models/safe.py new file mode 100644 index 00000000..c6f9893b --- /dev/null +++ b/atr/models/safe.py @@ -0,0 +1,62 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + + +class ProjectName: + """A project name that has been validated against the cache or database.""" + + __slots__ = ("_value",) + + def __init__(self, value: str) -> None: + self._value = value + + def __eq__(self, other: object) -> bool: + if isinstance(other, ProjectName): + return self._value == other._value + return NotImplemented + + def __hash__(self) -> int: + return hash(self._value) + + def __repr__(self) -> str: + return f"ProjectName({self._value!r})" + + def __str__(self) -> str: + return self._value + + +class VersionName: + """A version name that has been validated against the cache or database.""" + + __slots__ = ("_value",) + + def __init__(self, value: str) -> None: + self._value = value + + def __eq__(self, other: object) -> bool: + if isinstance(other, VersionName): + return self._value == other._value + return NotImplemented + + def __hash__(self) -> int: + return hash(self._value) + + def __repr__(self) -> str: + return f"VersionName({self._value!r})" + + def __str__(self) -> str: + return self._value diff --git a/atr/models/unsafe.py b/atr/models/unsafe.py new file mode 100644 index 00000000..6e38d4e4 --- /dev/null +++ b/atr/models/unsafe.py @@ -0,0 +1,32 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from typing import NewType + + +class UnsafeStr: + """A raw string from URL routing that has not been validated.""" + + __slots__ = ("_value",) + + def __init__(self, value: str) -> None: + self._value = value + + def __repr__(self) -> str: + return f"UnsafeStr({self._value!r})" + + +Path = NewType("Path", str) diff --git a/atr/post/projects.py b/atr/post/projects.py index 2997b67c..b3007626 100644 --- a/atr/post/projects.py +++ b/atr/post/projects.py @@ -47,7 +47,7 @@ async def add_project( ) return await session.redirect( - get.projects.view, name=label, success=f"Project '{display_name}' added successfully." + get.projects.view, project_name=label, success=f"Project '{display_name}' added successfully." ) @@ -158,10 +158,10 @@ async def _process_add_category( if modified: return await session.redirect( - get.projects.view, name=project_name, success=f"Category '{category_to_add}' added." + get.projects.view, project_name=project_name, success=f"Category '{category_to_add}' added." ) return await session.redirect( - get.projects.view, name=project_name, error=f"Category '{category_to_add}' already exists." + get.projects.view, project_name=project_name, error=f"Category '{category_to_add}' already exists." ) @@ -181,10 +181,10 @@ async def _process_add_language( if modified: return await session.redirect( - get.projects.view, name=project_name, success=f"Language '{language_to_add}' added." + get.projects.view, project_name=project_name, success=f"Language '{language_to_add}' added." ) return await session.redirect( - get.projects.view, name=project_name, error=f"Language '{language_to_add}' already exists." + get.projects.view, project_name=project_name, error=f"Language '{language_to_add}' already exists." ) @@ -199,10 +199,12 @@ async def _process_compose_form( await wacm.policy.edit_compose(compose_form) except storage.AccessError as e: return await session.redirect( - get.projects.view, name=project_name, error=f"Error editing compose policy: {e}" + get.projects.view, project_name=project_name, error=f"Error editing compose policy: {e}" ) - return await session.redirect(get.projects.view, name=project_name, success="Compose options saved successfully.") + return await session.redirect( + get.projects.view, project_name=project_name, success="Compose options saved successfully." + ) async def _process_delete_project( @@ -231,10 +233,12 @@ async def _process_finish_form( await wacm.policy.edit_finish(finish_form) except storage.AccessError as e: return await session.redirect( - get.projects.view, name=project_name, error=f"Error editing finish policy: {e}" + get.projects.view, project_name=project_name, error=f"Error editing finish policy: {e}" ) - return await session.redirect(get.projects.view, name=project_name, success="Finish options saved successfully.") + return await session.redirect( + get.projects.view, project_name=project_name, success="Finish options saved successfully." + ) async def _process_remove_category( @@ -253,10 +257,10 @@ async def _process_remove_category( if modified: return await session.redirect( - get.projects.view, name=project_name, success=f"Category '{category_to_remove}' removed." + get.projects.view, project_name=project_name, success=f"Category '{category_to_remove}' removed." ) return await session.redirect( - get.projects.view, name=project_name, error=f"Category '{category_to_remove}' does not exist." + get.projects.view, project_name=project_name, error=f"Category '{category_to_remove}' does not exist." ) @@ -276,10 +280,10 @@ async def _process_remove_language( if modified: return await session.redirect( - get.projects.view, name=project_name, success=f"Language '{language_to_remove}' removed." + get.projects.view, project_name=project_name, success=f"Language '{language_to_remove}' removed." ) return await session.redirect( - get.projects.view, name=project_name, error=f"Language '{language_to_remove}' does not exist." + get.projects.view, project_name=project_name, error=f"Language '{language_to_remove}' does not exist." ) @@ -291,6 +295,10 @@ async def _process_vote_form(session: web.Committer, vote_form: shared.projects. try: await wacm.policy.edit_vote(vote_form) except storage.AccessError as e: - return await session.redirect(get.projects.view, name=project_name, error=f"Error editing vote policy: {e}") + return await session.redirect( + get.projects.view, project_name=project_name, error=f"Error editing vote policy: {e}" + ) - return await session.redirect(get.projects.view, name=project_name, success="Vote options saved successfully.") + return await session.redirect( + get.projects.view, project_name=project_name, success="Vote options saved successfully." + ) diff --git a/atr/post/sbom.py b/atr/post/sbom.py index 09c779e3..564f0537 100644 --- a/atr/post/sbom.py +++ b/atr/post/sbom.py @@ -85,16 +85,16 @@ async def _augment( await quart.flash(f"Error augmenting SBOM: {e!s}", "error") return await session.redirect( get.sbom.report, - project=project_name, - version=version_name, + project_name=project_name, + version_name=version_name, file_path=str(rel_path), ) return await session.redirect( get.sbom.report, success=f"SBOM augmentation task queued for {rel_path.name} (task ID: {util.unwrap(sbom_task.id)})", - project=project_name, - version=version_name, + project_name=project_name, + version_name=version_name, file_path=str(rel_path), ) @@ -128,15 +128,15 @@ async def _scan( await quart.flash(f"Error starting OSV scan: {e!s}", "error") return await session.redirect( get.sbom.report, - project=project_name, - version=version_name, + project_name=project_name, + version_name=version_name, file_path=str(rel_path), ) return await session.redirect( get.sbom.report, success=f"OSV vulnerability scan queued for {rel_path.name} (task ID: {util.unwrap(sbom_task.id)})", - project=project_name, - version=version_name, + project_name=project_name, + version_name=version_name, file_path=str(rel_path), ) diff --git a/atr/post/test.py b/atr/post/test.py index b64a9486..b2945df6 100644 --- a/atr/post/test.py +++ b/atr/post/test.py @@ -26,7 +26,7 @@ import atr.web as web @post.public("/test/empty") @post.empty() -async def test_empty(session: web.Committer | None) -> web.WerkzeugResponse: +async def test_empty(session: web.Public) -> web.WerkzeugResponse: msg = "Empty form submitted successfully" log.info(msg) await quart.flash(msg, "success") @@ -35,7 +35,7 @@ async def test_empty(session: web.Committer | None) -> web.WerkzeugResponse: @post.public("/test/multiple") @post.form(shared.test.MultipleForm) -async def test_multiple(session: web.Committer | None, form: shared.test.MultipleForm) -> web.WerkzeugResponse: +async def test_multiple(session: web.Public, form: shared.test.MultipleForm) -> web.WerkzeugResponse: match form: case shared.test.AppleForm() as apple: msg = f"Apple order received: variety={apple.variety}, quantity={apple.quantity}, organic={apple.organic}" @@ -52,7 +52,7 @@ async def test_multiple(session: web.Committer | None, form: shared.test.Multipl @post.public("/test/single") @post.form(shared.test.SingleForm) -async def test_single(session: web.Committer | None, form: shared.test.SingleForm) -> web.WerkzeugResponse: +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): diff --git a/atr/server.py b/atr/server.py index 03927a78..da736e3b 100644 --- a/atr/server.py +++ b/atr/server.py @@ -277,6 +277,10 @@ def _app_setup_lifecycle(app: base.QuartApp, app_config: type[config.AppConfig]) admins_task = asyncio.create_task(cache.admins_refresh_loop()) app.extensions["admins_task"] = admins_task + await cache.project_version_startup_load() + project_version_task = asyncio.create_task(cache.project_version_refresh_loop()) + app.extensions["project_version_task"] = project_version_task + worker_manager = manager.get_worker_manager() await worker_manager.start() diff --git a/atr/shared/web.py b/atr/shared/web.py index 12a3a411..eb8340c7 100644 --- a/atr/shared/web.py +++ b/atr/shared/web.py @@ -140,7 +140,7 @@ async def check( is_local_caching = release.check_cache_key is not None - checks_summary_html = _render_checks_summary(info, release.project.name, release.version) + checks_summary_html = render_checks_summary(info, release.project.name, release.version) return await template.render( "check-selected.html", @@ -180,11 +180,7 @@ async def check( ) -def _checker_display_name(checker: str) -> str: - return checker.removeprefix("atr.tasks.checks.").replace("_", " ").replace(".", " ").title() - - -def _render_checks_summary(info: types.PathInfo | None, project_name: str, version_name: str) -> htm.Element | None: +def render_checks_summary(info: types.PathInfo | None, project_name: str, version_name: str) -> htm.Element | None: if (info is None) or (not info.checker_stats): return None @@ -210,7 +206,7 @@ def _render_checks_summary(info: types.PathInfo | None, project_name: str, versi files_div = htm.Block(htm.div, classes=".mt-2.atr-checks-files") all_files = set(stat.failure_files.keys()) | set(stat.warning_files.keys()) | set(stat.blocker_files.keys()) for file_path in sorted(all_files): - report_url = f"/report/{project_name}/{version_name}/{file_path}" + report_url = f"/report/{project_name!s}/{version_name!s}/{file_path}" error_count = stat.failure_files.get(file_path, 0) blocker_count = stat.blocker_files.get(file_path, 0) warning_count = stat.warning_files.get(file_path, 0) @@ -235,6 +231,10 @@ def _render_checks_summary(info: types.PathInfo | None, project_name: str, versi return card.collect() +def _checker_display_name(checker: str) -> str: + return checker.removeprefix("atr.tasks.checks.").replace("_", " ").replace(".", " ").title() + + def _warnings_from_vote_result(vote_task: sql.Task | None) -> list[str]: # TODO: Replace this with a schema.Strict model # But we'd still need to do some of this parsing and validation diff --git a/atr/templates/check-selected-path-table.html b/atr/templates/check-selected-path-table.html index eb01eb71..d43adae6 100644 --- a/atr/templates/check-selected-path-table.html +++ b/atr/templates/check-selected-path-table.html @@ -70,7 +70,7 @@ </form> {% endif %} {% if path.suffixes[-2:] == [".cdx", ".json"] %} - <a href="{{ as_url(get.sbom.report, project=project_name, version=version_name, file_path=path) }}" + <a href="{{ as_url(get.sbom.report, project_name=project_name, version_name=version_name, file_path=path) }}" class="btn btn-sm btn-outline-secondary">SBOM report</a> {% endif %} {% if has_blockers %} diff --git a/atr/templates/check-selected-release-info.html b/atr/templates/check-selected-release-info.html index 293df9ed..3c602ff6 100644 --- a/atr/templates/check-selected-release-info.html +++ b/atr/templates/check-selected-release-info.html @@ -7,7 +7,7 @@ <div class="col-md-6"> <p> <strong>Project:</strong> - <a href="{{ as_url(get.projects.view, name=release.project.name) }}">{{ release.project.display_name }}</a> + <a href="{{ as_url(get.projects.view, project_name=release.project.name) }}">{{ release.project.display_name }}</a> </p> <p> <strong>Label:</strong> {{ release.name }} diff --git a/atr/templates/check-selected.html b/atr/templates/check-selected.html index 7e027018..c41e91fe 100644 --- a/atr/templates/check-selected.html +++ b/atr/templates/check-selected.html @@ -130,9 +130,9 @@ </div> <p> <a class="btn btn-primary" - href="{{ as_url(get.distribution.stage_automate, project=release.project.name, version=release.version) }}">Distribute</a> + href="{{ as_url(get.distribution.stage_automate, project_name=release.project.name, version_name=release.version) }}">Distribute</a> <a class="btn btn-secondary" - href="{{ as_url(get.distribution.stage_record, project=release.project.name, version=release.version) }}">Record a manual distribution</a> + href="{{ as_url(get.distribution.stage_record, project_name=release.project.name, version_name=release.version) }}">Record a manual distribution</a> </p> <h2 id="more-actions">More actions</h2> <h3 id="ignored-checks" class="mt-4">Ignored checks</h3> diff --git a/atr/templates/committee-directory.html b/atr/templates/committee-directory.html index cf9e47ef..3eedfe0c 100644 --- a/atr/templates/committee-directory.html +++ b/atr/templates/committee-directory.html @@ -116,11 +116,11 @@ {% if committee.projects %} {% for project in committee.projects|sort(attribute="name") %} <div class="card mb-3 shadow-sm page-project-subcard {% if loop.index > max_initial_projects %}page-project-extra d-none{% endif %} {% if project.status.value.lower() != "active" %}page-project-inactive{% endif %}" - data-project-url="{{ as_url(get.projects.view, name=project.name) }}"> + data-project-url="{{ as_url(get.projects.view, project_name=project.name) }}"> <div class="card-body p-3 d-flex flex-column h-100"> <div class="d-flex justify-content-between align-items-start"> <p class="mb-1 me-2 fs-6"> - <a href="{{ as_url(get.projects.view, name=project.name) }}" + <a href="{{ as_url(get.projects.view, project_name=project.name) }}" class="text-decoration-none stretched-link">{{ project.display_name }}</a> </p> <div> diff --git a/atr/templates/committee-view.html b/atr/templates/committee-view.html index c7fd3bbb..6ce1f66d 100644 --- a/atr/templates/committee-view.html +++ b/atr/templates/committee-view.html @@ -29,7 +29,7 @@ <ul> {% for project in projects %} <li> - <a href="{{ as_url(get.projects.view, name=project.name) }}">{{ project.display_name }}</a> + <a href="{{ as_url(get.projects.view, project_name=project.name) }}">{{ project.display_name }}</a> </li> {% endfor %} </ul> diff --git a/atr/templates/includes/topnav.html b/atr/templates/includes/topnav.html index 6689dc35..081c55a1 100644 --- a/atr/templates/includes/topnav.html +++ b/atr/templates/includes/topnav.html @@ -42,7 +42,7 @@ </li> <li> <a class="dropdown-item fw-bold" - href="{{ as_url(get.projects.view, name=project_name) }}">{{ project_short_display_name }}</a> + href="{{ as_url(get.projects.view, project_name=project_name) }}">{{ project_short_display_name }}</a> </li> {% for release in releases %} <li> diff --git a/atr/templates/index-committer.html b/atr/templates/index-committer.html index 021c98c9..ce5f428c 100644 --- a/atr/templates/index-committer.html +++ b/atr/templates/index-committer.html @@ -87,7 +87,7 @@ </h2> <p class="mb-3"> - <a href="{{ as_url(get.projects.view, name=project.name) }}" + <a href="{{ as_url(get.projects.view, project_name=project.name) }}" class="text-decoration-none me-2">About this project</a> {% if project.committee.name in current_user.committees %} <span class="text-muted me-2">/</span> diff --git a/atr/templates/projects.html b/atr/templates/projects.html index dc010218..4d30e8cf 100644 --- a/atr/templates/projects.html +++ b/atr/templates/projects.html @@ -37,12 +37,12 @@ {% endif %} <div class="col"> <div class="card h-100 shadow-sm position-relative page-project-card {{ '' if project.status.value.lower() == 'active' else 'bg-body-secondary' }}" - data-project-url="{{ as_url(get.projects.view, name=project.name) }}" + data-project-url="{{ as_url(get.projects.view, project_name=project.name) }}" data-is-participant="{{ 'true' if is_part else 'false' }}"> <div class="card-body"> <div class="row g-1"> <div class="col-sm"> - <h3 class="card-title fs-4 mb-3"><a href="{{ as_url(get.projects.view, name=project.name) }}" class="text-decoration-none text-body stretched-link">{{ project.display_name }}</a></h3> + <h3 class="card-title fs-4 mb-3"><a href="{{ as_url(get.projects.view, project_name=project.name) }}" class="text-decoration-none text-body stretched-link">{{ project.display_name }}</a></h3> </div> {% if project.status.value.lower() != 'active' %} <div class="col-sm-2"> diff --git a/atr/web.py b/atr/web.py index 4d7d8c9c..e7a999f5 100644 --- a/atr/web.py +++ b/atr/web.py @@ -42,6 +42,7 @@ if TYPE_CHECKING: import pydantic import werkzeug.wrappers.response as response + R = TypeVar("R", covariant=True) type WerkzeugResponse = response.Response @@ -87,7 +88,7 @@ class Committer: return user.is_admin(self.uid) async def check_access(self, project_name: str) -> None: - if not any((p.name == project_name) for p in (await self.user_projects)): + if not any((p.name == str(project_name)) for p in (await self.user_projects)): if self.is_admin: # Admins can view all projects # But we must warn them when the project is not one of their own @@ -180,7 +181,7 @@ class Committer: phase_value = sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT else: phase_value = phase - release_name = sql.release_name(project_name, version_name) + release_name = sql.release_name(str(project_name), str(version_name)) if data is None: async with db.session() as data: release = await data.release( @@ -256,6 +257,9 @@ class HeaderValue: return self.__value +type Public = Committer | None + + class RouteFunction(Protocol[R]): """Protocol for @app_route decorated functions.""" --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
