This is an automated email from the ASF dual-hosted git repository.

sbp pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-release.git


The following commit(s) were added to refs/heads/main by this push:
     new 001250e  Separate route helpers from routes
001250e is described below

commit 001250ebf74a0a9f20892e726454bcfd84cd9a1e
Author: Sean B. Palmer <[email protected]>
AuthorDate: Tue Sep 16 18:36:40 2025 +0100

    Separate route helpers from routes
---
 atr/{routes/__init__.py => route.py} |   0
 atr/routes/__init__.py               | 598 ++++++-----------------------------
 atr/routes/announce.py               |  10 +-
 atr/routes/candidate.py              |  14 +-
 atr/routes/committees.py             |  12 +-
 atr/routes/compose.py                |   8 +-
 atr/routes/distribution.py           |  26 +-
 atr/routes/download.py               |  28 +-
 atr/routes/draft.py                  |  44 +--
 atr/routes/file.py                   |   6 +-
 atr/routes/finish.py                 |  14 +-
 atr/routes/ignores.py                |  18 +-
 atr/routes/keys.py                   |  50 +--
 atr/routes/mapping.py                |   4 +-
 atr/routes/modules.py                |  89 ------
 atr/routes/preview.py                |  18 +-
 atr/routes/projects.py               |  24 +-
 atr/routes/published.py              |  12 +-
 atr/routes/release.py                |  24 +-
 atr/routes/report.py                 |   6 +-
 atr/routes/resolve.py                |  18 +-
 atr/routes/revisions.py              |  10 +-
 atr/routes/root.py                   |  22 +-
 atr/routes/sbom.py                   |  10 +-
 atr/routes/start.py                  |  10 +-
 atr/routes/tokens.py                 |  18 +-
 atr/routes/upload.py                 |   6 +-
 atr/routes/vote.py                   |  10 +-
 atr/routes/voting.py                 |  10 +-
 atr/server.py                        |   8 +-
 pyproject.toml                       |   1 -
 31 files changed, 318 insertions(+), 810 deletions(-)

diff --git a/atr/routes/__init__.py b/atr/route.py
similarity index 100%
copy from atr/routes/__init__.py
copy to atr/route.py
diff --git a/atr/routes/__init__.py b/atr/routes/__init__.py
index ab7bac1..9a04e8b 100644
--- a/atr/routes/__init__.py
+++ b/atr/routes/__init__.py
@@ -15,502 +15,102 @@
 # specific language governing permissions and limitations
 # under the License.
 
