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-releases.git


The following commit(s) were added to refs/heads/main by this push:
     new 88a14e1  Split session enhancement into a separate module
88a14e1 is described below

commit 88a14e11e0127c274221f5a5e67514d798a445c8
Author: Sean B. Palmer <[email protected]>
AuthorDate: Sun Oct 26 19:11:09 2025 +0000

    Split session enhancement into a separate module
---
 atr/blueprints/get.py   |  42 ++++++++---
 atr/config.py           |   2 +
 atr/get/example_test.py |   4 +-
 atr/log.py              |  34 ++++++++-
 atr/route.py            |   2 +-
 atr/session.py          | 191 ++++++++++++++++++++++++++++++++++++++++++++++++
 6 files changed, 260 insertions(+), 15 deletions(-)

diff --git a/atr/blueprints/get.py b/atr/blueprints/get.py
index 54df06a..460644c 100644
--- a/atr/blueprints/get.py
+++ b/atr/blueprints/get.py
@@ -15,16 +15,18 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import time
 from collections.abc import Awaitable, Callable
 from types import ModuleType
 from typing import Any
 
 import asfquart.auth as auth
 import asfquart.base as base
-import asfquart.session as session
+import asfquart.session
 import quart
 
-import atr.route as route
+import atr.log as log
+import atr.session as session
 
 _BLUEPRINT = quart.Blueprint("get_blueprint", __name__)
 
@@ -36,15 +38,33 @@ def register(app: base.QuartApp) -> ModuleType:
     return get
 
 
-def committer(path: str) -> Callable[[route.CommitterRouteHandler[Any]], 
route.RouteHandler[Any]]:
-    def decorator(func: route.CommitterRouteHandler[Any]) -> 
route.RouteHandler[Any]:
+def committer(path: str) -> Callable[[session.CommitterRouteFunction[Any]], 
session.RouteFunction[Any]]:
+    def decorator(func: session.CommitterRouteFunction[Any]) -> 
session.RouteFunction[Any]:
         async def wrapper(*args: Any, **kwargs: Any) -> Any:
-            web_session = await session.read()
+            web_session = await asfquart.session.read()
             if web_session is None:
                 raise base.ASFQuartException("Not authenticated", 
errorcode=401)
 
-            enhanced_session = route.CommitterSession(web_session)
-            return await func(enhanced_session, *args, **kwargs)
+            enhanced_session = session.Committer(web_session)
+            start_time_ns = time.perf_counter_ns()
+            response = await func(enhanced_session, *args, **kwargs)
+            end_time_ns = time.perf_counter_ns()
+            total_ns = end_time_ns - start_time_ns
+            total_ms = total_ns // 1_000_000
+
+            # TODO: Make this configurable in config.py
+            log.performance(
+                "%s %s %s %s %s %s %s",
+                "GET",
+                path,
+                func.__name__,
+                "=",
+                0,
+                0,
+                total_ms,
+            )
+
+            return response
 
         wrapper.__name__ = func.__name__
         wrapper.__doc__ = func.__doc__
