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]

Reply via email to