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 1844140 Add release statistics to the download page
1844140 is described below
commit 1844140cdadc90c9d4a45af07e47419516c3acb6
Author: Sean B. Palmer <[email protected]>
AuthorDate: Fri May 2 09:49:08 2025 +0100
Add release statistics to the download page
Resolves #55.
---
atr/routes/__init__.py | 78 ---------------------------
atr/routes/candidate.py | 8 +--
atr/routes/compose.py | 2 +-
atr/routes/download.py | 1 +
atr/routes/draft.py | 8 +--
atr/routes/file.py | 4 +-
atr/routes/preview.py | 8 +--
atr/routes/release.py | 12 ++---
atr/routes/report.py | 2 +-
atr/routes/resolve.py | 2 +-
atr/routes/root.py | 3 +-
atr/templates/download-all.html | 12 +++++
atr/util.py | 116 ++++++++++++++++++++++++++++++++++++++++
13 files changed, 154 insertions(+), 102 deletions(-)
diff --git a/atr/routes/__init__.py b/atr/routes/__init__.py
index 28e7b06..0654620 100644
--- a/atr/routes/__init__.py
+++ b/atr/routes/__init__.py
@@ -18,7 +18,6 @@
from __future__ import annotations
import asyncio
-import datetime
import functools
import logging
import time
@@ -425,83 +424,6 @@ def committer(
return decorator
-def format_datetime(dt_obj: datetime.datetime | int) -> str:
- """Format a datetime object or Unix timestamp into a human readable
datetime string."""
- # Integers are unix timestamps
- if isinstance(dt_obj, int):
- dt_obj = datetime.datetime.fromtimestamp(dt_obj, tz=datetime.UTC)
-
- # Ensure UTC native timezone awareness
- if dt_obj.tzinfo is None:
- dt_obj = dt_obj.replace(tzinfo=datetime.UTC)
- else:
- # Convert to UTC if not already
- dt_obj = dt_obj.astimezone(datetime.UTC)
-
- return dt_obj.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
- formatted_bytes = f"{size_in_bytes:,}"
-
- # Calculate the appropriate unit
- if size_in_bytes >= 1_000_000_000:
- size_in_gb = size_in_bytes // 1_000_000_000
- return f"{size_in_gb:,} GB ({formatted_bytes} bytes)"
- elif size_in_bytes >= 1_000_000:
- size_in_mb = size_in_bytes // 1_000_000
- return f"{size_in_mb:,} MB ({formatted_bytes} bytes)"
- elif size_in_bytes >= 1_000:
- size_in_kb = size_in_bytes // 1_000
- return f"{size_in_kb:,} KB ({formatted_bytes} bytes)"
- else:
- 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 4c8813b..ba72035 100644
--- a/atr/routes/candidate.py
+++ b/atr/routes/candidate.py
@@ -63,9 +63,9 @@ async def view(session: routes.CommitterSession,
project_name: str, version_name
"phase-view.html",
file_stats=file_stats,
release=release,
- format_datetime=routes.format_datetime,
- format_file_size=routes.format_file_size,
- format_permissions=routes.format_permissions,
+ format_datetime=util.format_datetime,
+ format_file_size=util.format_file_size,
+ format_permissions=util.format_permissions,
phase="release candidate",
phase_key="candidate",
)
@@ -93,7 +93,7 @@ async def view_path(
is_text=is_text,
is_truncated=is_truncated,
error_message=error_message,
- format_file_size=routes.format_file_size,
+ format_file_size=util.format_file_size,
phase_key="candidate",
content_listing=content_listing,
)
diff --git a/atr/routes/compose.py b/atr/routes/compose.py
index 26a7a2e..5254361 100644
--- a/atr/routes/compose.py
+++ b/atr/routes/compose.py
@@ -119,7 +119,7 @@ async def check(
asf_id=session.uid,
server_domain=session.host,
user_ssh_keys=user_ssh_keys,
- format_datetime=routes.format_datetime,
+ format_datetime=util.format_datetime,
models=models,
task_mid=task_mid,
form=form,
diff --git a/atr/routes/download.py b/atr/routes/download.py
index 0bb1ad4..7d13f5a 100644
--- a/atr/routes/download.py
+++ b/atr/routes/download.py
@@ -57,6 +57,7 @@ async def all_selected(
server_domain=session.host,
user_ssh_keys=user_ssh_keys,
back_url=back_url,
+ get_release_stats=util.get_release_stats,
)
diff --git a/atr/routes/draft.py b/atr/routes/draft.py
index 09b4d5a..f5d89e8 100644
--- a/atr/routes/draft.py
+++ b/atr/routes/draft.py
@@ -426,7 +426,7 @@ async def tools(session: routes.CommitterSession,
project_name: str, version_nam
file_path=file_path,
file_data=file_data,
release=release,
- format_file_size=routes.format_file_size,
+ format_file_size=util.format_file_size,
)
@@ -450,9 +450,9 @@ async def view(session: routes.CommitterSession,
project_name: str, version_name
"phase-view.html",
file_stats=file_stats,
release=release,
- format_datetime=routes.format_datetime,
- format_file_size=routes.format_file_size,
- format_permissions=routes.format_permissions,
+ format_datetime=util.format_datetime,
+ format_file_size=util.format_file_size,
+ format_permissions=util.format_permissions,
phase="release candidate draft",
phase_key="draft",
)
diff --git a/atr/routes/file.py b/atr/routes/file.py
index cd04bcb..2063d34 100644
--- a/atr/routes/file.py
+++ b/atr/routes/file.py
@@ -52,7 +52,7 @@ async def selected_path(
is_truncated=is_truncated,
error_message=error_message,
content_listing=content_listing,
- format_file_size=routes.format_file_size,
+ format_file_size=util.format_file_size,
phase_key="draft",
- max_view_size=routes.format_file_size(_max_view_size),
+ max_view_size=util.format_file_size(_max_view_size),
)
diff --git a/atr/routes/preview.py b/atr/routes/preview.py
index 38030a3..541745a 100644
--- a/atr/routes/preview.py
+++ b/atr/routes/preview.py
@@ -128,9 +128,9 @@ async def view(session: routes.CommitterSession,
project_name: str, version_name
"phase-view.html",
file_stats=file_stats,
release=release,
- format_datetime=routes.format_datetime,
- format_file_size=routes.format_file_size,
- format_permissions=routes.format_permissions,
+ format_datetime=util.format_datetime,
+ format_file_size=util.format_file_size,
+ format_permissions=util.format_permissions,
phase="release preview",
phase_key="preview",
)
@@ -188,7 +188,7 @@ async def view_path(
is_text=is_text,
is_truncated=is_truncated,
error_message=error_message,
- format_file_size=routes.format_file_size,
+ format_file_size=util.format_file_size,
phase_key="preview",
content_listing=content_listing,
)
diff --git a/atr/routes/release.py b/atr/routes/release.py
index ade1536..ebce917 100644
--- a/atr/routes/release.py
+++ b/atr/routes/release.py
@@ -91,7 +91,7 @@ async def completed(project_name: str) -> str:
releases = sorted(releases, key=sort_releases, reverse=True)
return await quart.render_template(
- "releases-completed.html", project=project, releases=releases,
format_datetime=routes.format_datetime
+ "releases-completed.html", project=project, releases=releases,
format_datetime=util.format_datetime
)
@@ -132,7 +132,7 @@ async def select(session: routes.CommitterSession,
project_name: str) -> str:
)
releases = await project.releases_in_progress
return await quart.render_template(
- "release-select.html", project=project, releases=releases,
format_datetime=routes.format_datetime
+ "release-select.html", project=project, releases=releases,
format_datetime=util.format_datetime
)
@@ -153,9 +153,9 @@ async def view(project_name: str, version_name: str) ->
response.Response | str:
"phase-view.html",
file_stats=file_stats,
release=release,
- format_datetime=routes.format_datetime,
- format_file_size=routes.format_file_size,
- format_permissions=routes.format_permissions,
+ format_datetime=util.format_datetime,
+ format_file_size=util.format_file_size,
+ format_permissions=util.format_permissions,
phase="release",
phase_key="release",
)
@@ -183,7 +183,7 @@ async def view_path(project_name: str, version_name: str,
file_path: str) -> res
is_text=is_text,
is_truncated=is_truncated,
error_message=error_message,
- format_file_size=routes.format_file_size,
+ format_file_size=util.format_file_size,
phase_key="release",
content_listing=content_listing,
)
diff --git a/atr/routes/report.py b/atr/routes/report.py
index ff6c708..83923f0 100644
--- a/atr/routes/report.py
+++ b/atr/routes/report.py
@@ -80,5 +80,5 @@ async def selected_path(session: routes.CommitterSession,
project_name: str, ver
package=file_data,
release=release,
check_results=check_results_list,
- format_file_size=routes.format_file_size,
+ format_file_size=util.format_file_size,
)
diff --git a/atr/routes/resolve.py b/atr/routes/resolve.py
index 6d5fb46..d001a7c 100644
--- a/atr/routes/resolve.py
+++ b/atr/routes/resolve.py
@@ -77,7 +77,7 @@ async def selected(session: routes.CommitterSession,
project_name: str, version_
release=release,
format_artifact_name=_format_artifact_name,
form=form,
- format_datetime=routes.format_datetime,
+ format_datetime=util.format_datetime,
vote_task=latest_vote_task,
task_mid=task_mid,
archive_url=archive_url,
diff --git a/atr/routes/root.py b/atr/routes/root.py
index 7b3f6aa..0e8c3ea 100644
--- a/atr/routes/root.py
+++ b/atr/routes/root.py
@@ -27,6 +27,7 @@ import atr.db as db
import atr.db.models as models
import atr.routes as routes
import atr.user as user
+import atr.util as util
@routes.public("/")
@@ -97,7 +98,7 @@ async def index() -> response.Response | str:
all_projects=all_projects,
phase_sequence=phase_sequence,
phase_index_map=phase_index_map,
- format_datetime=routes.format_datetime,
+ format_datetime=util.format_datetime,
)
# Public view
diff --git a/atr/templates/download-all.html b/atr/templates/download-all.html
index 8bd1194..e366323 100644
--- a/atr/templates/download-all.html
+++ b/atr/templates/download-all.html
@@ -44,6 +44,18 @@
Download all files in <strong>{{ release.project.short_display_name
}}</strong> <em>{{ release.version }}</em>
</h1>
+ <p class="border rounded p-3 mb-3">
+ <i class="bi bi-info-circle me-1"></i>
+ {% set file_count, total_bytes, formatted_size =
get_release_stats(release) %}
+ This release consists of
+ {% if file_count == 1 %}
+ <code>{{ file_count }}</code> file
+ {% else %}
+ <code>{{ file_count }}</code> files
+ {% endif %}
+ with a total size of <code>{{ formatted_size }}</code>.
+ </p>
+
<h2 id="download-zip">Download ZIP archive</h2>
<p>
Download a single ZIP archive containing all files for this release below.
diff --git a/atr/util.py b/atr/util.py
index 0258aea..fb77c4c 100644
--- a/atr/util.py
+++ b/atr/util.py
@@ -19,6 +19,7 @@ import asyncio
import binascii
import contextlib
import dataclasses
+import datetime
import hashlib
import logging
import pathlib
@@ -247,6 +248,83 @@ async def file_sha3(path: str) -> str:
return sha3.hexdigest()
+def format_datetime(dt_obj: datetime.datetime | int) -> str:
+ """Format a datetime object or Unix timestamp into a human readable
datetime string."""
+ # Integers are unix timestamps
+ if isinstance(dt_obj, int):
+ dt_obj = datetime.datetime.fromtimestamp(dt_obj, tz=datetime.UTC)
+
+ # Ensure UTC native timezone awareness
+ if dt_obj.tzinfo is None:
+ dt_obj = dt_obj.replace(tzinfo=datetime.UTC)
+ else:
+ # Convert to UTC if not already
+ dt_obj = dt_obj.astimezone(datetime.UTC)
+
+ return dt_obj.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
+ formatted_bytes = f"{size_in_bytes:,}"
+
+ # Calculate the appropriate unit
+ if size_in_bytes >= 1_000_000_000:
+ size_in_gb = size_in_bytes // 1_000_000_000
+ return f"{size_in_gb:,} GB ({formatted_bytes} bytes)"
+ elif size_in_bytes >= 1_000_000:
+ size_in_mb = size_in_bytes // 1_000_000
+ return f"{size_in_mb:,} MB ({formatted_bytes} bytes)"
+ elif size_in_bytes >= 1_000:
+ size_in_kb = size_in_bytes // 1_000
+ return f"{size_in_kb:,} KB ({formatted_bytes} bytes)"
+ else:
+ 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_asf_id_or_die() -> str:
web_session = await session.read()
if web_session is None or web_session.uid is None:
@@ -258,6 +336,28 @@ def get_finished_dir() -> pathlib.Path:
return pathlib.Path(config.get().FINISHED_STORAGE_DIR)
+async def get_release_stats(release: models.Release) -> tuple[int, int, str]:
+ """Calculate file count, total byte size, and formatted size for a
release."""
+ base_dir = release_directory(release)
+ count = 0
+ total_bytes = 0
+ try:
+ async for rel_path in paths_recursive_async(base_dir):
+ full_path = base_dir / rel_path
+ if await aiofiles.os.path.isfile(full_path):
+ try:
+ size = await aiofiles.os.path.getsize(full_path)
+ count += 1
+ total_bytes += size
+ except OSError:
+ ...
+ except FileNotFoundError:
+ ...
+
+ formatted_size = format_file_size(total_bytes)
+ return count, total_bytes, formatted_size
+
+
def get_unfinished_dir() -> pathlib.Path:
return pathlib.Path(config.get().UNFINISHED_STORAGE_DIR)
@@ -330,6 +430,22 @@ async def paths_recursive(base_path: pathlib.Path, sort:
bool = True) -> list[pa
return paths
+async def paths_recursive_async(base_path: pathlib.Path) ->
AsyncGenerator[pathlib.Path]:
+ """Yield all file paths recursively within a base path, relative to the
base path."""
+ try:
+ abs_base_path = await asyncio.to_thread(base_path.resolve)
+ for entry in await aiofiles.os.scandir(abs_base_path):
+ entry_path = pathlib.Path(entry.path)
+ relative_path = entry_path.relative_to(abs_base_path)
+ if entry.is_file():
+ yield relative_path
+ elif entry.is_dir():
+ async for sub_path in paths_recursive_async(entry_path):
+ yield relative_path / sub_path
+ except FileNotFoundError:
+ return
+
+
def permitted_recipients(asf_uid: str) -> list[str]:
test_list = "user-tests"
return [
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]