-from __future__ import annotations
-
-import asyncio
-import functools
-import logging
-import time
-from typing import TYPE_CHECKING, Any, Concatenate, Final, NoReturn, 
ParamSpec, Protocol, TypeVar
-
-import aiofiles
-import aiofiles.os
-import asfquart
-import asfquart.auth as auth
-import asfquart.base as base
-import asfquart.session as session
-import quart
-
-import atr.config as config
-import atr.db as db
-import atr.models.sql as sql
-import atr.user as user
-import atr.util as util
-
-if TYPE_CHECKING:
-    from collections.abc import Awaitable, Callable, Coroutine, Sequence
-
-    import werkzeug.datastructures as datastructures
-    import werkzeug.wrappers.response as response
-
-if asfquart.APP is ...:
-    raise RuntimeError("APP is not set")
-
-P = ParamSpec("P")
-R = TypeVar("R", covariant=True)
-T = TypeVar("T")
-
-# TODO: Should get this from config, checking debug there
-_MEASURE_PERFORMANCE: Final[bool] = True
-
-
-# |         1 | RSA (Encrypt or Sign) [HAC]                        |
-# |         2 | RSA Encrypt-Only [HAC]                             |
-# |         3 | RSA Sign-Only [HAC]                                |
-# |        16 | Elgamal (Encrypt-Only) [ELGAMAL] [HAC]             |
-# |        17 | DSA (Digital Signature Algorithm) [FIPS186] [HAC]  |
-# |        18 | ECDH public key algorithm                          |
-# |        19 | ECDSA public key algorithm [FIPS186]               |
-# |        20 | Reserved (formerly Elgamal Encrypt or Sign)        |
-# |        21 | Reserved for Diffie-Hellman                        |
-# |           | (X9.42, as defined for IETF-S/MIME)                |
-# |        22 | EdDSA [I-D.irtf-cfrg-eddsa]                        |
-# - https://lists.gnupg.org/pipermail/gnupg-devel/2017-April/032762.html
-# TODO: (Obviously we should move this, but where to?)
-algorithms: Final[dict[int, str]] = {
-    1: "RSA",
-    2: "RSA",
-    3: "RSA",
-    16: "Elgamal",
-    17: "DSA",
-    18: "ECDH",
-    19: "ECDSA",
-    21: "Diffie-Hellman",
-    22: "EdDSA",
-}
-
-
-class AsyncFileHandler(logging.Handler):
-    """A logging handler that writes logs asynchronously using aiofiles."""
-
-    def __init__(self, filename, mode="w", encoding=None):
-        super().__init__()
-        self.filename = filename
-
-        if mode != "w":
-            raise RuntimeError("Only write mode is supported")
-
-        self.encoding = encoding
-        self.queue = asyncio.Queue()
-        self.our_worker_task = None
-
-    def our_worker_task_ensure(self):
-        """Lazily create the worker task if it doesn't exist and there's an 
event loop."""
-        if self.our_worker_task is None:
-            try:
-                loop = asyncio.get_running_loop()
-                self.our_worker_task = loop.create_task(self.our_worker())
-            except RuntimeError:
-                # No event loop running yet, try again on next emit
-                ...
-
-    async def our_worker(self):
-        """Background task that writes queued log messages to file."""
-        # Use a binary mode literal with aiofiles.open
-        # 
https://github.com/Tinche/aiofiles/blob/main/src/aiofiles/threadpool/__init__.py
-        # We should be able to use any mode, but pyright requires a binary mode
-        async with aiofiles.open(self.filename, "wb+") as f:
-            while True:
-                record = await self.queue.get()
-                if record is None:
-                    break
-
-                try:
-                    # Format the log record first
-                    formatted_message = self.format(record) + "\n"
-                    message_bytes = formatted_message.encode(self.encoding or 
"utf-8")
-                    await f.write(message_bytes)
-                    await f.flush()
-                except Exception:
-                    self.handleError(record)
-                finally:
-                    self.queue.task_done()
-
-    def emit(self, record):
-        """Queue the record for writing by the worker task."""
-        try:
-            # Ensure worker task is running
-            self.our_worker_task_ensure()
-
-            # Queue the record, but handle the case where no event loop is 
running yet
-            try:
-                self.queue.put_nowait(record)
-            except RuntimeError:
-                ...
-        except Exception:
-            self.handleError(record)
-
-    def close(self):
-        """Shut down the worker task cleanly."""
-        if self.our_worker_task is not None and not 
self.our_worker_task.done():
-            try:
-                self.queue.put_nowait(None)
-            except RuntimeError:
-                # No running event loop, no need to clean up
-                ...
-        super().close()
-
-
-# This is the type of functions to which we apply @committer_get
-# In other words, functions which accept CommitterSession as their first arg
-class CommitterRouteHandler(Protocol[R]):
-    """Protocol for @committer_get decorated functions."""
-
-    __name__: str
-    __doc__: str | None
-
-    def __call__(self, session: CommitterSession, *args: Any, **kwargs: Any) 
-> Awaitable[R]: ...
-
-
-class CommitterSession:
-    """Session with extra information about committers."""
-
-    def __init__(self, web_session: session.ClientSession) -> None:
-        self._projects: list[sql.Project] | None = None
-        self._session = web_session
-
-    @property
-    def asf_uid(self) -> str:
-        if self._session.uid is None:
-            raise base.ASFQuartException("Not authenticated", errorcode=401)
-        return self._session.uid
-
-    def __getattr__(self, name: str) -> Any:
-        # TODO: Not type safe, should subclass properly if possible
-        # For example, we can access session.no_such_attr and the type 
checkers won't notice
-        return getattr(self._session, name)
-
-    async def check_access(self, project_name: str) -> None:
-        if not any((p.name == project_name) for p in (await 
self.user_projects)):
-            if user.is_admin(self.uid):
-                # Admins can view all projects
-                # But we must warn them when the project is not one of their 
own
-                # TODO: This code is difficult to test locally
-                # TODO: This flash sometimes displays after deleting a 
project, which is a bug
-                await quart.flash("This is not your project, but you have 
access as an admin", "warning")
-                return
-            raise base.ASFQuartException("You do not have access to this 
project", errorcode=403)
-
-    async def check_access_committee(self, committee_name: str) -> None:
-        if committee_name not in self.committees:
-            if user.is_admin(self.uid):
-                # Admins can view all committees
-                # But we must warn them when the committee is not one of their 
own
-                # TODO: As above, this code is difficult to test locally
-                await quart.flash("This is not your committee, but you have 
access as an admin", "warning")
-                return
-            raise base.ASFQuartException("You do not have access to this 
committee", errorcode=403)
-
-    @property
-    def app_host(self) -> str:
-        return config.get().APP_HOST
-
-    @property
-    def host(self) -> str:
-        request_host = quart.request.host
-        if ":" in request_host:
-            domain, port = request_host.split(":")
-            # Could be an IPv6 address, so need to check whether port is a 
valid integer
-            if port.isdigit():
-                return domain
-        return request_host
-
-    def only_user_releases(self, releases: Sequence[sql.Release]) -> 
list[sql.Release]:
-        return util.user_releases(self.uid, releases)
-
-    async def redirect(
-        self, route: CommitterRouteHandler[R], success: str | None = None, 
error: str | None = None, **kwargs: Any
-    ) -> response.Response:
-        """Redirect to a route with a success or error message."""
-        if success is not None:
-            await quart.flash(success, "success")
-        elif error is not None:
-            await quart.flash(error, "error")
-        return quart.redirect(util.as_url(route, **kwargs))
-
-    async def release(
-        self,
-        project_name: str,
-        version_name: str,
-        phase: sql.ReleasePhase | db.NotSet | None = db.NOT_SET,
-        latest_revision_number: str | db.NotSet | None = db.NOT_SET,
-        data: db.Session | None = None,
-        with_committee: bool = True,
-        with_project: bool = True,
-        with_release_policy: bool = False,
-        with_project_release_policy: bool = False,
-        with_revisions: bool = False,
-    ) -> sql.Release:
-        # We reuse db.NOT_SET as an entirely different sentinel
-        # TODO: We probably shouldn't do that, or should make it clearer
-        if phase is None:
-            phase_value = db.NOT_SET
-        elif phase is db.NOT_SET:
-            phase_value = sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
-        else:
-            phase_value = phase
-        release_name = sql.release_name(project_name, version_name)
-        if data is None:
-            async with db.session() as data:
-                release = await data.release(
-                    name=release_name,
-                    phase=phase_value,
-                    latest_revision_number=latest_revision_number,
-                    _committee=with_committee,
-                    _project=with_project,
-                    _release_policy=with_release_policy,
-                    _project_release_policy=with_project_release_policy,
-                    _revisions=with_revisions,
-                ).demand(base.ASFQuartException("Release does not exist", 
errorcode=404))
-        else:
-            release = await data.release(
-                name=release_name,
-                phase=phase_value,
-                latest_revision_number=latest_revision_number,
-                _committee=with_committee,
-                _project=with_project,
-                _release_policy=with_release_policy,
-                _project_release_policy=with_project_release_policy,
-                _revisions=with_revisions,
-            ).demand(base.ASFQuartException("Release does not exist", 
errorcode=404))
-        return release
-
-    @property
-    async def user_candidate_drafts(self) -> list[sql.Release]:
-        return await user.candidate_drafts(self.uid, 
user_projects=self._projects)
-
-    # @property
-    # async def user_committees(self) -> list[models.Committee]:
-    #     return ...
-
-    @property
-    async def user_projects(self) -> list[sql.Project]:
-        if self._projects is None:
-            self._projects = await user.projects(self.uid)
-        return self._projects[:]
-
-
-class FlashError(RuntimeError): ...
-
-
-class MicrosecondsFormatter(logging.Formatter):
-    # Answers on a postcard if you know why Python decided to use a comma by 
default
-    default_msec_format = "%s.%03d"
-
-
-# Setup a dedicated logger for route performance metrics
-# NOTE: This code block must come after AsyncFileHandler and 
MicrosecondsFormatter
-route_logger: Final = logging.getLogger("route.performance")
-# Use custom formatter that properly includes microseconds
-# TODO: Is this actually UTC?
-route_logger_handler: Final[AsyncFileHandler] = 
AsyncFileHandler("route-performance.log")
-route_logger_handler.setFormatter(MicrosecondsFormatter("%(asctime)s - 
%(message)s"))
-route_logger.addHandler(route_logger_handler)
-route_logger.setLevel(logging.INFO)
-# If we don't set propagate to False then it logs to the term as well
-route_logger.propagate = False
-
-
-# This is the type of functions to which we apply @app_route
-# In other words, functions which accept no session
-class RouteHandler(Protocol[R]):
-    """Protocol for @app_route decorated functions."""
-
-    __name__: str
-    __doc__: str | None
-
-    def __call__(self, *args: Any, **kwargs: Any) -> Awaitable[R]: ...
-
-
-def app_route(
-    path: str, methods: list[str] | None = None, endpoint: str | None = None, 
measure_performance: bool = True
-) -> Callable:
-    """Register a route with the Flask app with built-in performance 
logging."""
-
-    def decorator(f: Callable[P, Coroutine[Any, Any, T]]) -> Callable[P, 
Awaitable[T]]:
-        # First apply our performance measuring decorator
-        if _MEASURE_PERFORMANCE and measure_performance:
-            measured_func = app_route_performance_measure(path, methods)(f)
-        else:
-            measured_func = f
-        # Then apply the original route decorator
-        return asfquart.APP.route(path, methods=methods, 
endpoint=endpoint)(measured_func)
-
-    return decorator
-
-
-def app_route_performance_measure(route_path: str, http_methods: list[str] | 
None = None) -> Callable:
-    """Decorator that measures and logs route performance with path and method 
information."""
-
-    # def format_time(seconds: float) -> str:
-    #     """Format time in appropriate units (µs or ms)."""
-    #     microseconds = seconds * 1_000_000
-    #     if microseconds < 1000:
-    #         return f"{microseconds:.2f} µs"
-    #     else:
-    #         milliseconds = microseconds / 1000
-    #         return f"{milliseconds:.2f} ms"
-
-    def decorator(f: Callable[P, Coroutine[Any, Any, T]]) -> Callable[P, 
Awaitable[T]]:
-        @functools.wraps(f)
-        async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
-            # This wrapper is based on an outstanding idea by Mostafa Farzán
-            # Farzán realised that we can step the event loop manually
-            # That way, we can also divide it into synchronous and 
asynchronous parts
-            # The synchronous part is done using coro.send(None)
-            # The asynchronous part is done using asyncio.sleep(0)
-            # We use two methods for measuring the async part, and take the 
largest
-            # This performance measurement adds a bit of overhead, about 
10-20ms
-            # Therefore it should be avoided in production, or made more 
efficient
-            # We could perhaps use for a small portion of requests
-            blocking_time = 0.0
-            async_time = 0.0
-            loop_time = 0.0
-            total_start = time.perf_counter()
-            coro = f(*args, **kwargs)
-            try:
-                while True:
-                    # Measure the synchronous part
-                    sync_start = time.perf_counter()
-                    future = coro.send(None)
-                    sync_end = time.perf_counter()
-                    blocking_time += sync_end - sync_start
-
-                    # Measure the asynchronous part in two different ways
-                    loop = asyncio.get_running_loop()
-                    wait_start = time.perf_counter()
-                    loop_start = loop.time()
-                    if future is not None:
-                        done = asyncio.Event()
-                        future.add_done_callback(lambda _: done.set())
-                        await done.wait()
-                    wait_end = time.perf_counter()
-                    loop_end = loop.time()
-                    async_time += wait_end - wait_start
-                    loop_time += loop_end - loop_start
-
-                    # Raise exception if any
-                    # future.result()
-            except StopIteration as e:
-                total_end = time.perf_counter()
-                total_time = total_end - total_start
-
-                methods_str = ",".join(http_methods) if http_methods else "GET"
-
-                nonblocking_time = max(async_time, loop_time)
-                # If async time is more than 10% different from loop time, log 
it
-                delta_symbol = "="
-                nonblocking_delta = abs(async_time - loop_time)
-                # Must check that nonblocking_time is not 0 to avoid division 
by zero
-                if nonblocking_time and ((nonblocking_delta / 
nonblocking_time) > 0.1):
-                    delta_symbol = "!"
-                route_logger.info(
-                    "%s %s %s %s %s %s %s",
-                    methods_str,
-                    route_path,
-                    f.__name__,
-                    delta_symbol,
-                    int(blocking_time * 1000),
-                    int(nonblocking_time * 1000),
-                    int(total_time * 1000),
-                )
-
-                return e.value
-
-        return wrapper
-
-    return decorator
-
-
-# This decorator is an adaptor between @committer_get and @app_route functions
-def committer(
-    path: str, methods: list[str] | None = None, measure_performance: bool = 
True
-) -> Callable[[CommitterRouteHandler[R]], RouteHandler[R]]:
-    """Decorator for committer GET routes that provides an enhanced session 
object."""
-
-    def decorator(func: CommitterRouteHandler[R]) -> RouteHandler[R]:
-        async def wrapper(*args: Any, **kwargs: Any) -> R:
-            web_session = await session.read()
-            if web_session is None:
-                _authentication_failed()
-
-            enhanced_session = CommitterSession(web_session)
-            return await func(enhanced_session, *args, **kwargs)
-
-        # Generate a unique endpoint name
-        endpoint = func.__module__ + "_" + func.__name__
-
-        # Set the name before applying decorators
-        wrapper.__name__ = func.__name__
-        wrapper.__doc__ = func.__doc__
-        wrapper.__annotations__["endpoint"] = endpoint
-
-        # Apply decorators in reverse order
-        decorated = auth.require(auth.Requirements.committer)(wrapper)
-        decorated = app_route(
-            path, methods=methods or ["GET"], endpoint=endpoint, 
measure_performance=measure_performance
-        )(decorated)
-
-        return decorated
-
-    return decorator
-
-
-async def get_form(request: quart.Request) -> datastructures.MultiDict:
-    # The request.form() method in Quart calls a synchronous tempfile method
-    # It calls quart.wrappers.request.form _load_form_data
-    # Which calls quart.formparser parse and parse_func and parser.parse
-    # Which calls _write which calls tempfile, which is synchronous
-    # It's getting a tempfile back from some prior call
-    # We can't just make blockbuster ignore the call because then it ignores 
it everywhere
-    app = asfquart.APP
-
-    if app is ...:
-        raise RuntimeError("APP is not set")
-
-    # Or quart.current_app?
-    blockbuster = app.extensions.get("blockbuster")
-
-    # Turn blockbuster off
-    if blockbuster is not None:
-        blockbuster.deactivate()
-    form = await request.form
-    # Turn blockbuster on
-    if blockbuster is not None:
-        blockbuster.activate()
-    return form
-
-
-def public(
-    path: str, methods: list[str] | None = None, measure_performance: bool = 
True
-) -> Callable[[Callable[Concatenate[CommitterSession | None, P], 
Awaitable[R]]], RouteHandler[R]]:
-    """Decorator for public GET routes that provides an enhanced session 
object."""
-
-    def decorator(func: Callable[Concatenate[CommitterSession | None, P], 
Awaitable[R]]) -> RouteHandler[R]:
-        async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
-            web_session = await session.read()
-            enhanced_session = CommitterSession(web_session) if web_session 
else None
-            return await func(enhanced_session, *args, **kwargs)
-
-        # Generate a unique endpoint name
-        endpoint = func.__module__ + "_" + func.__name__
-
-        # Set the name before applying decorators
-        wrapper.__name__ = func.__name__
-        wrapper.__doc__ = func.__doc__
-        wrapper.__annotations__["endpoint"] = endpoint
-
-        # Apply decorators in reverse order
-        decorated = app_route(
-            path, methods=methods or ["GET"], endpoint=endpoint, 
measure_performance=measure_performance
-        )(wrapper)
-
-        return decorated
-
-    return decorator
-
-
-def _authentication_failed() -> NoReturn:
-    """Handle authentication failure with an exception."""
-    # NOTE: This is a separate function to fix a problem with analysis flow in 
mypy
-    raise base.ASFQuartException("Not authenticated", errorcode=401)
+import atr.routes.announce as announce
+import atr.routes.candidate as candidate
+import atr.routes.committees as committees
+import atr.routes.compose as compose
+import atr.routes.distribution as distribution
+import atr.routes.download as download
+import atr.routes.draft as draft
+import atr.routes.file as file
+import atr.routes.finish as finish
+import atr.routes.ignores as ignores
+import atr.routes.keys as keys
+import atr.routes.preview as preview
+import atr.routes.projects as projects
+import atr.routes.published as published
+import atr.routes.release as release
+import atr.routes.report as report
+import atr.routes.resolve as resolve
+import atr.routes.revisions as revisions
+import atr.routes.root as root
+import atr.routes.sbom as sbom
+import atr.routes.start as start
+import atr.routes.tokens as tokens
+import atr.routes.upload as upload
+import atr.routes.vote as vote
+import atr.routes.voting as voting
+
+__all__ = [
+    "announce",
+    "candidate",
+    "committees",
+    "compose",
+    "distribution",
+    "download",
+    "draft",
+    "file",
+    "finish",
+    "ignores",
+    "keys",
+    "preview",
+    "projects",
+    "published",
+    "release",
+    "report",
+    "resolve",
+    "revisions",
+    "root",
+    "sbom",
+    "start",
+    "tokens",
+    "upload",
+    "vote",
+    "voting",
+]
+
+
+# Export data for a custom linter script
+def _export_routes() -> None:
+    import asyncio
+
+    async def _export_routes_async() -> None:
+        """Export all routes to a JSON file for static analysis."""
+        import json
+        import sys
+
+        import aiofiles
+
+        route_paths: list[str] = []
+        current_module = sys.modules[__name__]
+
+        for module_name in dir(current_module):
+            if module_name.startswith("_"):
+                # Not intended for external use
+                continue
+
+            module = getattr(current_module, module_name)
+            if not hasattr(module, "__file__"):
+                # Not a module
+                continue
+
+            # Get all callable interfaces that do not begin with an underscore
+            for attr_name in dir(module):
+                if attr_name.startswith("_"):
+                    # Not intended for external use
+                    continue
+                if not callable(getattr(module, attr_name)):
+                    # Not callable
+                    continue
+                route_path = f"{module_name}.{attr_name}"
+                route_paths.append(route_path)
+
+        async with aiofiles.open("routes.json", "w", encoding="utf-8") as f:
+            await f.write(json.dumps(route_paths, indent=2))
+
+    loop = asyncio.get_event_loop()
+    loop.run_until_complete(_export_routes_async())
+
+
+_export_routes()
+del _export_routes
diff --git a/atr/routes/announce.py b/atr/routes/announce.py
index dd14908..d15909a 100644
--- a/atr/routes/announce.py
+++ b/atr/routes/announce.py
@@ -25,7 +25,7 @@ import atr.config as config
 import atr.construct as construct
 import atr.forms as forms
 import atr.models.sql as sql
-import atr.routes as routes
+import atr.route as route
 import atr.routes.release as routes_release
 import atr.storage as storage
 import atr.template as template
@@ -60,8 +60,8 @@ class DeleteForm(forms.Typed):
     submit = forms.submit("Delete preview")
 
 
[email protected]("/announce/<project_name>/<version_name>")
-async def selected(session: routes.CommitterSession, project_name: str, 
version_name: str) -> str | response.Response:
[email protected]("/announce/<project_name>/<version_name>")
+async def selected(session: route.CommitterSession, project_name: str, 
version_name: str) -> str | response.Response:
     """Allow the user to announce a release preview."""
     await session.check_access(project_name)
 
@@ -105,9 +105,9 @@ async def selected(session: routes.CommitterSession, 
project_name: str, version_
     )
 
 
[email protected]("/announce/<project_name>/<version_name>", methods=["POST"])
[email protected]("/announce/<project_name>/<version_name>", methods=["POST"])
 async def selected_post(
-    session: routes.CommitterSession, project_name: str, version_name: str
+    session: route.CommitterSession, project_name: str, version_name: str
 ) -> str | response.Response:
     """Handle the announcement form submission and promote the preview to 
release."""
     await session.check_access(project_name)
diff --git a/atr/routes/candidate.py b/atr/routes/candidate.py
index 3fec17b..61621f1 100644
--- a/atr/routes/candidate.py
+++ b/atr/routes/candidate.py
@@ -24,7 +24,7 @@ import werkzeug.wrappers.response as response
 import atr.db as db
 import atr.log as log
 import atr.models.sql as sql
-import atr.routes as routes
+import atr.route as route
 import atr.routes.root as root
 import atr.template as template
 import atr.util as util
@@ -33,15 +33,15 @@ if asfquart.APP is ...:
     raise RuntimeError("APP is not set")
 
 
[email protected]("/candidate/delete", methods=["POST"])
-async def delete(session: routes.CommitterSession) -> response.Response:
[email protected]("/candidate/delete", methods=["POST"])
+async def delete(session: route.CommitterSession) -> response.Response:
     """Delete a release candidate."""
     # TODO: We need to never retire revisions, if allowing release deletion
     return await session.redirect(root.index, error="Not yet implemented")
 
 
[email protected]("/candidate/view/<project_name>/<version_name>")
-async def view(session: routes.CommitterSession, project_name: str, 
version_name: str) -> response.Response | str:
[email protected]("/candidate/view/<project_name>/<version_name>")
+async def view(session: route.CommitterSession, project_name: str, 
version_name: str) -> response.Response | str:
     """View all the files in the rsync upload directory for a release."""
     await session.check_access(project_name)
 
@@ -75,9 +75,9 @@ async def view(session: routes.CommitterSession, 
project_name: str, version_name
     )
 
 
[email protected]("/candidate/view/<project_name>/<version_name>/<path:file_path>")
[email protected]("/candidate/view/<project_name>/<version_name>/<path:file_path>")
 async def view_path(
-    session: routes.CommitterSession, project_name: str, version_name: str, 
file_path: str
+    session: route.CommitterSession, project_name: str, version_name: str, 
file_path: str
 ) -> response.Response | str:
     """View the content of a specific file in the release candidate."""
     await session.check_access(project_name)
diff --git a/atr/routes/committees.py b/atr/routes/committees.py
index 1008d63..f743ddc 100644
--- a/atr/routes/committees.py
+++ b/atr/routes/committees.py
@@ -23,7 +23,7 @@ import http.client
 import atr.db as db
 import atr.forms as forms
 import atr.models.sql as sql
-import atr.routes as routes
+import atr.route as route
 import atr.template as template
 import atr.util as util
 
@@ -32,8 +32,8 @@ class UpdateCommitteeKeysForm(forms.Typed):
     submit = forms.submit("Regenerate KEYS file")
 
 
[email protected]("/committees")
-async def directory(session: routes.CommitterSession | None) -> str:
[email protected]("/committees")
+async def directory(session: route.CommitterSession | None) -> str:
     """Main committee directory page."""
     async with db.session() as data:
         committees = await 
data.committee(_projects=True).order_by(sql.Committee.name).all()
@@ -44,8 +44,8 @@ async def directory(session: routes.CommitterSession | None) 
-> str:
         )
 
 
[email protected]("/committees/<name>")
-async def view(session: routes.CommitterSession | None, name: str) -> str:
[email protected]("/committees/<name>")
+async def view(session: route.CommitterSession | None, name: str) -> str:
     # TODO: Could also import this from keys.py
     async with db.session() as data:
         committee = await data.committee(
@@ -61,7 +61,7 @@ async def view(session: routes.CommitterSession | None, name: 
str) -> str:
         "committee-view.html",
         committee=committee,
         projects=project_list,
-        algorithms=routes.algorithms,
+        algorithms=route.algorithms,
         now=datetime.datetime.now(datetime.UTC),
         email_from_key=util.email_from_uid,
         update_committee_keys_form=await UpdateCommitteeKeysForm.create_form(),
diff --git a/atr/routes/compose.py b/atr/routes/compose.py
index b147808..bfa3c87 100644
--- a/atr/routes/compose.py
+++ b/atr/routes/compose.py
@@ -27,7 +27,7 @@ import atr.forms as forms
 import atr.models.results as results
 import atr.models.sql as sql
 import atr.revision as revision
-import atr.routes as routes
+import atr.route as route
 import atr.routes.draft as draft
 import atr.routes.mapping as mapping
 import atr.storage as storage
@@ -39,7 +39,7 @@ if TYPE_CHECKING:
 
 
 async def check(
-    session: routes.CommitterSession,
+    session: route.CommitterSession,
     release: sql.Release,
     task_mid: str | None = None,
     form: wtforms.Form | None = None,
@@ -119,8 +119,8 @@ async def check(
     )
 
 
[email protected]("/compose/<project_name>/<version_name>")
-async def selected(session: routes.CommitterSession, project_name: str, 
version_name: str) -> response.Response | str:
[email protected]("/compose/<project_name>/<version_name>")
+async def selected(session: route.CommitterSession, project_name: str, 
version_name: str) -> response.Response | str:
     """Show the contents of the release candidate draft."""
     await session.check_access(project_name)
 
diff --git a/atr/routes/distribution.py b/atr/routes/distribution.py
index 6c0c1b6..23508b8 100644
--- a/atr/routes/distribution.py
+++ b/atr/routes/distribution.py
@@ -29,7 +29,7 @@ import atr.forms as forms
 import atr.htm as htm
 import atr.models.distribution as distribution
 import atr.models.sql as sql
-import atr.routes as routes
+import atr.route as route
 import atr.routes.compose as compose
 import atr.routes.finish as finish
 import atr.storage as storage
@@ -93,8 +93,8 @@ class FormProjectVersion:
     version: str
 
 
[email protected]("/distribution/delete/<project>/<version>", methods=["POST"])
-async def delete(session: routes.CommitterSession, project: str, version: str) 
-> response.Response:
[email protected]("/distribution/delete/<project>/<version>", methods=["POST"])
+async def delete(session: route.CommitterSession, project: str, version: str) 
-> response.Response:
     form = await DeleteForm.create_form(data=await quart.request.form)
     dd = distribution.DeleteData.model_validate(form.data)
 
@@ -122,8 +122,8 @@ async def delete(session: routes.CommitterSession, project: 
str, version: str) -
     )
 
 
[email protected]("/distributions/list/<project>/<version>", methods=["GET"])
-async def list_get(session: routes.CommitterSession, project: str, version: 
str) -> str:
[email protected]("/distributions/list/<project>/<version>", methods=["GET"])
+async def list_get(session: route.CommitterSession, project: str, version: 
str) -> str:
     async with db.session() as data:
         distributions = await data.distribution(
             release_name=sql.release_name(project, version),
@@ -205,15 +205,15 @@ async def list_get(session: routes.CommitterSession, 
project: str, version: str)
     return await template.blank(title, content=block.collect())
 
 
[email protected]("/distribution/record/<project>/<version>", methods=["GET"])
-async def record(session: routes.CommitterSession, project: str, version: str) 
-> str:
[email protected]("/distribution/record/<project>/<version>", methods=["GET"])
+async def record(session: route.CommitterSession, project: str, version: str) 
-> str:
     form = await DistributeForm.create_form(data={"package": project, 
"version": version})
     fpv = FormProjectVersion(form=form, project=project, version=version)
     return await _record_form_page(fpv)
 
 
[email protected]("/distribution/record/<project>/<version>", methods=["POST"])
-async def record_post(session: routes.CommitterSession, project: str, version: 
str) -> str:
[email protected]("/distribution/record/<project>/<version>", methods=["POST"])
+async def record_post(session: route.CommitterSession, project: str, version: 
str) -> str:
     form = await DistributeForm.create_form(data=await quart.request.form)
     fpv = FormProjectVersion(form=form, project=project, version=version)
     if await form.validate():
@@ -229,15 +229,15 @@ async def record_post(session: routes.CommitterSession, 
project: str, version: s
     return await _record_form_page(fpv)
 
 
[email protected]("/distribution/stage/<project>/<version>", methods=["GET"])
-async def stage(session: routes.CommitterSession, project: str, version: str) 
-> str:
[email protected]("/distribution/stage/<project>/<version>", methods=["GET"])
+async def stage(session: route.CommitterSession, project: str, version: str) 
-> str:
     form = await DistributeForm.create_form(data={"package": project, 
"version": version})
     fpv = FormProjectVersion(form=form, project=project, version=version)
     return await _record_form_page(fpv, staging=True)
 
 
[email protected]("/distribution/stage/<project>/<version>", methods=["POST"])
-async def stage_post(session: routes.CommitterSession, project: str, version: 
str) -> str:
[email protected]("/distribution/stage/<project>/<version>", methods=["POST"])
+async def stage_post(session: route.CommitterSession, project: str, version: 
str) -> str:
     form = await DistributeForm.create_form(data=await quart.request.form)
     fpv = FormProjectVersion(form=form, project=project, version=version)
     if await form.validate():
diff --git a/atr/routes/download.py b/atr/routes/download.py
index b1d9b25..974fc40 100644
--- a/atr/routes/download.py
+++ b/atr/routes/download.py
@@ -30,16 +30,16 @@ import zipstream
 import atr.config as config
 import atr.db as db
 import atr.models.sql as sql
-import atr.routes as routes
+import atr.route as route
 import atr.routes.mapping as mapping
 import atr.routes.root as root
 import atr.template as template
 import atr.util as util
 
 
[email protected]("/download/all/<project_name>/<version_name>")
[email protected]("/download/all/<project_name>/<version_name>")
 async def all_selected(
-    session: routes.CommitterSession, project_name: str, version_name: str
+    session: route.CommitterSession, project_name: str, version_name: str
 ) -> response.Response | str:
     """Display download commands for a release."""
     async with db.session() as data:
@@ -64,25 +64,25 @@ async def all_selected(
     )
 
 
[email protected]("/download/path/<project_name>/<version_name>/<path:file_path>")
[email protected]("/download/path/<project_name>/<version_name>/<path:file_path>")
 async def path(
-    session: routes.CommitterSession | None, project_name: str, version_name: 
str, file_path: str
+    session: route.CommitterSession | None, project_name: str, version_name: 
str, file_path: str
 ) -> response.Response | quart.Response:
     """Download a file or list a directory from a release in any phase."""
     return await _download_or_list(project_name, version_name, file_path)
 
 
[email protected]("/download/path/<project_name>/<version_name>/")
[email protected]("/download/path/<project_name>/<version_name>/")
 async def path_empty(
-    session: routes.CommitterSession | None, project_name: str, version_name: 
str
+    session: route.CommitterSession | None, project_name: str, version_name: 
str
 ) -> response.Response | quart.Response:
     """List files at the root of a release directory for download."""
     return await _download_or_list(project_name, version_name, ".")
 
 
[email protected]("/download/sh/<project_name>/<version_name>")
[email protected]("/download/sh/<project_name>/<version_name>")
 async def sh_selected(
-    session: routes.CommitterSession | None, project_name: str, version_name: 
str
+    session: route.CommitterSession | None, project_name: str, version_name: 
str
 ) -> response.Response | quart.Response:
     """Shell script to download a release."""
     conf = config.get()
@@ -97,9 +97,9 @@ async def sh_selected(
     return quart.Response(content, mimetype="text/x-shellscript")
 
 
[email protected]("/download/urls/<project_name>/<version_name>")
[email protected]("/download/urls/<project_name>/<version_name>")
 async def urls_selected(
-    session: routes.CommitterSession | None, project_name: str, version_name: 
str
+    session: route.CommitterSession | None, project_name: str, version_name: 
str
 ) -> response.Response | quart.Response:
     try:
         async with db.session() as data:
@@ -114,9 +114,9 @@ async def urls_selected(
         return quart.Response(f"Internal server error: {e}", status=500, 
mimetype="text/plain")
 
 
[email protected]("/download/zip/<project_name>/<version_name>")
[email protected]("/download/zip/<project_name>/<version_name>")
 async def zip_selected(
-    session: routes.CommitterSession, project_name: str, version_name: str
+    session: route.CommitterSession, project_name: str, version_name: str
 ) -> response.Response | quart.wrappers.response.Response:
     try:
         release = await session.release(project_name=project_name, 
version_name=version_name, phase=None)
@@ -154,7 +154,7 @@ async def _download_or_list(project_name: str, 
version_name: str, file_path: str
     # Check that path is relative
     original_path = pathlib.Path(file_path)
     if (file_path != ".") and (not 
original_path.is_relative_to(original_path.anchor)):
-        raise routes.FlashError("Path must be relative")
+        raise route.FlashError("Path must be relative")
 
     # We allow downloading files from any phase
     async with db.session() as data:
diff --git a/atr/routes/draft.py b/atr/routes/draft.py
index 001d31e..71b5f60 100644
--- a/atr/routes/draft.py
+++ b/atr/routes/draft.py
@@ -33,7 +33,7 @@ import atr.forms as forms
 import atr.log as log
 import atr.models.sql as sql
 import atr.revision as revision
-import atr.routes as routes
+import atr.route as route
 import atr.routes.compose as compose
 import atr.routes.root as root
 import atr.routes.upload as upload
@@ -73,8 +73,8 @@ class VotePreviewForm(forms.Typed):
     vote_duration = forms.integer("Vote duration")
 
 
[email protected]("/draft/delete", methods=["POST"])
-async def delete(session: routes.CommitterSession) -> response.Response:
[email protected]("/draft/delete", methods=["POST"])
+async def delete(session: route.CommitterSession) -> response.Response:
     """Delete a candidate draft and all its associated files."""
     form = await DeleteForm.create_form(data=await quart.request.form)
     if not await form.validate_on_submit():
@@ -117,8 +117,8 @@ async def delete(session: routes.CommitterSession) -> 
response.Response:
     return await session.redirect(root.index, success="Candidate draft deleted 
successfully")
 
 
[email protected]("/draft/delete-file/<project_name>/<version_name>", 
methods=["POST"])
-async def delete_file(session: routes.CommitterSession, project_name: str, 
version_name: str) -> response.Response:
[email protected]("/draft/delete-file/<project_name>/<version_name>", 
methods=["POST"])
+async def delete_file(session: route.CommitterSession, project_name: str, 
version_name: str) -> response.Response:
     """Delete a specific file from the release candidate, creating a new 
revision."""
     await session.check_access(project_name)
 
@@ -152,8 +152,8 @@ async def delete_file(session: routes.CommitterSession, 
project_name: str, versi
     )
 
 
[email protected]("/draft/fresh/<project_name>/<version_name>", 
methods=["POST"])
-async def fresh(session: routes.CommitterSession, project_name: str, 
version_name: str) -> response.Response:
[email protected]("/draft/fresh/<project_name>/<version_name>", 
methods=["POST"])
+async def fresh(session: route.CommitterSession, project_name: str, 
version_name: str) -> response.Response:
     """Restart all checks for a whole release candidate draft."""
     # Admin only button, but it's okay if users find and use this manually
     await session.check_access(project_name)
@@ -176,9 +176,9 @@ async def fresh(session: routes.CommitterSession, 
project_name: str, version_nam
     )
 
 
[email protected]("/draft/hashgen/<project_name>/<version_name>/<path:file_path>",
 methods=["POST"])
[email protected]("/draft/hashgen/<project_name>/<version_name>/<path:file_path>",
 methods=["POST"])
 async def hashgen(
-    session: routes.CommitterSession, project_name: str, version_name: str, 
file_path: str
+    session: route.CommitterSession, project_name: str, version_name: str, 
file_path: str
 ) -> response.Response:
     """Generate an sha256 or sha512 hash file for a candidate draft file, 
creating a new revision."""
     await session.check_access(project_name)
@@ -211,9 +211,9 @@ async def hashgen(
     )
 
 
[email protected]("/draft/sbomgen/<project_name>/<version_name>/<path:file_path>",
 methods=["POST"])
[email protected]("/draft/sbomgen/<project_name>/<version_name>/<path:file_path>",
 methods=["POST"])
 async def sbomgen(
-    session: routes.CommitterSession, project_name: str, version_name: str, 
file_path: str
+    session: route.CommitterSession, project_name: str, version_name: str, 
file_path: str
 ) -> response.Response:
     """Generate a CycloneDX SBOM file for a candidate draft file, creating a 
new revision."""
     await session.check_access(project_name)
@@ -238,14 +238,14 @@ async def sbomgen(
             # Check that the source file exists in the new revision
             if not await aiofiles.os.path.exists(path_in_new_revision):
                 log.error(f"Source file {rel_path} not found in new revision 
for SBOM generation.")
-                raise routes.FlashError("Source artifact file not found in the 
new revision.")
+                raise route.FlashError("Source artifact file not found in the 
new revision.")
 
             # Check that the SBOM file does not already exist in the new 
revision
             if await aiofiles.os.path.exists(sbom_path_in_new_revision):
                 raise base.ASFQuartException("SBOM file already exists", 
errorcode=400)
 
         if creating.new is None:
-            raise routes.FlashError("Internal error: New revision not found")
+            raise route.FlashError("Internal error: New revision not found")
 
         # Create and queue the task, using paths within the new revision
         async with storage.write(session.uid) as write:
@@ -268,8 +268,8 @@ async def sbomgen(
     )
 
 
[email protected]("/draft/svnload/<project_name>/<version_name>", 
methods=["POST"])
-async def svnload(session: routes.CommitterSession, project_name: str, 
version_name: str) -> response.Response | str:
[email protected]("/draft/svnload/<project_name>/<version_name>", 
methods=["POST"])
+async def svnload(session: route.CommitterSession, project_name: str, 
version_name: str) -> response.Response | str:
     """Import files from SVN into a draft."""
     await session.check_access(project_name)
 
@@ -312,8 +312,8 @@ async def svnload(session: routes.CommitterSession, 
project_name: str, version_n
     )
 
 
[email protected]("/draft/tools/<project_name>/<version_name>/<path:file_path>")
-async def tools(session: routes.CommitterSession, project_name: str, 
version_name: str, file_path: str) -> str:
[email protected]("/draft/tools/<project_name>/<version_name>/<path:file_path>")
+async def tools(session: route.CommitterSession, project_name: str, 
version_name: str, file_path: str) -> str:
     """Show the tools for a specific file."""
     await session.check_access(project_name)
 
@@ -348,8 +348,8 @@ async def tools(session: routes.CommitterSession, 
project_name: str, version_nam
 
 # TODO: Should we deprecate this and ensure compose covers it all?
 # If we did that, we'd lose the exhaustive use of the abstraction
[email protected]("/draft/view/<project_name>/<version_name>")
-async def view(session: routes.CommitterSession, project_name: str, 
version_name: str) -> response.Response | str:
[email protected]("/draft/view/<project_name>/<version_name>")
+async def view(session: route.CommitterSession, project_name: str, 
version_name: str) -> response.Response | str:
     """View all the files in the rsync upload directory for a release."""
     await session.check_access(project_name)
 
@@ -379,9 +379,9 @@ async def view(session: routes.CommitterSession, 
project_name: str, version_name
     )
 
 
[email protected]("/draft/vote/preview/<project_name>/<version_name>", 
methods=["POST"])
[email protected]("/draft/vote/preview/<project_name>/<version_name>", 
methods=["POST"])
 async def vote_preview(
-    session: routes.CommitterSession, project_name: str, version_name: str
+    session: route.CommitterSession, project_name: str, version_name: str
 ) -> quart.wrappers.response.Response | response.Response | str:
     """Show the vote email preview for a release."""
 
@@ -391,7 +391,7 @@ async def vote_preview(
 
     release = await session.release(project_name, version_name)
     if release.committee is None:
-        raise routes.FlashError("Release has no associated committee")
+        raise route.FlashError("Release has no associated committee")
 
     form_body: str = util.unwrap(form.body.data)
     asfuid = session.uid
diff --git a/atr/routes/file.py b/atr/routes/file.py
index 61a9de8..1879ab6 100644
--- a/atr/routes/file.py
+++ b/atr/routes/file.py
@@ -17,14 +17,14 @@
 
 import werkzeug.wrappers.response as response
 
-import atr.routes as routes
+import atr.route as route
 import atr.template as template
 import atr.util as util
 
 
[email protected]("/file/<project_name>/<version_name>/<path:file_path>")
[email protected]("/file/<project_name>/<version_name>/<path:file_path>")
 async def selected_path(
-    session: routes.CommitterSession, project_name: str, version_name: str, 
file_path: str
+    session: route.CommitterSession, project_name: str, version_name: str, 
file_path: str
 ) -> response.Response | str:
     """View the content of a specific file in the release candidate draft."""
     # TODO: Make this independent of the release phase
diff --git a/atr/routes/finish.py b/atr/routes/finish.py
index 33c373f..e84dc58 100644
--- a/atr/routes/finish.py
+++ b/atr/routes/finish.py
@@ -34,7 +34,7 @@ import atr.db as db
 import atr.forms as forms
 import atr.log as log
 import atr.models.sql as sql
-import atr.routes as routes
+import atr.route as route
 import atr.routes.mapping as mapping
 import atr.routes.root as root
 import atr.storage as storage
@@ -81,7 +81,7 @@ class RemoveRCTagsForm(forms.Typed):
 @dataclasses.dataclass
 class ProcessFormDataArgs:
     formdata: datastructures.MultiDict
-    session: routes.CommitterSession
+    session: route.CommitterSession
     project_name: str
     version_name: str
     move_form: MoveFileForm
@@ -99,9 +99,9 @@ class RCTagAnalysisResult:
     total_paths: int
 
 
[email protected]("/finish/<project_name>/<version_name>", methods=["GET", 
"POST"])
[email protected]("/finish/<project_name>/<version_name>", methods=["GET", 
"POST"])
 async def selected(
-    session: routes.CommitterSession, project_name: str, version_name: str
+    session: route.CommitterSession, project_name: str, version_name: str
 ) -> tuple[quart_response.Response, int] | response.Response | str:
     """Finish a release preview."""
     await session.check_access(project_name)
@@ -245,7 +245,7 @@ async def _deletable_choices(latest_revision_dir: 
pathlib.Path, target_dirs: set
 
 async def _delete_empty_directory(
     dir_to_delete_rel: pathlib.Path,
-    session: routes.CommitterSession,
+    session: route.CommitterSession,
     project_name: str,
     version_name: str,
     respond: Respond,
@@ -266,7 +266,7 @@ async def _delete_empty_directory(
 async def _move_file_to_revision(
     source_files_rel: list[pathlib.Path],
     target_dir_rel: pathlib.Path,
-    session: routes.CommitterSession,
+    session: route.CommitterSession,
     project_name: str,
     version_name: str,
     respond: Respond,
@@ -307,7 +307,7 @@ async def _move_file_to_revision(
 
 
 async def _remove_rc_tags(
-    session: routes.CommitterSession,
+    session: route.CommitterSession,
     project_name: str,
     version_name: str,
     respond: Respond,
diff --git a/atr/routes/ignores.py b/atr/routes/ignores.py
index f5b451a..8bd1c20 100644
--- a/atr/routes/ignores.py
+++ b/atr/routes/ignores.py
@@ -33,7 +33,7 @@ from htpy import (
 
 import atr.forms as forms
 import atr.models.sql as sql
-import atr.routes as routes
+import atr.route as route
 import atr.storage as storage
 import atr.template as template
 import atr.util as util
@@ -104,8 +104,8 @@ class UpdateIgnoreForm(forms.Typed):
     submit = forms.submit("Update ignore")
 
 
[email protected]("/ignores/<committee_name>", methods=["GET", "POST"])
-async def ignores(session: routes.CommitterSession, committee_name: str) -> 
str | response.Response:
[email protected]("/ignores/<committee_name>", methods=["GET", "POST"])
+async def ignores(session: route.CommitterSession, committee_name: str) -> str 
| response.Response:
     async with storage.read() as read:
         ragp = read.as_general_public()
         ignores = await ragp.checks.ignores(committee_name)
@@ -121,8 +121,8 @@ async def ignores(session: routes.CommitterSession, 
committee_name: str) -> str
     return await template.blank("Ignored checks", content)
 
 
[email protected]("/ignores/<committee_name>/add", methods=["POST"])
-async def ignores_committee_add(session: routes.CommitterSession, 
committee_name: str) -> str | response.Response:
[email protected]("/ignores/<committee_name>/add", methods=["POST"])
+async def ignores_committee_add(session: route.CommitterSession, 
committee_name: str) -> str | response.Response:
     data = await quart.request.form
     form = await AddIgnoreForm.create_form(data=data)
     if not (await form.validate_on_submit()):
@@ -149,8 +149,8 @@ async def ignores_committee_add(session: 
routes.CommitterSession, committee_name
     )
 
 
[email protected]("/ignores/<committee_name>/delete", methods=["POST"])
-async def ignores_committee_delete(session: routes.CommitterSession, 
committee_name: str) -> str | response.Response:
[email protected]("/ignores/<committee_name>/delete", methods=["POST"])
+async def ignores_committee_delete(session: route.CommitterSession, 
committee_name: str) -> str | response.Response:
     data = await quart.request.form
     form = await DeleteIgnoreForm.create_form(data=data)
     if not (await form.validate_on_submit()):
@@ -179,8 +179,8 @@ async def ignores_committee_delete(session: 
routes.CommitterSession, committee_n
     )
 
 
[email protected]("/ignores/<committee_name>/update", methods=["POST"])
-async def ignores_committee_update(session: routes.CommitterSession, 
committee_name: str) -> str | response.Response:
[email protected]("/ignores/<committee_name>/update", methods=["POST"])
+async def ignores_committee_update(session: route.CommitterSession, 
committee_name: str) -> str | response.Response:
     data = await quart.request.form
     form = await UpdateIgnoreForm.create_form(data=data)
     if not (await form.validate_on_submit()):
diff --git a/atr/routes/keys.py b/atr/routes/keys.py
index d3faf87..0a4bc29 100644
--- a/atr/routes/keys.py
+++ b/atr/routes/keys.py
@@ -33,7 +33,7 @@ import atr.db as db
 import atr.forms as forms
 import atr.log as log
 import atr.models.sql as sql
-import atr.routes as routes
+import atr.route as route
 import atr.routes.compose as compose
 import atr.storage as storage
 import atr.storage.outcome as outcome
@@ -133,8 +133,8 @@ class UploadKeyFormBase(forms.Typed):
         return True
 
 
[email protected]("/keys/add", methods=["GET", "POST"])
-async def add(session: routes.CommitterSession) -> str:
[email protected]("/keys/add", methods=["GET", "POST"])
+async def add(session: route.CommitterSession) -> str:
     """Add a new public signing key to the user's account."""
     key_info = None
 
@@ -172,7 +172,7 @@ async def add(session: routes.CommitterSession) -> str:
             form = await AddOpenPGPKeyForm.create_form()
             forms.choices(form.selected_committees, committee_choices)
 
-        except routes.FlashError as e:
+        except route.FlashError as e:
             log.warning("FlashError adding OpenPGP key: %s", e)
             await quart.flash(str(e), "error")
         except Exception as e:
@@ -185,12 +185,12 @@ async def add(session: routes.CommitterSession) -> str:
         user_committees=participant_of_committees,
         form=form,
         key_info=key_info,
-        algorithms=routes.algorithms,
+        algorithms=route.algorithms,
     )
 
 
[email protected]("/keys/delete", methods=["POST"])
-async def delete(session: routes.CommitterSession) -> response.Response:
[email protected]("/keys/delete", methods=["POST"])
+async def delete(session: route.CommitterSession) -> response.Response:
     """Delete a public signing key or SSH key from the user's account."""
     form = await DeleteKeyForm.create_form(data=await quart.request.form)
 
@@ -221,8 +221,8 @@ async def delete(session: routes.CommitterSession) -> 
response.Response:
             return await session.redirect(keys, error=f"Error deleting key: 
{error}")
 
 
[email protected]("/keys/details/<fingerprint>", methods=["GET", "POST"])
-async def details(session: routes.CommitterSession, fingerprint: str) -> str | 
response.Response:
[email protected]("/keys/details/<fingerprint>", methods=["GET", "POST"])
+async def details(session: route.CommitterSession, fingerprint: str) -> str | 
response.Response:
     """Display details for a specific OpenPGP key."""
     fingerprint = fingerprint.lower()
     user_committees = []
@@ -274,14 +274,14 @@ async def details(session: routes.CommitterSession, 
fingerprint: str) -> str | r
         "keys-details.html",
         key=key,
         form=form,
-        algorithms=routes.algorithms,
+        algorithms=route.algorithms,
         now=datetime.datetime.now(datetime.UTC),
         asf_id=session.uid,
     )
 
 
[email protected]("/keys/export/<committee_name>")
-async def export(session: routes.CommitterSession, committee_name: str) -> 
quart.Response:
[email protected]("/keys/export/<committee_name>")
+async def export(session: route.CommitterSession, committee_name: str) -> 
quart.Response:
     """Export a KEYS file for a specific committee."""
     async with storage.write() as write:
         wafc = write.as_foundation_committer()
@@ -290,9 +290,9 @@ async def export(session: routes.CommitterSession, 
committee_name: str) -> quart
     return quart.Response(keys_file_text, mimetype="text/plain")
 
 
[email protected]("/keys/import/<project_name>/<version_name>", 
methods=["POST"])
[email protected]("/keys/import/<project_name>/<version_name>", 
methods=["POST"])
 async def import_selected_revision(
-    session: routes.CommitterSession, project_name: str, version_name: str
+    session: route.CommitterSession, project_name: str, version_name: str
 ) -> response.Response:
     await util.validate_empty_form()
 
@@ -311,8 +311,8 @@ async def import_selected_revision(
     )
 
 
[email protected]("/keys")
-async def keys(session: routes.CommitterSession) -> str:
[email protected]("/keys")
+async def keys(session: route.CommitterSession) -> str:
     """View all keys associated with the user's account."""
     committees_to_query = list(set(session.committees + session.projects))
 
@@ -335,7 +335,7 @@ async def keys(session: routes.CommitterSession) -> str:
         user_keys=user_keys,
         user_ssh_keys=user_ssh_keys,
         committees=user_committees_with_keys,
-        algorithms=routes.algorithms,
+        algorithms=route.algorithms,
         status_message=status_message,
         status_type=status_type,
         now=datetime.datetime.now(datetime.UTC),
@@ -346,8 +346,8 @@ async def keys(session: routes.CommitterSession) -> str:
     )
 
 
[email protected]("/keys/ssh/add", methods=["GET", "POST"])
-async def ssh_add(session: routes.CommitterSession) -> response.Response | str:
[email protected]("/keys/ssh/add", methods=["GET", "POST"])
+async def ssh_add(session: route.CommitterSession) -> response.Response | str:
     """Add a new SSH key to the user's account."""
     # TODO: Make an auth.require wrapper that gives the session automatically
     # And the form if it's a POST handler? Might be hard to type
@@ -379,8 +379,8 @@ async def ssh_add(session: routes.CommitterSession) -> 
response.Response | str:
     )
 
 
[email protected]("/keys/update-committee-keys/<committee_name>", 
methods=["POST"])
-async def update_committee_keys(session: routes.CommitterSession, 
committee_name: str) -> response.Response:
[email protected]("/keys/update-committee-keys/<committee_name>", 
methods=["POST"])
+async def update_committee_keys(session: route.CommitterSession, 
committee_name: str) -> response.Response:
     """Generate and save the KEYS file for a specific committee."""
     form = await UpdateCommitteeKeysForm.create_form()
     if not await form.validate_on_submit():
@@ -399,8 +399,8 @@ async def update_committee_keys(session: 
routes.CommitterSession, committee_name
     return await session.redirect(keys)
 
 
[email protected]("/keys/upload", methods=["GET", "POST"])
-async def upload(session: routes.CommitterSession) -> str:
[email protected]("/keys/upload", methods=["GET", "POST"])
+async def upload(session: route.CommitterSession) -> str:
     """Upload a KEYS file containing multiple OpenPGP keys."""
     async with storage.write() as write:
         participant_of_committees = await write.participant_of_committees()
@@ -447,7 +447,7 @@ async def upload(session: routes.CommitterSession) -> str:
             committee_map=committee_map,
             form=form,
             results=results,
-            algorithms=routes.algorithms,
+            algorithms=route.algorithms,
             submitted_committees=submitted_committees,
         )
 
@@ -503,7 +503,7 @@ async def _get_keys_text(keys_url: str, render: 
Callable[[str], Awaitable[str]])
 
 
 async def _key_and_is_owner(
-    data: db.Session, session: routes.CommitterSession, fingerprint: str
+    data: db.Session, session: route.CommitterSession, fingerprint: str
 ) -> tuple[sql.PublicSigningKey, bool]:
     key = await data.public_signing_key(fingerprint=fingerprint, 
_committees=True).get()
     if not key:
diff --git a/atr/routes/mapping.py b/atr/routes/mapping.py
index 3f96462..c26ddcf 100644
--- a/atr/routes/mapping.py
+++ b/atr/routes/mapping.py
@@ -20,7 +20,7 @@ from collections.abc import Callable
 import werkzeug.wrappers.response as response
 
 import atr.models.sql as sql
-import atr.routes as routes
+import atr.route as route
 import atr.routes.compose as compose
 import atr.routes.finish as finish
 import atr.routes.release as routes_release
@@ -28,7 +28,7 @@ import atr.routes.vote as vote
 import atr.util as util
 
 
-async def release_as_redirect(session: routes.CommitterSession, release: 
sql.Release) -> response.Response:
+async def release_as_redirect(session: route.CommitterSession, release: 
sql.Release) -> response.Response:
     route = release_as_route(release)
     if route is routes_release.finished:
         return await session.redirect(route, project_name=release.project.name)
diff --git a/atr/routes/modules.py b/atr/routes/modules.py
deleted file mode 100644
index a63a4f6..0000000
--- a/atr/routes/modules.py
+++ /dev/null
@@ -1,89 +0,0 @@
-# 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.
-
-# These are imported for their side effects and for access in templates
-import atr.routes.announce as announce
-import atr.routes.candidate as candidate
-import atr.routes.committees as committees
-import atr.routes.compose as compose
-import atr.routes.distribution as distribution
-import atr.routes.download as download
-import atr.routes.draft as draft
-import atr.routes.file as file
-import atr.routes.finish as finish
-import atr.routes.ignores as ignores
-import atr.routes.keys as keys
-import atr.routes.preview as preview
-import atr.routes.projects as projects
-import atr.routes.published as published
-import atr.routes.release as release
-import atr.routes.report as report
-import atr.routes.resolve as resolve
-import atr.routes.revisions as revisions
-import atr.routes.root as root
-import atr.routes.sbom as sbom
-import atr.routes.start as start
-import atr.routes.tokens as tokens
-import atr.routes.upload as upload
-import atr.routes.vote as vote
-import atr.routes.voting as voting
-
-
-# Export data for a custom linter script
-def _export_routes() -> None:
-    import asyncio
-
-    async def _export_routes_async() -> None:
-        """Export all routes to a JSON file for static analysis."""
-        import json
-        import sys
-
-        import aiofiles
-
-        route_paths: list[str] = []
-        current_module = sys.modules[__name__]
-
-        for module_name in dir(current_module):
-            if module_name.startswith("_"):
-                # Not intended for external use
-                continue
-
-            module = getattr(current_module, module_name)
-            if not hasattr(module, "__file__"):
-                # Not a module
-                continue
-
-            # Get all callable interfaces that do not begin with an underscore
-            for attr_name in dir(module):
-                if attr_name.startswith("_"):
-                    # Not intended for external use
-                    continue
-                if not callable(getattr(module, attr_name)):
-                    # Not callable
-                    continue
-                route_path = f"{module_name}.{attr_name}"
-                route_paths.append(route_path)
-
-        async with aiofiles.open("routes.json", "w", encoding="utf-8") as f:
-            await f.write(json.dumps(route_paths, indent=2))
-
-    loop = asyncio.get_event_loop()
-    loop.run_until_complete(_export_routes_async())
-
-
-_export_routes()
-del _export_routes
diff --git a/atr/routes/preview.py b/atr/routes/preview.py
index 927ab61..d3c832d 100644
--- a/atr/routes/preview.py
+++ b/atr/routes/preview.py
@@ -25,7 +25,7 @@ import atr.construct as construct
 import atr.forms as forms
 import atr.log as log
 import atr.models.sql as sql
-import atr.routes as routes
+import atr.route as route
 import atr.routes.root as root
 import atr.storage as storage
 import atr.template as template
@@ -52,9 +52,9 @@ class DeleteForm(forms.Typed):
     submit = forms.submit("Delete preview")
 
 
[email protected]("/preview/announce/<project_name>/<version_name>", 
methods=["POST"])
[email protected]("/preview/announce/<project_name>/<version_name>", 
methods=["POST"])
 async def announce_preview(
-    session: routes.CommitterSession, project_name: str, version_name: str
+    session: route.CommitterSession, project_name: str, version_name: str
 ) -> quart.wrappers.response.Response | str:
     """Generate a preview of the announcement email body."""
 
@@ -83,8 +83,8 @@ async def announce_preview(
         return quart.Response(f"Error generating preview: {e!s}", status=500, 
mimetype="text/plain")
 
 
[email protected]("/preview/delete", methods=["POST"])
-async def delete(session: routes.CommitterSession) -> response.Response:
[email protected]("/preview/delete", methods=["POST"])
+async def delete(session: route.CommitterSession) -> response.Response:
     """Delete a preview and all its associated files."""
     form = await DeleteForm.create_form(data=await quart.request.form)
 
@@ -110,8 +110,8 @@ async def delete(session: routes.CommitterSession) -> 
response.Response:
     return await session.redirect(root.index, success="Preview deleted 
successfully")
 
 
[email protected]("/preview/view/<project_name>/<version_name>")
-async def view(session: routes.CommitterSession, project_name: str, 
version_name: str) -> response.Response | str:
[email protected]("/preview/view/<project_name>/<version_name>")
+async def view(session: route.CommitterSession, project_name: str, 
version_name: str) -> response.Response | str:
     """View all the files in the rsync upload directory for a release."""
     await session.check_access(project_name)
 
@@ -141,9 +141,9 @@ async def view(session: routes.CommitterSession, 
project_name: str, version_name
     )
 
 
[email protected]("/preview/view/<project_name>/<version_name>/<path:file_path>")
[email protected]("/preview/view/<project_name>/<version_name>/<path:file_path>")
 async def view_path(
-    session: routes.CommitterSession, project_name: str, version_name: str, 
file_path: str
+    session: route.CommitterSession, project_name: str, version_name: str, 
file_path: str
 ) -> response.Response | str:
     """View the content of a specific file in the release preview."""
     await session.check_access(project_name)
diff --git a/atr/routes/projects.py b/atr/routes/projects.py
index 6217591..80270a7 100644
--- a/atr/routes/projects.py
+++ b/atr/routes/projects.py
@@ -34,7 +34,7 @@ import atr.log as log
 import atr.models.policy as policy
 import atr.models.sql as sql
 import atr.registry as registry
-import atr.routes as routes
+import atr.route as route
 import atr.storage as storage
 import atr.template as template
 import atr.user as user
@@ -229,8 +229,8 @@ class ReleasePolicyForm(forms.Typed):
         return not self.errors
 
 
[email protected]("/project/add/<committee_name>", methods=["GET", "POST"])
-async def add_project(session: routes.CommitterSession, committee_name: str) 
-> response.Response | str:
[email protected]("/project/add/<committee_name>", methods=["GET", "POST"])
+async def add_project(session: route.CommitterSession, committee_name: str) -> 
response.Response | str:
     await session.check_access_committee(committee_name)
 
     async with db.session() as data:
@@ -254,8 +254,8 @@ You must start with your committee label, and you must use 
lower case.
     return await template.render("project-add-project.html", form=form, 
committee_name=committee.display_name)
 
 
[email protected]("/project/delete", methods=["POST"])
-async def delete(session: routes.CommitterSession) -> response.Response:
[email protected]("/project/delete", methods=["POST"])
+async def delete(session: route.CommitterSession) -> response.Response:
     """Delete a project created by the user."""
     # TODO: This is not truly empty, so make a form object for this
     await util.validate_empty_form()
@@ -276,16 +276,16 @@ async def delete(session: routes.CommitterSession) -> 
response.Response:
     return await session.redirect(projects, success=f"Project '{project_name}' 
deleted successfully.")
 
 
[email protected]("/projects")
-async def projects(session: routes.CommitterSession | None) -> str:
[email protected]("/projects")
+async def projects(session: route.CommitterSession | None) -> str:
     """Main project directory page."""
     async with db.session() as data:
         projects = await 
data.project(_committee=True).order_by(sql.Project.full_name).all()
         return await template.render("projects.html", projects=projects, 
empty_form=await forms.Empty.create_form())
 
 
[email protected]("/project/select")
-async def select(session: routes.CommitterSession) -> str:
[email protected]("/project/select")
+async def select(session: route.CommitterSession) -> str:
     """Select a project to work on."""
     user_projects = []
     if session.uid:
@@ -307,8 +307,8 @@ async def select(session: routes.CommitterSession) -> str:
     return await template.render("project-select.html", 
user_projects=user_projects)
 
 
[email protected]("/projects/<name>", methods=["GET", "POST"])
-async def view(session: routes.CommitterSession, name: str) -> 
response.Response | str:
[email protected]("/projects/<name>", methods=["GET", "POST"])
+async def view(session: route.CommitterSession, name: str) -> 
response.Response | str:
     policy_form = None
     metadata_form = None
     can_edit = False
@@ -357,7 +357,7 @@ async def view(session: routes.CommitterSession, name: str) 
-> response.Response
     return await template.render(
         "project-view.html",
         project=project,
-        algorithms=routes.algorithms,
+        algorithms=route.algorithms,
         candidate_drafts=candidate_drafts,
         candidates=candidates,
         previews=previews,
diff --git a/atr/routes/published.py b/atr/routes/published.py
index a3d9ed7..175ddd7 100644
--- a/atr/routes/published.py
+++ b/atr/routes/published.py
@@ -22,12 +22,12 @@ from datetime import datetime
 import aiofiles.os
 import quart
 
-import atr.routes as routes
+import atr.route as route
 import atr.util as util
 
 
[email protected]("/published/<path:path>")
-async def path(session: routes.CommitterSession, path: str) -> quart.Response:
[email protected]("/published/<path:path>")
+async def path(session: route.CommitterSession, path: str) -> quart.Response:
     """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
@@ -35,8 +35,8 @@ async def path(session: routes.CommitterSession, path: str) 
-> quart.Response:
     return await _path(session, path)
 
 
[email protected]("/published/")
-async def root(session: routes.CommitterSession) -> quart.Response:
[email protected]("/published/")
+async def root(session: route.CommitterSession) -> quart.Response:
     return await _path(session, "")
 
 
@@ -91,7 +91,7 @@ async def _file_content(full_path: pathlib.Path) -> 
quart.Response:
     return await quart.send_file(full_path)
 
 
-async def _path(session: routes.CommitterSession, path: str) -> quart.Response:
+async def _path(session: route.CommitterSession, path: str) -> quart.Response:
     downloads_path = util.get_downloads_dir()
     full_path = downloads_path / path
     if await aiofiles.os.path.isdir(full_path):
diff --git a/atr/routes/release.py b/atr/routes/release.py
index 0b90a7b..5b2cc4e 100644
--- a/atr/routes/release.py
+++ b/atr/routes/release.py
@@ -26,7 +26,7 @@ import werkzeug.wrappers.response as response
 import atr.db as db
 import atr.db.interaction as interaction
 import atr.models.sql as sql
-import atr.routes as routes
+import atr.route as route
 import atr.template as template
 import atr.util as util
 
@@ -34,8 +34,8 @@ if asfquart.APP is ...:
     raise RuntimeError("APP is not set")
 
 
[email protected]("/releases/finished/<project_name>")
-async def finished(session: routes.CommitterSession | None, project_name: str) 
-> str:
[email protected]("/releases/finished/<project_name>")
+async def finished(session: route.CommitterSession | None, project_name: str) 
-> str:
     """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(
@@ -58,8 +58,8 @@ async def finished(session: routes.CommitterSession | None, 
project_name: str) -
     )
 
 
[email protected]("/releases")
-async def releases(session: routes.CommitterSession | None) -> str:
[email protected]("/releases")
+async def releases(session: route.CommitterSession | None) -> str:
     """View all releases."""
     # Releases are public, so we don't need to filter by user
     async with db.session() as data:
@@ -83,8 +83,8 @@ async def releases(session: routes.CommitterSession | None) 
-> str:
     )
 
 
[email protected]("/release/select/<project_name>")
-async def select(session: routes.CommitterSession, project_name: str) -> str:
[email protected]("/release/select/<project_name>")
+async def select(session: route.CommitterSession, project_name: str) -> str:
     """Show releases in progress for a project."""
     await session.check_access(project_name)
 
@@ -98,10 +98,8 @@ async def select(session: routes.CommitterSession, 
project_name: str) -> str:
     )
 
 
[email protected]("/release/view/<project_name>/<version_name>")
-async def view(
-    session: routes.CommitterSession | None, project_name: str, version_name: 
str
-) -> response.Response | str:
[email protected]("/release/view/<project_name>/<version_name>")
+async def view(session: route.CommitterSession | None, project_name: str, 
version_name: str) -> response.Response | str:
     """View all the files in the rsync upload directory for a release."""
     async with db.session() as data:
         release_name = sql.release_name(project_name, version_name)
@@ -127,9 +125,9 @@ async def view(
     )
 
 
[email protected]("/release/view/<project_name>/<version_name>/<path:file_path>")
[email protected]("/release/view/<project_name>/<version_name>/<path:file_path>")
 async def view_path(
-    session: routes.CommitterSession | None, project_name: str, version_name: 
str, file_path: str
+    session: route.CommitterSession | None, project_name: str, version_name: 
str, file_path: str
 ) -> response.Response | str:
     """View the content of a specific file in the final release."""
     async with db.session() as data:
diff --git a/atr/routes/report.py b/atr/routes/report.py
index 05b52ec..70b349e 100644
--- a/atr/routes/report.py
+++ b/atr/routes/report.py
@@ -23,14 +23,14 @@ import asfquart.base as base
 
 import atr.forms as forms
 import atr.models.sql as sql
-import atr.routes as routes
+import atr.route as route
 import atr.storage as storage
 import atr.template as template
 import atr.util as util
 
 
[email protected]("/report/<project_name>/<version_name>/<path:rel_path>")
-async def selected_path(session: routes.CommitterSession, project_name: str, 
version_name: str, rel_path: str) -> str:
[email protected]("/report/<project_name>/<version_name>/<path:rel_path>")
+async def selected_path(session: route.CommitterSession, project_name: str, 
version_name: str, rel_path: str) -> str:
     """Show the report for a specific file."""
     await session.check_access(project_name)
 
diff --git a/atr/routes/resolve.py b/atr/routes/resolve.py
index c50d635..9a255c1 100644
--- a/atr/routes/resolve.py
+++ b/atr/routes/resolve.py
@@ -21,7 +21,7 @@ import werkzeug.wrappers.response as response
 
 import atr.forms as forms
 import atr.models.sql as sql
-import atr.routes as routes
+import atr.route as route
 import atr.routes.compose as compose
 import atr.routes.finish as finish
 import atr.routes.vote as vote
@@ -60,8 +60,8 @@ class ResolveVoteManualForm(forms.Typed):
     submit = forms.submit("Resolve vote")
 
 
[email protected]("/resolve/manual/<project_name>/<version_name>")
-async def manual_selected(session: routes.CommitterSession, project_name: str, 
version_name: str) -> str:
[email protected]("/resolve/manual/<project_name>/<version_name>")
+async def manual_selected(session: route.CommitterSession, project_name: str, 
version_name: str) -> str:
     """Get the manual vote resolution page."""
     await session.check_access(project_name)
 
@@ -82,9 +82,9 @@ async def manual_selected(session: routes.CommitterSession, 
project_name: str, v
     )
 
 
[email protected]("/resolve/manual/<project_name>/<version_name>", 
methods=["POST"])
[email protected]("/resolve/manual/<project_name>/<version_name>", 
methods=["POST"])
 async def manual_selected_post(
-    session: routes.CommitterSession, project_name: str, version_name: str
+    session: route.CommitterSession, project_name: str, version_name: str
 ) -> response.Response | str:
     """Post the manual vote resolution page."""
     await session.check_access(project_name)
@@ -122,9 +122,9 @@ async def manual_selected_post(
     )
 
 
[email protected]("/resolve/submit/<project_name>/<version_name>", 
methods=["POST"])
[email protected]("/resolve/submit/<project_name>/<version_name>", 
methods=["POST"])
 async def submit_selected(
-    session: routes.CommitterSession, project_name: str, version_name: str
+    session: route.CommitterSession, project_name: str, version_name: str
 ) -> response.Response | str:
     """Resolve a vote."""
     await session.check_access(project_name)
@@ -164,8 +164,8 @@ async def submit_selected(
     )
 
 
[email protected]("/resolve/tabulated/<project_name>/<version_name>", 
methods=["POST"])
-async def tabulated_selected_post(session: routes.CommitterSession, 
project_name: str, version_name: str) -> str:
[email protected]("/resolve/tabulated/<project_name>/<version_name>", 
methods=["POST"])
+async def tabulated_selected_post(session: route.CommitterSession, 
project_name: str, version_name: str) -> str:
     """Tabulate votes."""
     await session.check_access(project_name)
     asf_uid = session.uid
diff --git a/atr/routes/revisions.py b/atr/routes/revisions.py
index c805f15..25dfc16 100644
--- a/atr/routes/revisions.py
+++ b/atr/routes/revisions.py
@@ -31,13 +31,13 @@ import atr.forms as forms
 import atr.models.schema as schema
 import atr.models.sql as sql
 import atr.revision as revision
-import atr.routes as routes
+import atr.route as route
 import atr.template as template
 import atr.util as util
 
 
[email protected]("/revisions/<project_name>/<version_name>")
-async def selected(session: routes.CommitterSession, project_name: str, 
version_name: str) -> str:
[email protected]("/revisions/<project_name>/<version_name>")
+async def selected(session: route.CommitterSession, 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)
 
@@ -92,8 +92,8 @@ async def selected(session: routes.CommitterSession, 
project_name: str, version_
     )
 
 
[email protected]("/revisions/<project_name>/<version_name>", methods=["POST"])
-async def selected_post(session: routes.CommitterSession, project_name: str, 
version_name: str) -> response.Response:
[email protected]("/revisions/<project_name>/<version_name>", methods=["POST"])
+async def selected_post(session: route.CommitterSession, project_name: str, 
version_name: str) -> response.Response:
     """Set a specific revision as the latest for a candidate draft or release 
preview."""
     await session.check_access(project_name)
 
diff --git a/atr/routes/root.py b/atr/routes/root.py
index 60f160e..6cdc4af 100644
--- a/atr/routes/root.py
+++ b/atr/routes/root.py
@@ -27,7 +27,7 @@ import sqlmodel
 
 import atr.db as db
 import atr.models.sql as sql
-import atr.routes as routes
+import atr.route as route
 import atr.template as template
 import atr.user as user
 import atr.util as util
@@ -64,14 +64,14 @@ _POLICIES: Final = htpy.div[
 ]
 
 
[email protected]("/about")
-async def about(session: routes.CommitterSession) -> str:
[email protected]("/about")
+async def about(session: route.CommitterSession) -> str:
     """About page."""
     return await template.render("about.html")
 
 
[email protected]("/")
-async def index(session: routes.CommitterSession | None) -> response.Response 
| str:
[email protected]("/")
+async def index(session: route.CommitterSession | None) -> response.Response | 
str:
     """Show public info or an entry portal for participants."""
     session_data = await asfquart.session.read()
     if session_data:
@@ -145,18 +145,18 @@ async def index(session: routes.CommitterSession | None) 
-> response.Response |
     return await template.render("index-public.html")
 
 
[email protected]("/policies")
-async def policies(session: routes.CommitterSession | None) -> str:
[email protected]("/policies")
+async def policies(session: route.CommitterSession | None) -> str:
     return await template.blank("Policies", content=_POLICIES)
 
 
[email protected]("/todo", methods=["POST"])
-async def todo(session: routes.CommitterSession) -> str:
[email protected]("/todo", methods=["POST"])
+async def todo(session: route.CommitterSession) -> str:
     """POST target for development."""
     return await template.render("todo.html")
 
 
[email protected]("/tutorial")
-async def tutorial(session: routes.CommitterSession) -> str:
[email protected]("/tutorial")
+async def tutorial(session: route.CommitterSession) -> str:
     """Tutorial page."""
     return await template.render("tutorial.html")
diff --git a/atr/routes/sbom.py b/atr/routes/sbom.py
index a0f6531..4ac1a73 100644
--- a/atr/routes/sbom.py
+++ b/atr/routes/sbom.py
@@ -32,7 +32,7 @@ import atr.htm as htm
 import atr.log as log
 import atr.models.results as results
 import atr.models.sql as sql
-import atr.routes as routes
+import atr.route as route
 import atr.sbomtool as sbomtool
 import atr.storage as storage
 import atr.template as template
@@ -42,9 +42,9 @@ if TYPE_CHECKING:
     import werkzeug.wrappers.response as response
 
 
[email protected]("/sbom/augment/<project_name>/<version_name>/<path:file_path>",
 methods=["POST"])
[email protected]("/sbom/augment/<project_name>/<version_name>/<path:file_path>",
 methods=["POST"])
 async def augment(
-    session: routes.CommitterSession, project_name: str, version_name: str, 
file_path: str
+    session: route.CommitterSession, project_name: str, version_name: str, 
file_path: str
 ) -> response.Response:
     """Augment a CycloneDX SBOM file."""
     await session.check_access(project_name)
@@ -87,8 +87,8 @@ async def augment(
     )
 
 
[email protected]("/sbom/report/<project>/<version>/<path:file_path>")
-async def report(session: routes.CommitterSession, project: str, version: str, 
file_path: str) -> str:
[email protected]("/sbom/report/<project>/<version>/<path:file_path>")
+async def report(session: route.CommitterSession, project: str, version: str, 
file_path: str) -> str:
     await session.check_access(project)
     await session.release(project, version)
     async with db.session() as data:
diff --git a/atr/routes/start.py b/atr/routes/start.py
index a794a14..d7b146a 100644
--- a/atr/routes/start.py
+++ b/atr/routes/start.py
@@ -24,7 +24,7 @@ import atr.db as db
 import atr.db.interaction as interaction
 import atr.forms as forms
 import atr.models.sql as sql
-import atr.routes as routes
+import atr.route as route
 import atr.routes.compose as compose
 import atr.storage as storage
 import atr.template as template
@@ -40,8 +40,8 @@ class StartReleaseForm(forms.Typed):
     submit = forms.submit("Start new release")
 
 
[email protected]("/start/<project_name>", methods=["GET", "POST"])
-async def selected(session: routes.CommitterSession, project_name: str) -> 
response.Response | str:
[email protected]("/start/<project_name>", methods=["GET", "POST"])
+async def selected(session: route.CommitterSession, project_name: str) -> 
response.Response | str:
     """Allow the user to start a new release draft, or handle its 
submission."""
     await session.check_access(project_name)
 
@@ -71,7 +71,7 @@ async def selected(session: routes.CommitterSession, 
project_name: str) -> respo
                 version_name=new_release.version,
                 success="Release candidate draft created successfully",
             )
-        except (routes.FlashError, base.ASFQuartException) as e:
+        except (route.FlashError, base.ASFQuartException) as e:
             # Flash the error and let the code fall through to render the 
template below
             await quart.flash(str(e), "error")
 
@@ -79,4 +79,4 @@ async def selected(session: routes.CommitterSession, 
project_name: str) -> respo
     releases = await interaction.all_releases(project)
 
     # Render the template for GET requests or POST requests with validation 
errors
-    return await template.render("start-selected.html", project=project, 
form=form, routes=routes, releases=releases)
+    return await template.render("start-selected.html", project=project, 
form=form, releases=releases)
diff --git a/atr/routes/tokens.py b/atr/routes/tokens.py
index 1653fda..3926bd9 100644
--- a/atr/routes/tokens.py
+++ b/atr/routes/tokens.py
@@ -51,7 +51,7 @@ import atr.forms as forms
 import atr.jwtoken as jwtoken
 import atr.log as log
 import atr.models.sql as sql
-import atr.routes as routes
+import atr.route as route
 import atr.storage as storage
 import atr.template as templates
 import atr.util as util
@@ -76,16 +76,16 @@ class IssueJWTForm(forms.Typed):
     submit = forms.submit("Generate JWT")
 
 
[email protected]("/tokens/jwt", methods=["POST"])
-async def jwt_post(session: routes.CommitterSession) -> quart.Response:
[email protected]("/tokens/jwt", methods=["POST"])
+async def jwt_post(session: route.CommitterSession) -> quart.Response:
     await util.validate_empty_form()
 
     jwt_token = jwtoken.issue(session.uid)
     return quart.Response(jwt_token, mimetype="text/plain")
 
 
[email protected]("/tokens", methods=["GET", "POST"])
-async def tokens(session: routes.CommitterSession) -> str | response.Response:
[email protected]("/tokens", methods=["GET", "POST"])
+async def tokens(session: route.CommitterSession) -> str | response.Response:
     request_form = await quart.request.form
 
     if is_post := quart.request.method == "POST":
@@ -265,7 +265,7 @@ async def _fetch_tokens(data: db.Session, uid: str) -> 
list[sql.PersonalAccessTo
 
 
 async def _handle_post(
-    session: routes.CommitterSession, request_form: datastructures.MultiDict
+    session: route.CommitterSession, request_form: datastructures.MultiDict
 ) -> response.Response | None:
     if "token_id" in request_form:
         return await _handle_delete_token_post(session, request_form)
@@ -277,7 +277,7 @@ async def _handle_post(
 
 
 async def _handle_add_token_post(
-    session: routes.CommitterSession, request_form: datastructures.MultiDict
+    session: route.CommitterSession, request_form: datastructures.MultiDict
 ) -> response.Response | None:
     add_form = await AddTokenForm.create_form(data=request_form)
     if await add_form.validate_on_submit():
@@ -298,7 +298,7 @@ async def _handle_add_token_post(
 
 
 async def _handle_delete_token_post(
-    session: routes.CommitterSession, request_form: datastructures.MultiDict
+    session: route.CommitterSession, request_form: datastructures.MultiDict
 ) -> response.Response | None:
     del_form = await DeleteTokenForm.create_form(data=request_form)
     if await del_form.validate_on_submit():
@@ -312,7 +312,7 @@ async def _handle_delete_token_post(
 
 
 async def _handle_issue_jwt_post(
-    session: routes.CommitterSession, request_form: datastructures.MultiDict
+    session: route.CommitterSession, request_form: datastructures.MultiDict
 ) -> response.Response | None:
     issue_form = await IssueJWTForm.create_form(data=request_form)
     if await issue_form.validate_on_submit():
diff --git a/atr/routes/upload.py b/atr/routes/upload.py
index 21ad2d3..d2010bf 100644
--- a/atr/routes/upload.py
+++ b/atr/routes/upload.py
@@ -24,7 +24,7 @@ import wtforms
 import atr.db as db
 import atr.forms as forms
 import atr.log as log
-import atr.routes as routes
+import atr.route as route
 import atr.routes.compose as compose
 import atr.storage as storage
 import atr.template as template
@@ -61,8 +61,8 @@ class SvnImportForm(forms.Typed):
     submit = forms.submit("Queue SVN import task")
 
 
[email protected]("/upload/<project_name>/<version_name>", methods=["GET", 
"POST"])
-async def selected(session: routes.CommitterSession, project_name: str, 
version_name: str) -> response.Response | str:
[email protected]("/upload/<project_name>/<version_name>", methods=["GET", 
"POST"])
+async def selected(session: route.CommitterSession, project_name: str, 
version_name: str) -> response.Response | str:
     """Show a page to allow the user to add files to a candidate draft."""
     await session.check_access(project_name)
 
diff --git a/atr/routes/vote.py b/atr/routes/vote.py
index b9d685a..ace6331 100644
--- a/atr/routes/vote.py
+++ b/atr/routes/vote.py
@@ -25,7 +25,7 @@ import atr.forms as forms
 import atr.log as log
 import atr.models.results as results
 import atr.models.sql as sql
-import atr.routes as routes
+import atr.route as route
 import atr.routes.compose as compose
 import atr.routes.mapping as mapping
 import atr.storage as storage
@@ -40,8 +40,8 @@ class CastVoteForm(forms.Typed):
     submit = forms.submit("Submit vote")
 
 
[email protected]("/vote/<project_name>/<version_name>")
-async def selected(session: routes.CommitterSession, project_name: str, 
version_name: str) -> response.Response | str:
[email protected]("/vote/<project_name>/<version_name>")
+async def selected(session: route.CommitterSession, project_name: str, 
version_name: str) -> response.Response | str:
     """Show the contents of the release candidate draft."""
     await session.check_access(project_name)
 
@@ -115,8 +115,8 @@ async def selected(session: routes.CommitterSession, 
project_name: str, version_
     )
 
 
[email protected]("/vote/<project_name>/<version_name>", methods=["POST"])
-async def selected_post(session: routes.CommitterSession, project_name: str, 
version_name: str) -> response.Response:
[email protected]("/vote/<project_name>/<version_name>", methods=["POST"])
+async def selected_post(session: route.CommitterSession, project_name: str, 
version_name: str) -> response.Response:
     """Handle submission of a vote."""
     await session.check_access(project_name)
 
diff --git a/atr/routes/voting.py b/atr/routes/voting.py
index 51f12d3..4fe4787 100644
--- a/atr/routes/voting.py
+++ b/atr/routes/voting.py
@@ -28,7 +28,7 @@ import atr.db.interaction as interaction
 import atr.forms as forms
 import atr.log as log
 import atr.models.sql as sql
-import atr.routes as routes
+import atr.route as route
 import atr.routes.compose as compose
 import atr.routes.root as root
 import atr.routes.vote as vote
@@ -55,9 +55,9 @@ class VoteInitiateForm(forms.Typed):
     submit = forms.submit("Send vote email")
 
 
[email protected]("/voting/<project_name>/<version_name>/<revision>", 
methods=["GET", "POST"])
[email protected]("/voting/<project_name>/<version_name>/<revision>", 
methods=["GET", "POST"])
 async def selected_revision(
-    session: routes.CommitterSession, project_name: str, version_name: str, 
revision: str
+    session: route.CommitterSession, project_name: str, version_name: str, 
revision: str
 ) -> response.Response | str:
     """Show the vote initiation form for a release."""
     await session.check_access(project_name)
@@ -114,7 +114,7 @@ async def selected_revision(
 async def start_vote_manual(
     release: sql.Release,
     selected_revision_number: str,
-    session: routes.CommitterSession,
+    session: route.CommitterSession,
     data: db.Session,
 ) -> response.Response | str:
     # This verifies the state and sets the phase to RELEASE_CANDIDATE
@@ -193,7 +193,7 @@ async def _selected_revision_data(
     version_name: str,
     revision: str,
     data: db.Session,
-    session: routes.CommitterSession,
+    session: route.CommitterSession,
 ) -> response.Response | str | VoteInitiateForm:
     committee = release.committee
     if committee is None:
diff --git a/atr/server.py b/atr/server.py
index b8e3727..a46320e 100644
--- a/atr/server.py
+++ b/atr/server.py
@@ -125,8 +125,8 @@ def app_setup_context(app: base.QuartApp) -> None:
     @app.context_processor
     async def app_wide() -> dict[str, Any]:
         import atr.metadata as metadata
+        import atr.routes as routes
         import atr.routes.mapping as mapping
-        import atr.routes.modules as modules
 
         return {
             "as_url": util.as_url,
@@ -135,7 +135,7 @@ def app_setup_context(app: base.QuartApp) -> None:
             "is_admin_fn": user.is_admin,
             "is_viewing_as_admin_fn": util.is_user_viewing_as_admin,
             "is_committee_member_fn": user.is_committee_member,
-            "routes": modules,
+            "routes": routes,
             "unfinished_releases_fn": interaction.unfinished_releases,
             # "user_committees_fn": interaction.user_committees,
             "user_projects_fn": interaction.user_projects,
@@ -313,7 +313,7 @@ def main() -> None:
 
 def register_routes(app: base.QuartApp) -> ModuleType:
     # NOTE: These imports are for their side effects only
-    import atr.routes.modules as modules
+    import atr.routes as routes
 
     # Add a global error handler to show helpful error messages with tracebacks
     @app.errorhandler(Exception)
@@ -350,7 +350,7 @@ def register_routes(app: base.QuartApp) -> ModuleType:
             return quart.jsonify({"error": "404 Not Found"}), 404
         return await template.render("notfound.html", error="404 Not Found", 
traceback="", status_code=404), 404
 
-    return modules
+    return routes
 
 
 # FIXME: when running in SSL mode, you will receive these exceptions upon 
termination at times:
diff --git a/pyproject.toml b/pyproject.toml
index b975f46..6f08b1f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -133,6 +133,5 @@ select = [
 "atr/db/__init__.py" = ["C901"]
 "atr/models/cyclonedx/__init__.py" = ["N815"]
 "atr/models/cyclonedx/spdx.py" = ["N815"]
-"atr/routes/modules.py" = ["F401"]
 "atr/sbomtool.py" = ["C901"]
 "migrations/env.py" = ["E402"]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]


Reply via email to