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]