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 1d3a5a3 Add a directory browser for each phase
1d3a5a3 is described below
commit 1d3a5a35c2bb787ffdce1d022449ee99da13fdd7
Author: Sean B. Palmer <[email protected]>
AuthorDate: Fri Mar 28 15:09:12 2025 +0200
Add a directory browser for each phase
---
atr/routes/__init__.py | 48 +++++++++++
atr/routes/candidate.py | 29 +++++++
atr/routes/draft.py | 63 +++++++++++----
atr/routes/preview.py | 28 +++++++
atr/routes/release.py | 27 +++++++
.../{draft-list.html => draft-files.html} | 0
atr/templates/phase-viewer.html | 94 ++++++++++++++++++++++
atr/util.py | 27 ++++++-
8 files changed, 298 insertions(+), 18 deletions(-)
diff --git a/atr/routes/__init__.py b/atr/routes/__init__.py
index 535f173..6d96c40 100644
--- a/atr/routes/__init__.py
+++ b/atr/routes/__init__.py
@@ -18,6 +18,7 @@
from __future__ import annotations
import asyncio
+import datetime
import functools
import logging
import time
@@ -265,6 +266,11 @@ def app_route_performance_measure(route_path: str,
http_methods: list[str] | Non
return decorator
+def format_datetime(timestamp: int) -> str:
+ """Format a Unix timestamp into a human readable datetime string."""
+ return datetime.datetime.fromtimestamp(timestamp,
tz=datetime.UTC).strftime("%Y-%m-%d %H:%M:%S")
+
+
def format_file_size(size_in_bytes: int) -> str:
"""Format a file size with appropriate units and comma-separated digits."""
# Format the raw bytes with commas
@@ -284,6 +290,48 @@ def format_file_size(size_in_bytes: int) -> str:
return f"{formatted_bytes} bytes"
+def format_permissions(mode: int) -> str:
+ """Format Unix file permissions in ls -l style."""
+ # File type
+ if mode & 0o040000:
+ # Directory
+ perms = "d"
+ elif mode & 0o0100000:
+ # Regular file
+ perms = "-"
+ elif mode & 0o020000:
+ # Character special
+ perms = "c"
+ elif mode & 0o060000:
+ # Block special
+ perms = "b"
+ elif mode & 0o010000:
+ # FIFO
+ perms = "p"
+ elif mode & 0o0140000:
+ # Socket
+ perms = "s"
+ else:
+ perms = "?"
+
+ # Owner permissions
+ perms += "r" if mode & 0o400 else "-"
+ perms += "w" if mode & 0o200 else "-"
+ perms += "x" if mode & 0o100 else "-"
+
+ # Group permissions
+ perms += "r" if mode & 0o040 else "-"
+ perms += "w" if mode & 0o020 else "-"
+ perms += "x" if mode & 0o010 else "-"
+
+ # Others permissions
+ perms += "r" if mode & 0o004 else "-"
+ perms += "w" if mode & 0o002 else "-"
+ perms += "x" if mode & 0o001 else "-"
+
+ return perms
+
+
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
diff --git a/atr/routes/candidate.py b/atr/routes/candidate.py
index 2c6f714..5665f0c 100644
--- a/atr/routes/candidate.py
+++ b/atr/routes/candidate.py
@@ -64,6 +64,35 @@ async def resolve(session: routes.CommitterSession) ->
response.Response | str:
return await _resolve_post(session)
[email protected]("/candidate/viewer/<project_name>/<version_name>")
+async def viewer(session: routes.CommitterSession, project_name: str,
version_name: str) -> response.Response | str:
+ """Show all the files in the rsync upload directory for a release."""
+ # Check that the user has access to the project
+ if not any((p.name == project_name) for p in (await
session.user_projects)):
+ return await session.redirect(vote, error="You do not have access to
this project")
+
+ # Check that the release exists
+ async with db.session() as data:
+ release = await data.release(name=f"{project_name}-{version_name}",
_project=True).demand(
+ base.ASFQuartException("Release does not exist", errorcode=404)
+ )
+
+ # Convert async generator to list
+ file_stats = [
+ stat async for stat in
util.content_list(util.get_release_candidate_dir(), project_name, version_name)
+ ]
+
+ return await quart.render_template(
+ "phase-viewer.html",
+ file_stats=file_stats,
+ release=release,
+ format_datetime=routes.format_datetime,
+ format_file_size=routes.format_file_size,
+ format_permissions=routes.format_permissions,
+ phase="release candidate",
+ )
+
+
@routes.committer("/candidate/vote")
async def vote(session: routes.CommitterSession) -> str:
"""Show all release candidates to which the user has access."""
diff --git a/atr/routes/draft.py b/atr/routes/draft.py
index 0c52d7a..b409dbb 100644
--- a/atr/routes/draft.py
+++ b/atr/routes/draft.py
@@ -56,6 +56,22 @@ class AddProtocol(Protocol):
project_name: wtforms.SelectField
+class DeleteForm(util.QuartFormTyped):
+ """Form for deleting a candidate draft."""
+
+ candidate_draft_name = wtforms.StringField(
+ "Candidate draft name",
validators=[wtforms.validators.InputRequired("Candidate draft name is
required")]
+ )
+ confirm_delete = wtforms.StringField(
+ "Confirmation",
+ validators=[
+ wtforms.validators.InputRequired("Confirmation is required"),
+ wtforms.validators.Regexp("^DELETE$", message="Please type DELETE
to confirm"),
+ ],
+ )
+ submit = wtforms.SubmitField("Delete candidate draft")
+
+
async def _number_of_release_files(release: models.Release) -> int:
"""Return the number of files in the release."""
path_project = release.project.name
@@ -232,22 +248,6 @@ async def add_project(
)
-class DeleteForm(util.QuartFormTyped):
- """Form for deleting a candidate draft."""
-
- candidate_draft_name = wtforms.StringField(
- "Candidate draft name",
validators=[wtforms.validators.InputRequired("Candidate draft name is
required")]
- )
- confirm_delete = wtforms.StringField(
- "Confirmation",
- validators=[
- wtforms.validators.InputRequired("Confirmation is required"),
- wtforms.validators.Regexp("^DELETE$", message="Please type DELETE
to confirm"),
- ],
- )
- submit = wtforms.SubmitField("Delete candidate draft")
-
-
@routes.committer("/draft/delete", methods=["POST"])
async def delete(session: routes.CommitterSession) -> response.Response:
"""Delete a candidate draft and all its associated files."""
@@ -353,7 +353,7 @@ async def files(session: routes.CommitterSession,
project_name: str, version_nam
path_tasks[path] = await db.recent_tasks(data,
f"{project_name}-{version_name}", str(path), path_modified[path])
return await quart.render_template(
- "draft-list.html",
+ "draft-files.html",
asf_id=session.uid,
project_name=project_name,
version_name=version_name,
@@ -640,6 +640,35 @@ async def tools(session: routes.CommitterSession,
project_name: str, version_nam
)
[email protected]("/draft/viewer/<project_name>/<version_name>")
+async def viewer(session: routes.CommitterSession, project_name: str,
version_name: str) -> response.Response | str:
+ """Show all the files in the rsync upload directory for a release."""
+ # Check that the user has access to the project
+ if not any((p.name == project_name) for p in (await
session.user_projects)):
+ return await session.redirect(add, error="You do not have access to
this project")
+
+ # Check that the release exists
+ async with db.session() as data:
+ release = await data.release(name=f"{project_name}-{version_name}",
_project=True).demand(
+ base.ASFQuartException("Release does not exist", errorcode=404)
+ )
+
+ # Convert async generator to list
+ file_stats = [
+ stat async for stat in
util.content_list(util.get_release_candidate_draft_dir(), project_name,
version_name)
+ ]
+
+ return await quart.render_template(
+ "phase-viewer.html",
+ file_stats=file_stats,
+ release=release,
+ format_datetime=routes.format_datetime,
+ format_file_size=routes.format_file_size,
+ format_permissions=routes.format_permissions,
+ phase="release candidate draft",
+ )
+
+
async def _delete_candidate_draft(data: db.Session, candidate_draft_name: str)
-> None:
"""Delete a candidate draft and all its associated files."""
# Check that the release exists
diff --git a/atr/routes/preview.py b/atr/routes/preview.py
index 4d517e8..3bd8712 100644
--- a/atr/routes/preview.py
+++ b/atr/routes/preview.py
@@ -22,6 +22,7 @@ import logging
import aiofiles.os
import aioshutil
import asfquart
+import asfquart.base as base
import quart
import werkzeug.wrappers.response as response
import wtforms
@@ -200,6 +201,33 @@ async def review(session: routes.CommitterSession) -> str:
)
[email protected]("/preview/viewer/<project_name>/<version_name>")
+async def viewer(session: routes.CommitterSession, project_name: str,
version_name: str) -> response.Response | str:
+ """Show all the files in the rsync upload directory for a release."""
+ # Check that the user has access to the project
+ if not any((p.name == project_name) for p in (await
session.user_projects)):
+ return await session.redirect(review, error="You do not have access to
this project")
+
+ # Check that the release exists
+ async with db.session() as data:
+ release = await data.release(name=f"{project_name}-{version_name}",
_project=True).demand(
+ base.ASFQuartException("Release does not exist", errorcode=404)
+ )
+
+ # Convert async generator to list
+ file_stats = [stat async for stat in
util.content_list(util.get_release_preview_dir(), project_name, version_name)]
+
+ return await quart.render_template(
+ "phase-viewer.html",
+ file_stats=file_stats,
+ release=release,
+ format_datetime=routes.format_datetime,
+ format_file_size=routes.format_file_size,
+ format_permissions=routes.format_permissions,
+ phase="release preview",
+ )
+
+
async def _delete_preview(data: db.Session, preview_name: str) -> None:
"""Delete a release preview and all its associated files."""
# Check that the release exists
diff --git a/atr/routes/release.py b/atr/routes/release.py
index 816eac7..ec4163c 100644
--- a/atr/routes/release.py
+++ b/atr/routes/release.py
@@ -21,6 +21,7 @@ import logging
import logging.handlers
import asfquart
+import asfquart.base as base
import quart
import werkzeug.wrappers.response as response
@@ -28,6 +29,7 @@ import atr.db as db
import atr.db.models as models
import atr.routes as routes
import atr.routes.candidate as candidate
+import atr.util as util
if asfquart.APP is ...:
raise RuntimeError("APP is not set")
@@ -91,3 +93,28 @@ async def review(session: routes.CommitterSession) -> str:
"release-review.html",
releases=list(releases_before_announcement) +
list(releases_after_announcement),
)
+
+
[email protected]("/release/viewer/<project_name>/<version_name>")
+async def viewer(session: routes.CommitterSession, project_name: str,
version_name: str) -> response.Response | str:
+ """Show all the files in the rsync upload directory for a release."""
+ # Releases are public, so we don't need to filter by user
+
+ # Check that the release exists
+ async with db.session() as data:
+ release = await data.release(name=f"{project_name}-{version_name}",
_project=True).demand(
+ base.ASFQuartException("Release does not exist", errorcode=404)
+ )
+
+ # Convert async generator to list
+ file_stats = [stat async for stat in
util.content_list(util.get_release_dir(), project_name, version_name)]
+
+ return await quart.render_template(
+ "phase-viewer.html",
+ file_stats=file_stats,
+ release=release,
+ format_datetime=routes.format_datetime,
+ format_file_size=routes.format_file_size,
+ format_permissions=routes.format_permissions,
+ phase="release",
+ )
diff --git a/atr/templates/draft-list.html b/atr/templates/draft-files.html
similarity index 100%
rename from atr/templates/draft-list.html
rename to atr/templates/draft-files.html
diff --git a/atr/templates/phase-viewer.html b/atr/templates/phase-viewer.html
new file mode 100644
index 0000000..47d7125
--- /dev/null
+++ b/atr/templates/phase-viewer.html
@@ -0,0 +1,94 @@
+{% extends "layouts/base.html" %}
+
+{% block title %}
+ Viewer for {{ release.project.display_name }} {{ release.version }} ~ ATR
+{% endblock title %}
+
+{% block description %}
+ View the files for the {{ release.project.display_name }} {{ release.version
}} {{ phase }}.
+{% endblock description %}
+
+{% block content %}
+ <h1>Viewer for {{ release.project.display_name }} {{ release.version }}</h1>
+ <p class="intro">
+ This page shows the files for the {{ release.project.display_name }} {{
release.version }} <strong>{{ phase }}</strong>.
+ </p>
+
+ <div class="card mb-4">
+ <div class="card-header d-flex justify-content-between align-items-center">
+ <h5 class="mb-0">Release information</h5>
+ </div>
+ <div class="card-body">
+ <div class="row">
+ <div class="col-md-6">
+ <p>
+ <strong>Project:</strong> {{ release.project.display_name }}
+ </p>
+ <p>
+ <strong>Version:</strong> {{ release.version }}
+ </p>
+ <p>
+ <strong>Label:</strong> {{ release.name }}
+ </p>
+ </div>
+ <div class="col-md-6">
+ <p>
+ <strong>Stage:</strong> <span class="badge bg-{% if release.stage
== 'CURRENT' %}success{% elif release.stage == 'CANDIDATE' %}warning{% elif
release.stage == 'BUILD' %}info{% else %}secondary{% endif %}">{{
release.stage.value.upper() }}</span>
+ </p>
+ <p>
+ <strong>Phase:</strong> <span class="badge bg-info">{{
release.phase.value.upper() }}</span>
+ </p>
+ <p>
+ <strong>Created:</strong> {{ release.created.strftime("%Y-%m-%d
%H:%M") }}
+ </p>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="card mb-4">
+ <div class="card-header d-flex justify-content-between align-items-center">
+ <h5 class="mb-0">Files</h5>
+ </div>
+ <div class="card-body">
+ {% if file_stats|length > 0 %}
+ <div class="table-responsive">
+ <table class="table table-striped table-hover">
+ <thead>
+ <tr>
+ <th>Permissions</th>
+ <th>File path</th>
+ <th>Size</th>
+ <th>Modified</th>
+ </tr>
+ </thead>
+ <tbody>
+ {% for stat in file_stats %}
+ <tr>
+ <td>{{ format_permissions(stat.permissions) }}</td>
+ <td>
+ {% if stat.is_file %}
+ {{ stat.path }}
+ {% else %}
+ <strong>{{ stat.path }}/</strong>
+ {% endif %}
+ </td>
+ <td>
+ {% if stat.is_file %}
+ {{ format_file_size(stat.size) }}
+ {% else %}
+ -
+ {% endif %}
+ </td>
+ <td>{{ format_datetime(stat.modified) }}</td>
+ </tr>
+ {% endfor %}
+ </tbody>
+ </table>
+ </div>
+ {% else %}
+ <div class="alert alert-info">This {{ phase }} does not have any
files.</div>
+ {% endif %}
+ </div>
+ </div>
+{% endblock content %}
diff --git a/atr/util.py b/atr/util.py
index dbbe814..539b1b0 100644
--- a/atr/util.py
+++ b/atr/util.py
@@ -18,7 +18,7 @@
import dataclasses
import hashlib
import pathlib
-from collections.abc import Callable, Mapping, Sequence
+from collections.abc import AsyncGenerator, Callable, Mapping, Sequence
from typing import Annotated, Any, TypeVar
import aiofiles.os
@@ -62,6 +62,16 @@ class DictToList:
)
[email protected]
+class FileStat:
+ path: str
+ modified: int
+ size: int
+ permissions: int
+ is_file: bool
+ is_dir: bool
+
+
class QuartFormTyped(quart_wtf.QuartForm):
"""Quart form with type annotations."""
@@ -101,6 +111,21 @@ async def compute_sha512(file_path: pathlib.Path) -> str:
return sha512.hexdigest()
+async def content_list(phase_subdir: pathlib.Path, project_name: str,
version_name: str) -> AsyncGenerator[FileStat]:
+ """List all the files in the given path."""
+ base_path = phase_subdir / project_name / version_name
+ for path in await paths_recursive(base_path):
+ stat = await aiofiles.os.stat(base_path / path)
+ yield FileStat(
+ path=str(path),
+ modified=int(stat.st_mtime),
+ size=stat.st_size,
+ permissions=stat.st_mode,
+ is_file=bool(stat.st_mode & 0o0100000),
+ is_dir=bool(stat.st_mode & 0o040000),
+ )
+
+
async def file_sha3(path: str) -> str:
"""Compute SHA3-256 hash of a file."""
sha3 = hashlib.sha3_256()
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]