@@ -59,11 +79,11 @@ def committer(path: str) -> 
Callable[[route.CommitterRouteHandler[Any]], route.R
     return decorator
 
 
-def public(path: str) -> Callable[[Callable[..., Awaitable[Any]]], 
route.RouteHandler[Any]]:
-    def decorator(func: Callable[..., Awaitable[Any]]) -> 
route.RouteHandler[Any]:
+def public(path: str) -> Callable[[Callable[..., Awaitable[Any]]], 
session.RouteFunction[Any]]:
+    def decorator(func: Callable[..., Awaitable[Any]]) -> 
session.RouteFunction[Any]:
         async def wrapper(*args: Any, **kwargs: Any) -> Any:
-            web_session = await session.read()
-            enhanced_session = route.CommitterSession(web_session) if 
web_session else None
+            web_session = await asfquart.session.read()
+            enhanced_session = session.Committer(web_session) if web_session 
else None
             return await func(enhanced_session, *args, **kwargs)
 
         wrapper.__name__ = func.__name__
diff --git a/atr/config.py b/atr/config.py
index 85056ff..fcbcc21 100644
--- a/atr/config.py
+++ b/atr/config.py
@@ -72,6 +72,7 @@ class AppConfig:
     SVN_STORAGE_DIR = os.path.join(STATE_DIR, "svn")
     SQLITE_DB_PATH = decouple.config("SQLITE_DB_PATH", default="atr.db")
     STORAGE_AUDIT_LOG_FILE = os.path.join(STATE_DIR, "storage-audit.log")
+    PERFORMANCE_LOG_FILE = os.path.join(STATE_DIR, "route-performance.log")
 
     # Apache RAT configuration
     APACHE_RAT_JAR_PATH = decouple.config("APACHE_RAT_JAR_PATH", 
default=f"/opt/tools/apache-rat-{_RAT_VERSION}.jar")
@@ -148,6 +149,7 @@ def get() -> type[AppConfig]:
         (config.UNFINISHED_STORAGE_DIR, "UNFINISHED_STORAGE_DIR"),
         (config.SVN_STORAGE_DIR, "SVN_STORAGE_DIR"),
         (config.STORAGE_AUDIT_LOG_FILE, "STORAGE_AUDIT_LOG_FILE"),
+        (config.PERFORMANCE_LOG_FILE, "PERFORMANCE_LOG_FILE"),
     ]
     relative_paths = [
         (config.SQLITE_DB_PATH, "SQLITE_DB_PATH"),
diff --git a/atr/get/example_test.py b/atr/get/example_test.py
index 7850f03..b418bd2 100644
--- a/atr/get/example_test.py
+++ b/atr/get/example_test.py
@@ -16,11 +16,11 @@
 # under the License.
 
 import atr.blueprints.get as get
-import atr.route as route
+import atr.session as session
 
 
 @get.committer("/example/test")
-async def respond(session: route.CommitterSession) -> str:
+async def respond(session: session.Committer) -> str:
     return f"""\
 <h1>Test route</h1>
 <p>Hello, {session.asf_uid}!</p>
diff --git a/atr/log.py b/atr/log.py
index 2088320..a506543 100644
--- a/atr/log.py
+++ b/atr/log.py
@@ -19,7 +19,9 @@ from __future__ import annotations
 
 import inspect
 import logging
-from typing import Any
+import logging.handlers
+import queue
+from typing import Any, Final
 
 
 def caller_name(depth: int = 1) -> str:
@@ -120,3 +122,33 @@ def _event(level: int, msg: str, *args: Any, stacklevel: 
int = 3, **kwargs: Any)
     # Stack level 1 is *here*, 2 is the caller, 3 is the caller of the caller
     # I.e. _event (1), log.* (2), actual caller (3)
     logger.log(level, msg, *args, stacklevel=stacklevel, **kwargs)
+
+
+def _performance_logger() -> logging.Logger:
+    import atr.config as config
+
+    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"
+
+    performance: Final = logging.getLogger("log.performance")
+    # Use custom formatter that properly includes microseconds
+    # TODO: Is this actually UTC?
+    performance_handler: Final = 
logging.FileHandler(config.get().PERFORMANCE_LOG_FILE, encoding="utf-8")
+    performance_handler.setFormatter(MicrosecondsFormatter("%(asctime)s - 
%(message)s"))
+    performance_queue = queue.Queue(-1)
+    performance_listener = logging.handlers.QueueListener(performance_queue, 
performance_handler)
+    performance_listener.start()
+    performance.addHandler(logging.handlers.QueueHandler(performance_queue))
+    performance.setLevel(logging.INFO)
+    # If we don't set propagate to False then it logs to the term as well
+    performance.propagate = False
+
+    return performance
+
+
+PERFORMANCE: Final = _performance_logger()
+
+
+def performance(msg: str, *args: Any, **kwargs: Any) -> None:
+    PERFORMANCE.info(msg, *args, **kwargs)
diff --git a/atr/route.py b/atr/route.py
index faca13f..e4860dc 100644
--- a/atr/route.py
+++ b/atr/route.py
@@ -298,7 +298,7 @@ class MicrosecondsFormatter(logging.Formatter):
 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: Final[AsyncFileHandler] = 
AsyncFileHandler("deprecated-route-performance.log")
 route_logger_handler.setFormatter(MicrosecondsFormatter("%(asctime)s - 
%(message)s"))
 route_logger.addHandler(route_logger_handler)
 route_logger.setLevel(logging.INFO)
diff --git a/atr/session.py b/atr/session.py
new file mode 100644
index 0000000..4fec710
--- /dev/null
+++ b/atr/session.py
@@ -0,0 +1,191 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any, Protocol, TypeVar
+
+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, Sequence
+
+    import werkzeug.wrappers.response as response
+
+
+R = TypeVar("R", covariant=True)
+
+
+class CommitterRouteFunction(Protocol[R]):
+    """Protocol for @committer_get decorated functions."""
+
+    __name__: str
+    __doc__: str | None
+
+    def __call__(self, session: Committer, *args: Any, **kwargs: Any) -> 
Awaitable[R]: ...
+
+
+class Committer:
+    """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: CommitterRouteFunction[R], success: str | None = None, 
error: str | None = None, **kwargs: Any
+    ) -> response.Response:
+        """Redirect to a route with a success or error message."""
+        return await redirect(route, success, error, **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 RouteFunction(Protocol[R]):
+    """Protocol for @app_route decorated functions."""
+
+    __name__: str
+    __doc__: str | None
+
+    def __call__(self, *args: Any, **kwargs: Any) -> Awaitable[R]: ...
+
+
+async def redirect[R](
+    route: RouteFunction[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))


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

Reply via email to