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 a0cca54  Add ZIP downloads and set them as the primary download method
a0cca54 is described below

commit a0cca5495d2b0db6fc835c7aff4bd35d4060de22
Author: Sean B. Palmer <[email protected]>
AuthorDate: Thu May 1 20:34:41 2025 +0100

    Add ZIP downloads and set them as the primary download method
---
 atr/routes/download.py                | 35 ++++++++++++++++++++++++++++++
 atr/templates/check-selected.html     |  6 +++---
 atr/templates/download-all.html       | 40 +++++++++++++++++++++++------------
 atr/templates/draft-tools.html        |  2 +-
 atr/templates/releases-completed.html |  2 +-
 poetry.lock                           | 14 +++++++++++-
 pyproject.toml                        |  1 +
 7 files changed, 80 insertions(+), 20 deletions(-)

diff --git a/atr/routes/download.py b/atr/routes/download.py
index f1a0ff8..0bb1ad4 100644
--- a/atr/routes/download.py
+++ b/atr/routes/download.py
@@ -18,12 +18,14 @@
 """download.py"""
 
 import pathlib
+from collections.abc import AsyncGenerator
 
 import aiofiles
 import aiofiles.os
 import asfquart.base as base
 import quart
 import werkzeug.wrappers.response as response
+import zipstream
 
 import atr.db as db
 import atr.db.models as models
@@ -85,6 +87,39 @@ async def urls_selected(project_name: str, version_name: 
str) -> response.Respon
         return quart.Response(f"Internal server error: {e}", status=500, 
mimetype="text/plain")
 
 
[email protected]("/download/zip/<project_name>/<version_name>")
+async def zip_selected(
+    session: routes.CommitterSession, project_name: str, version_name: str
+) -> response.Response | quart.wrappers.response.Response:
+    try:
+        release = await session.release(project_name=project_name, 
version_name=version_name, phase=None)
+    except ValueError as e:
+        return quart.Response(f"Error: {e}", status=404, mimetype="text/plain")
+    except Exception as e:
+        return quart.Response(f"Server error: {e}", status=500, 
mimetype="text/plain")
+
+    base_dir = util.release_directory(release)
+    files_to_zip = []
+    try:
+        for rel_path in await util.paths_recursive(base_dir):
+            full_item_path = base_dir / rel_path
+            if await aiofiles.os.path.isfile(full_item_path):
+                files_to_zip.append({"file": str(full_item_path), "name": 
str(rel_path)})
+    except FileNotFoundError:
+        return quart.Response("Error: Release directory not found.", 
status=404, mimetype="text/plain")
+
+    async def stream_zip(file_list: list[dict[str, str]]) -> 
AsyncGenerator[bytes]:
+        aiozip = zipstream.AioZipStream(file_list, chunksize=32768)
+        async for chunk in aiozip.stream():
+            yield chunk
+
+    headers = {
+        "Content-Disposition": f'attachment; filename="{release.name}.zip"',
+        "Content-Type": "application/zip",
+    }
+    return quart.Response(stream_zip(files_to_zip), headers=headers, 
mimetype="application/zip")
+
+
 async def _download_or_list(project_name: str, version_name: str, file_path: 
str) -> response.Response | quart.Response:
     """Download a file or list a directory from a release in any phase."""
     # await session.check_access(project_name)
diff --git a/atr/templates/check-selected.html 
b/atr/templates/check-selected.html
index 82d6e44..0aed8f0 100644
--- a/atr/templates/check-selected.html
+++ b/atr/templates/check-selected.html
@@ -99,11 +99,11 @@
     {% if phase == "release_candidate" %}
       <div class="card-body">
         <a href="{{ as_url(routes.candidate.view, 
project_name=release.project.name, version_name=release.version) }}"
-           class="btn btn-primary me-2"><i class="bi bi-eye me-1"></i>View 
files</a>
+           class="btn btn-primary me-2"><i class="bi bi-eye me-1"></i> View 
files</a>
         <a href="{{ as_url(routes.download.all_selected, 
project_name=release.project.name, version_name=release.version) }}"
-           class="btn btn-primary me-2"><i class="bi bi-download 
me-1"></i>Download files</a>
+           class="btn btn-primary me-2"><i class="bi bi-download me-1"></i> 
Download files</a>
         <a href="{{ as_url(routes.resolve.selected, 
