You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by ar...@apache.org on 2026/02/26 11:49:46 UTC
(tooling-trusted-releases) branch taint_tracking_types updated (f6e08562 -> 7f570656)
This is an automated email from the ASF dual-hosted git repository.
arm pushed a change to branch taint_tracking_types
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
discard f6e08562 Add taint tracking types to get endpoints
new 7f570656 Add taint tracking types to get endpoints
This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version. This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:
* -- * -- B -- O -- O -- O (f6e08562)
\
N -- N -- N refs/heads/taint_tracking_types (7f570656)
You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.
Any revisions marked "omit" are not gone; other references still
refer to them. Any revisions marked "discard" are gone forever.
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
atr/blueprints/get.py | 2 +-
atr/get/announce.py | 4 ++--
atr/get/checks.py | 4 +++-
atr/get/distribution.py | 4 ++--
atr/get/voting.py | 4 ++--
atr/post/projects.py | 38 +++++++++++++++++++++++---------------
6 files changed, 33 insertions(+), 23 deletions(-)
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org
(tooling-trusted-releases) 01/01: Add taint tracking types to get endpoints
Posted by ar...@apache.org.
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 7f5706566fe4048b139fbbf0a4758fb42ca40cac
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Wed Feb 25 11:56:34 2026 +0000
Add taint tracking types to get endpoints
---
atr/blueprints/common.py | 145 +++++++++++++++++++++++++
atr/blueprints/get.py | 64 ++++++++---
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/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 +-
50 files changed, 1174 insertions(+), 404 deletions(-)
diff --git a/atr/blueprints/common.py b/atr/blueprints/common.py
new file mode 100644
index 00000000..5337f375
--- /dev/null
+++ b/atr/blueprints/common.py
@@ -0,0 +1,145 @@
+# 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:
+ return await authenticate()
+
+
+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..7b38691c 100644
--- a/atr/blueprints/get.py
+++ b/atr/blueprints/get.py
@@ -25,7 +25,7 @@ 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.web as web
@@ -34,17 +34,55 @@ _BLUEPRINT = quart.Blueprint(_BLUEPRINT_NAME, __name__)
_routes: list[str] = []
+def typed(func: Callable[..., Any]) -> web.RouteFunction[Any]:
+ """Decorator that derives the URL path from the function's type annotations.
+
+ - Arguments after session are joined with / to make the web path
+ - Literal["..."] parameters become literal path segments
+ - safe.ProjectName / safe.VersionName parameters are validated via cache/DB
+ - int, float use Quart's built-in type converters
+ - str parameters pass through as-is
+ - check_access is called automatically for committer routes with project_name
+ """
+ path, validated_params, literal_params, public = common.build_path(func)
+ check_access = not public and any(name == "project_name" for name, _ in validated_params)
+
+ async def wrapper(*_args: Any, **kwargs: Any) -> Any:
+ enhanced_session = await common.authenticate_public() if public else await common.authenticate()
+ await common.run_validators(kwargs, validated_params)
+ kwargs.update(literal_params)
+
+ if check_access and enhanced_session is not None:
+ await enhanced_session.check_access(str(kwargs["project_name"]))
+
+ 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
+
+ log.performance(
+ f"GET {path} {func.__name__} = 0 0 {total_ms}",
+ )
+
+ return response
+
+ endpoint = func.__module__.replace(".", "_") + "_" + func.__name__
+ wrapper.__name__ = func.__name__
+ wrapper.__doc__ = func.__doc__
+ wrapper.__annotations__["endpoint"] = _BLUEPRINT_NAME + "." + endpoint
+
+ decorated = 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 decorated
+
+
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 common.authenticate()
start_time_ns = time.perf_counter_ns()
response = await func(enhanced_session, *args, **kwargs)
end_time_ns = time.perf_counter_ns()
@@ -65,9 +103,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=["GET"])
-
- module_name = func.__module__.split(".")[-1]
- _routes.append(f"get.{module_name}.{func.__name__}")
+ common.register_route(func, "get", _routes)
return decorated
@@ -87,9 +123,7 @@ def public(path: str) -> Callable[[Callable[..., Awaitable[Any]]], web.RouteFunc
wrapper.__annotations__["endpoint"] = _BLUEPRINT_NAME + "." + endpoint
_BLUEPRINT.add_url_rule(path, endpoint=endpoint, view_func=wrapper, methods=["GET"])
-
- module_name = func.__module__.split(".")[-1]
- _routes.append(f"get.{module_name}.{func.__name__}")
+ common.register_route(func, "get", _routes)
return wrapper
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
-@get.committer("/keys/add")
-async def add(session: web.Committer) -> str:
- """Add a new public signing key to the user's account."""
+@get.typed
+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
-@get.committer("/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)
+@get.typed
+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
-@get.public("/checklist/<project_name>/<version_name>")
-async def selected(session: web.Committer | None, project_name: str, version_name: str) -> str:
+@get.typed
+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 8faa5ce2..f7faf45d 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
-@get.public("/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."""
+@get.typed
+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
)
-@get.committer("/checks/<project_name>/<version_name>/<revision_number>")
+@get.typed
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=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
-@get.public("/committees")
-async def directory(session: web.Committer | None) -> str:
- """Main committee directory page."""
+@get.typed
+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:
)
-@get.public("/committees/<name>")
-async def view(session: web.Committer | None, name: str) -> str:
+@get.typed
+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
-@get.committer("/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)
-
+@get.typed
+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
-@get.committer("/distribution/automate/<project>/<version>")
-async def automate(session: web.Committer, project: str, version: str) -> str:
- return await _automate_form_page(project, version, staging=False)
-
-
-@get.committer("/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)
+@get.typed
+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)
+
+
+@get.typed
+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())
-@get.committer("/distribution/record/<project>/<version>")
-async def record(session: web.Committer, project: str, version: str) -> str:
- return await _record_form_page(project, version, staging=False)
-
-
-@get.committer("/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)
-
-
-@get.committer("/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)
+@get.typed
+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)
+
+
+@get.typed
+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)
+
+
+@get.typed
+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()
-@get.public("/docs/")
-async def index(session: web.Committer | None) -> str:
+@get.typed
+async def index(session: web.Public, _docs: Literal["docs"]) -> str:
return await _serve_docs_page("index")
-@get.public("/docs/<path:page>")
-async def page(session: web.Committer | None, page: str) -> str:
- validated_page = form.to_relpath(page)
+@get.typed
+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
-@get.committer("/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."""
+@get.typed
+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:
)
-@get.public("/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)
-
-
-@get.public("/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, ".")
-
-
-@get.public("/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."""
+@get.typed
+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))
+
+
+@get.typed
+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), ".")
+
+
+@get.typed
+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)
-@get.public("/download/urls/<project_name>/<version_name>")
-async def urls_selected(session: web.Committer | None, project_name: str, version_name: str) -> web.Response:
+@get.typed
+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)
-@get.committer("/download/zip/<project_name>/<version_name>")
-async def zip_selected(session: web.Committer, project_name: str, version_name: str) -> web.Response:
+@get.typed
+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
-@get.committer("/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)
+@get.typed
+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"]
-@get.committer("/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)
+@get.typed
+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())
-@get.committer("/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)
+@get.typed
+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
-@get.committer("/finish/<project_name>/<version_name>")
+@get.typed
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
-@get.committer("/ignores/<project_name>")
-async def ignores(session: web.Committer, project_name: str) -> str | web.WerkzeugResponse:
+@get.typed
+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
-@get.committer("/keys/add")
-async def add(session: web.Committer) -> str:
- """Add a new public signing key to the user's account."""
+@get.typed
+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:
)
-@get.committer("/keys/details/<fingerprint>")
-async def details(session: web.Committer, fingerprint: str) -> str:
- """Display details for a specific OpenPGP key."""
+@get.typed
+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:
)
-@get.committer("/keys/export/<committee_name>")
-async def export(session: web.Committer, committee_name: str) -> web.TextResponse:
- """Export a KEYS file for a specific committee."""
+@get.typed
+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)
-@get.committer("/keys")
-async def keys(session: web.Committer) -> str:
- """View all keys associated with the user's account."""
+@get.typed
+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:
)
-@get.committer("/keys/ssh/add")
-async def ssh_add(session: web.Committer) -> str:
- """Add a new SSH key to the user's account."""
+@get.typed
+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:
)
-@get.committer("/keys/upload")
-async def upload(session: web.Committer) -> str:
- """Upload a KEYS file containing multiple OpenPGP keys."""
+@get.typed
+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
-@get.committer("/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)
-
+@get.typed
+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
)
-@get.committer("/manual/start/<project_name>/<version_name>/<revision>")
+@get.typed
async def start_selected_revision(
- session: web.Committer, project_name: str, version_name: str, revision: str
+ session: web.Committer,
+ _manual_start: Literal["manual/start"],
+ project_name: safe.ProjectName,
+ version_name: safe.VersionName,
+ revision: str,
) -> 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
-@get.committer("/project/add/<committee_name>")
-async def add_project(session: web.Committer, committee_name: str) -> web.WerkzeugResponse | str:
+@get.typed
+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
)
-@get.public("/projects")
-async def projects(session: web.Committer | None) -> str:
- """Main project directory page."""
+@get.typed
+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)
-@get.committer("/project/select")
-async def select(session: web.Committer) -> str:
- """Select a project to work on."""
+@get.typed
+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)
-@get.committer("/projects/<name>")
-async def view(session: web.Committer, name: str) -> web.WerkzeugResponse | str:
+@get.typed
+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
-@get.committer("/published/<path:path>")
-async def path(session: web.Committer, path: str) -> web.QuartResponse:
- """View the content of a specific file in the downloads directory."""
+@get.typed
+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))
-@get.committer("/published/")
-async def root(session: web.Committer) -> web.QuartResponse:
+@get.typed
+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
-@get.public("/ref/<path:ref_path>")
-async def resolve(session: web.Committer | None, ref_path: str) -> web.WerkzeugResponse:
+@get.typed
+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
-@get.public("/releases/finished/<project_name>")
-async def finished(session: web.Committer | None, project_name: str) -> str:
- """View all finished releases for a project."""
+@get.typed
+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:
)
-@get.public("/releases")
-async def releases(session: web.Committer | None) -> str:
- """View all releases."""
+@get.typed
+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:
)
-@get.committer("/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)
-
+@get.typed
+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
-@get.committer("/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)
-
+@get.typed
+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
-@get.committer("/result/data/<project_name>/<version_name>/<int:check_id>")
+@get.typed
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]
-@get.committer("/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)
-
+@get.typed
+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[
]
-@get.committer("/about")
-async def about(session: web.Committer) -> str:
- """About page."""
+@get.typed
+async def about(session: web.Committer, _about: Literal["about"]) -> str:
+ """
+ URL: /about
+ About page.
+ """
return await template.render("about.html")
-@get.public("/")
-async def index(session: web.Committer | None) -> quart_response.Response | str:
- """Show public info or an entry portal for participants."""
+@get.typed
+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")
-@get.public("/policies")
-async def policies(session: web.Committer | None) -> str:
+@get.typed
+async def policies(session: web.Public, _policies: Literal["policies"]) -> str:
+ """
+ URL: /policies
+ """
return await template.blank("Policies", content=_POLICIES)
-@get.public("/miscellaneous/resolved.json")
-async def resolved_json(session: web.Committer | None) -> quart_response.Response:
+@get.typed
+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")
-@get.committer("/tutorial")
-async def tutorial(session: web.Committer) -> str:
- """Tutorial page."""
+@get.typed
+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
-@get.committer("/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)
-
+@get.typed
+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
-@get.committer("/start/<project_name>")
-async def selected(session: web.Committer, project_name: str) -> str:
- await session.check_access(project_name)
-
+@get.typed
+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
-@get.public("/test/empty")
-async def test_empty(session: web.Committer | None) -> str:
+@get.typed
+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)
-@get.public("/test/login")
-async def test_login(session: web.Committer | None) -> web.WerkzeugResponse:
+@get.typed
+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)
-@get.committer("/test/merge/<project_name>/<version_name>")
-async def test_merge(session: web.Committer, project_name: str, version_name: str) -> web.WerkzeugResponse:
+@get.typed
+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")
-@get.public("/test/multiple")
-async def test_multiple(session: web.Committer | None) -> str:
+@get.typed
+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)
-@get.public("/test/single")
-async def test_single(session: web.Committer | None) -> str:
+@get.typed
+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)
-@get.public("/test/vote/<category>/<project_name>/<version_name>")
-async def test_vote(session: web.Committer | None, category: str, project_name: str, version_name: str) -> str:
+@get.typed
+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
-@get.committer("/tokens")
-async def tokens(session: web.Committer) -> str:
+@get.typed
+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
-@get.committer("/upload/<project_name>/<version_name>")
-async def selected(session: web.Committer, project_name: str, version_name: str) -> str:
- await session.check_access(project_name)
-
+@get.typed
+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
-@get.committer("/user/cache")
-async def cache_get(session: web.Committer) -> str:
+@get.typed
+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:
)
-@get.public("/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)
+@get.typed
+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
-@get.committer("/voting/<project_name>/<version_name>/<revision>")
+@get.typed
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/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 6dcee179..fe73716a 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: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org