project_name=release.project.name, version_name=release.version) }}"
-           class="btn btn-success"><i class="bi bi-clipboard-check 
me-1"></i>Resolve vote</a>
+           class="btn btn-success"><i class="bi bi-clipboard-check me-1"></i> 
Resolve vote</a>
       </div>
     {% endif %}
   </div>
diff --git a/atr/templates/download-all.html b/atr/templates/download-all.html
index 86fd352..8bd1194 100644
--- a/atr/templates/download-all.html
+++ b/atr/templates/download-all.html
@@ -41,42 +41,54 @@
   </p>
 
   <h1>
-    Download all files for <strong>{{ release.project.short_display_name 
}}</strong> <em>{{ release.version }}</em>
+    Download all files in <strong>{{ release.project.short_display_name 
}}</strong> <em>{{ release.version }}</em>
   </h1>
 
+  <h2 id="download-zip">Download ZIP archive</h2>
   <p>
+    Download a single ZIP archive containing all files for this release below.
+    The archive is generated on the fly, which may take a while for very large 
releases.
+  </p>
+  <p>
+    <a href="{{ as_url(routes.download.zip_selected, 
project_name=release.project.name, version_name=release.version) }}"
+       class="btn btn-primary btn-lg">
+      <i class="bi bi-file-earmark-zip me-2"></i>Download {{ release.name 
}}.zip
+    </a>
+  </p>
+
+  <h2>Alternative methods</h2>
+  <p>If you prefer, you can download the files using other methods.</p>
+  <p>
+    <a href="#download-browser"
+       class="btn btn-sm btn-outline-secondary"
+       title="Download using your browser">Browser</a>
     <a href="#download-rsync"
        class="btn btn-sm btn-outline-secondary me-2"
        title="Download using rsync">Rsync</a>
     <a href="#download-wget"
        class="btn btn-sm btn-outline-secondary me-2"
        title="Download using wget">Wget</a>
-    <a href="#download-browser"
-       class="btn btn-sm btn-outline-secondary"
-       title="Download using your browser">Browser</a>
   </p>
-
-  <h2 id="download-rsync">Using rsync</h2>
-  <p>You can download the files in this release using rsync with the following 
command:</p>
+  <h3 id="download-browser" class="mt-4">Using your browser</h3>
+  <p>
+    You can download the files one by one using your browser from the <a 
href="{{ as_url(routes.download.path_empty, project_name=release.project.name, 
version_name=release.version) }}">download folder</a>. Clicking a link to any 
file will download it, as it is served as <code>application/octet-stream</code>.
+  </p>
+  <h3 id="download-rsync" class="mt-4">Using rsync</h3>
+  <p>You can download all of the files in this release using rsync with the 
following command:</p>
   <!-- TODO: Add a button to copy the command to the clipboard -->
   <pre class="bg-light p-3 mb-3">
 rsync -av -e 'ssh -p 2222' {{ asf_id }}@{{ server_domain }}:/{{ 
release.project.name }}/{{ release.version }}/ ${DOWNLOAD_PATH}/
 </pre>
   {% include "user-ssh-keys.html" %}
 
-  <h2 id="download-wget">Using wget</h2>
-  <p>You can download the files in this release using wget with the following 
command:</p>
+  <h3 id="download-wget" class="mt-4">Using wget</h3>
+  <p>You can download all of the files in this release using wget with the 
following command:</p>
   <pre class="bg-light p-3 mb-3">
 wget -r -np -nH --cut-dirs=3{% if server_domain == "127.0.0.1" %} 
--no-check-certificate{% endif %} https://{{ server_domain }}{{ 
as_url(routes.download.path_empty, project_name=release.project.name, 
version_name=release.version) }}
 </pre>
   <p>
     This downloads the files into the <em>current directory</em>. Ensure that 
you create a new empty directory, and change to it, before running the command.
   </p>
-
-  <h2 id="download-browser">Using your browser</h2>
-  <p>
-    You can also download the files one by one using your browser from the <a 
href="{{ as_url(routes.download.path_empty, project_name=release.project.name, 
version_name=release.version) }}">download folder</a>. Clicking a link to any 
file will download it, as it is served as 
<code>application/octet-stream</code>. We do not offer an archive of the entire 
release due to the size of large releases.
-  </p>
 {% endblock content %}
 
 {% block stylesheets %}
diff --git a/atr/templates/draft-tools.html b/atr/templates/draft-tools.html
index 5780c58..a17518f 100644
--- a/atr/templates/draft-tools.html
+++ b/atr/templates/draft-tools.html
@@ -29,7 +29,7 @@
   <h3>Generate hash files</h3>
   <p>Generate an SHA256 or SHA512 hash file for this file.</p>
   <div class="alert alert-warning">
-    <i class="bi bi-exclamation-triangle me-2"></i>IMPORTANT: The ASF security 
team <a href="https://infra.apache.org/release-signing.html#sha-checksum";
+    <i class="bi bi-exclamation-triangle me-2"></i> IMPORTANT: The ASF 
security team <a 
href="https://infra.apache.org/release-signing.html#sha-checksum";
     class="alert-link">recommends using SHA512</a> as the hash algorithm.
     Please select SHA512 unless you have a specific reason to use SHA256.
   </div>
diff --git a/atr/templates/releases-completed.html 
b/atr/templates/releases-completed.html
index 5fc1d71..12a48de 100644
--- a/atr/templates/releases-completed.html
+++ b/atr/templates/releases-completed.html
@@ -50,7 +50,7 @@
     </div>
   {% else %}
     <div class="alert alert-info mt-4" role="alert">
-      <i class="bi bi-info-circle me-2"></i>There are no completed releases 
recorded for {{ project_display_name }}.
+      <i class="bi bi-info-circle me-2"></i> There are no completed releases 
recorded for {{ project_display_name }}.
     </div>
   {% endif %}
 {% endblock content %}
diff --git a/poetry.lock b/poetry.lock
index 3e2d6d6..6bb0abb 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -189,6 +189,18 @@ typing_extensions = ">=4.0"
 dev = ["attribution (==1.7.1)", "black (==24.3.0)", "build (>=1.2)", 
"coverage[toml] (==7.6.10)", "flake8 (==7.0.0)", "flake8-bugbear (==24.12.12)", 
"flit (==3.10.1)", "mypy (==1.14.1)", "ufmt (==2.5.1)", "usort (==1.0.8.post1)"]
 docs = ["sphinx (==8.1.3)", "sphinx-mdinclude (==0.6.1)"]
 
+[[package]]
+name = "aiozipstream"
+version = "0.4"
+description = "Creating zip files on the fly"
+optional = false
+python-versions = "*"
+groups = ["main"]
+files = [
+    {file = "aiozipstream-0.4-py2.py3-none-any.whl", hash = 
"sha256:a58bad8c75aba319c07bd3d817da7caec7417c1eb4f4c692e00b173fb9ded9c6"},
+    {file = "aiozipstream-0.4.tar.gz", hash = 
"sha256:ccc5cec35c2580b8a13185c916b1581bfcb4278ddf6ea3f7f834b6c9c47d6c61"},
+]
+
 [[package]]
 name = "alembic"
 version = "1.15.2"
@@ -2980,4 +2992,4 @@ propcache = ">=0.2.1"
 [metadata]
 lock-version = "2.1"
 python-versions = "~=3.13"
-content-hash = 
"35f766c3f0adadcf4faf77453dce39cfbd488a0d780617f83422bcc14c805e9f"
+content-hash = 
"6ff06f78389576f0ae1fdda7a7547d7fa6d32cca37acf7eacecaf52108e7e20f"
diff --git a/pyproject.toml b/pyproject.toml
index 8708a91..30bf8a6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -15,6 +15,7 @@ dependencies = [
   "aioshutil (>=1.5,<2.0)",
   "aiosmtplib (>=4.0.0,<5.0.0)",
   "aiosqlite>=0.21.0,<0.22.0",
+  "aiozipstream (>=0.4,<0.5)",
   "alembic~=1.14",
   "asfquart @ git+https://github.com/apache/infrastructure-asfquart.git@main";,
   "asyncssh>=2.20.0,<3.0.0",


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

Reply via email to