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 2eec194  Add viewers for individual files in all phases
2eec194 is described below

commit 2eec194f1587b2e8f11ab045bd2a7be7e641b176
Author: Sean B. Palmer <[email protected]>
AuthorDate: Tue Apr 1 17:38:45 2025 +0100

    Add viewers for individual files in all phases
---
 atr/routes/candidate.py              | 35 ++++++++++++++++++++++
 atr/routes/draft.py                  | 35 ++++++++++++++++++++++
 atr/routes/preview.py                | 35 ++++++++++++++++++++++
 atr/routes/release.py                | 31 +++++++++++++++++++
 atr/templates/phase-viewer-path.html | 58 ++++++++++++++++++++++++++++++++++++
 atr/templates/phase-viewer.html      | 14 ++++++++-
 atr/util.py                          | 58 ++++++++++++++++++++++++++++++++++++
 7 files changed, 265 insertions(+), 1 deletion(-)

diff --git a/atr/routes/candidate.py b/atr/routes/candidate.py
index 2a804d1..ef452e1 100644
--- a/atr/routes/candidate.py
+++ b/atr/routes/candidate.py
@@ -92,6 +92,41 @@ async def viewer(session: routes.CommitterSession, 
project_name: str, version_na
         format_file_size=routes.format_file_size,
         format_permissions=routes.format_permissions,
         phase="release candidate",
+        phase_key="candidate",
+    )
+
+
[email protected]("/candidate/viewer/<project_name>/<version_name>/<path:file_path>")
+async def viewer_path(
+    session: routes.CommitterSession, project_name: str, version_name: str, 
file_path: str
+) -> response.Response | str:
+    """Show the content of a specific file in the release candidate."""
+    # 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(
+            viewer, error="You do not have access to this project", 
project_name=project_name, version_name=version_name
+        )
+
+    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)
+        )
+
+    _max_view_size = 1 * 1024 * 1024
+    full_path = util.get_release_candidate_dir() / project_name / version_name 
/ file_path
+    content, is_text, is_truncated, error_message = await 
util.read_file_for_viewer(full_path, _max_view_size)
+    return await quart.render_template(
+        "phase-viewer-path.html",
+        release=release,
+        project_name=project_name,
+        version_name=version_name,
+        file_path=file_path,
+        content=content,
+        is_text=is_text,
+        is_truncated=is_truncated,
+        error_message=error_message,
+        format_file_size=routes.format_file_size,
+        phase_key="candidate",
     )
 
 
diff --git a/atr/routes/draft.py b/atr/routes/draft.py
index af37889..90b7955 100644
--- a/atr/routes/draft.py
+++ b/atr/routes/draft.py
@@ -666,6 +666,41 @@ async def viewer(session: routes.CommitterSession, 
project_name: str, version_na
         format_file_size=routes.format_file_size,
         format_permissions=routes.format_permissions,
         phase="release candidate draft",
+        phase_key="draft",
+    )
+
+
[email protected]("/draft/viewer/<project_name>/<version_name>/<path:file_path>")
+async def viewer_path(
+    session: routes.CommitterSession, project_name: str, version_name: str, 
file_path: str
+) -> response.Response | str:
+    """Show the content of a specific file in the release candidate draft."""
+    # 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(
+            viewer, error="You do not have access to this project", 
project_name=project_name, version_name=version_name
+        )
+
+    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)
+        )
+
+    _max_view_size = 1 * 1024 * 1024
+    full_path = util.get_release_candidate_draft_dir() / project_name / 
version_name / file_path
+    content, is_text, is_truncated, error_message = await 
util.read_file_for_viewer(full_path, _max_view_size)
+    return await quart.render_template(
+        "phase-viewer-path.html",
+        release=release,
+        project_name=project_name,
+        version_name=version_name,
+        file_path=file_path,
+        content=content,
+        is_text=is_text,
+        is_truncated=is_truncated,
+        error_message=error_message,
+        format_file_size=routes.format_file_size,
+        phase_key="draft",
     )
 
 
diff --git a/atr/routes/preview.py b/atr/routes/preview.py
index 3bd8712..336bcde 100644
--- a/atr/routes/preview.py
+++ b/atr/routes/preview.py
@@ -225,6 +225,41 @@ async def viewer(session: routes.CommitterSession, 
project_name: str, version_na
         format_file_size=routes.format_file_size,
         format_permissions=routes.format_permissions,
         phase="release preview",
+        phase_key="preview",
+    )
+
+
[email protected]("/preview/viewer/<project_name>/<version_name>/<path:file_path>")
+async def viewer_path(
+    session: routes.CommitterSession, project_name: str, version_name: str, 
file_path: str
+) -> response.Response | str:
+    """Show the content of a specific file in the release preview."""
+    # 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(
+            viewer, error="You do not have access to this project", 
project_name=project_name, version_name=version_name
+        )
+
+    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)
+        )
+
+    _max_view_size = 1 * 1024 * 1024
+    full_path = util.get_release_preview_dir() / project_name / version_name / 
file_path
+    content, is_text, is_truncated, error_message = await 
util.read_file_for_viewer(full_path, _max_view_size)
+    return await quart.render_template(
+        "phase-viewer-path.html",
+        release=release,
+        project_name=project_name,
+        version_name=version_name,
+        file_path=file_path,
+        content=content,
+        is_text=is_text,
+        is_truncated=is_truncated,
+        error_message=error_message,
+        format_file_size=routes.format_file_size,
+        phase_key="preview",
     )
 
 
diff --git a/atr/routes/release.py b/atr/routes/release.py
index ec4163c..83d81e6 100644
--- a/atr/routes/release.py
+++ b/atr/routes/release.py
@@ -117,4 +117,35 @@ async def viewer(session: routes.CommitterSession, 
project_name: str, version_na
         format_file_size=routes.format_file_size,
         format_permissions=routes.format_permissions,
         phase="release",
+        phase_key="release",
+    )
+
+
[email protected]("/release/viewer/<project_name>/<version_name>/<path:file_path>")
+async def viewer_path(
+    session: routes.CommitterSession, project_name: str, version_name: str, 
file_path: str
+) -> response.Response | str:
+    """Show the content of a specific file in the final release."""
+    # Releases are public, no specific access check needed here beyond being a 
committer
+
+    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)
+        )
+
+    _max_view_size = 1 * 1024 * 1024
+    full_path = util.get_release_dir() / project_name / version_name / 
file_path
+    content, is_text, is_truncated, error_message = await 
util.read_file_for_viewer(full_path, _max_view_size)
+    return await quart.render_template(
+        "phase-viewer-path.html",
+        release=release,
+        project_name=project_name,
+        version_name=version_name,
+        file_path=file_path,
+        content=content,
+        is_text=is_text,
+        is_truncated=is_truncated,
+        error_message=error_message,
+        format_file_size=routes.format_file_size,
+        phase_key="release",
     )
diff --git a/atr/templates/phase-viewer-path.html 
b/atr/templates/phase-viewer-path.html
new file mode 100644
index 0000000..035eeec
--- /dev/null
+++ b/atr/templates/phase-viewer-path.html
@@ -0,0 +1,58 @@
+{% extends "layouts/base.html" %}
+
+{% block title %}
+  View {{ project_name }}/{{ version_name }}/{{ file_path }} ~ ATR
+{% endblock title %}
+
+{% block description %}
+  View the content of the {{ project_name }} {{ version_name }} {{ file_path 
}} file.
+{% endblock description %}
+
+{% block content %}
+  {# Generate back link based on phase_key #}
+  {% if phase_key == "draft" %}
+    {% set back_url = as_url(routes.draft.viewer, project_name=project_name, 
version_name=version_name) %}
+  {% elif phase_key == "candidate" %}
+    {% set back_url = as_url(routes.candidate.viewer, 
project_name=project_name, version_name=version_name) %}
+  {% elif phase_key == "preview" %}
+    {% set back_url = as_url(routes.preview.viewer, project_name=project_name, 
version_name=version_name) %}
+  {% elif phase_key == "release" %}
+    {% set back_url = as_url(routes.release.viewer, project_name=project_name, 
version_name=version_name) %}
+  {% endif %}
+  <a href="{{ back_url }}" class="back-link">← Back to Viewer</a>
+
+  <div class="p-3 mb-4 bg-light border rounded">
+    <h2 class="mt-0">Viewing file: {{ file_path }}</h2>
+    <p class="mb-0">
+      <strong>Release:</strong> {{ release.name }}
+    </p>
+  </div>
+
+  {% if error_message %}
+    <div class="alert alert-danger">{{ error_message }}</div>
+  {% elif size_limit_exceeded %}
+    <div class="alert alert-warning">{{ content }}</div>
+  {% elif content is not none %}
+    <div class="card mb-4">
+      <div class="card-header">
+        <h5 class="mb-0">
+          File content
+          {% if not is_text %}(Hexdump){% endif %}
+        </h5>
+      </div>
+      <div class="card-body p-0">
+        {% if is_text %}
+          <pre class="bg-light p-4 rounded-bottom mb-0 text-break">{{ content 
}}</pre>
+        {% else %}
+          <pre class="bg-light p-4 rounded-bottom mb-0 text-break"><code>{{ 
content }}</code></pre>
+        {% endif %}
+      </div>
+      {% if is_truncated %}
+        <div class="card-footer text-muted small">Note: File content truncated 
to the first 1 MB.</div>
+      {% endif %}
+    </div>
+  {% else %}
+    {# Shouldn't happen #}
+    <div class="alert alert-secondary">No content available for this 
file.</div>
+  {% endif %}
+{% endblock content %}
diff --git a/atr/templates/phase-viewer.html b/atr/templates/phase-viewer.html
index 47d7125..416cb5e 100644
--- a/atr/templates/phase-viewer.html
+++ b/atr/templates/phase-viewer.html
@@ -68,7 +68,19 @@
                   <td>{{ format_permissions(stat.permissions) }}</td>
                   <td>
                     {% if stat.is_file %}
-                      {{ stat.path }}
+                      {% if phase_key == "draft" %}
+                        {% set file_url = as_url(routes.draft.viewer_path, 
project_name=release.project.name, version_name=release.version, 
file_path=stat.path) %}
+                      {% elif phase_key == "candidate" %}
+                        {% set file_url = as_url(routes.candidate.viewer_path, 
project_name=release.project.name, version_name=release.version, 
file_path=stat.path) %}
+                      {% elif phase_key == "preview" %}
+                        {% set file_url = as_url(routes.preview.viewer_path, 
project_name=release.project.name, version_name=release.version, 
file_path=stat.path) %}
+                      {% elif phase_key == "release" %}
+                        {% set file_url = as_url(routes.release.viewer_path, 
project_name=release.project.name, version_name=release.version, 
file_path=stat.path) %}
+                      {% else %}
+                        {# TODO: Should probably disable the link here #}
+                        {% set file_url = "#" %}
+                      {% endif %}
+                      <a href="{{ file_url }}">{{ stat.path }}</a>
                     {% else %}
                       <strong>{{ stat.path }}/</strong>
                     {% endif %}
diff --git a/atr/util.py b/atr/util.py
index d2dcbd0..ca3a842 100644
--- a/atr/util.py
+++ b/atr/util.py
@@ -16,6 +16,7 @@
 # under the License.
 
 import asyncio
+import binascii
 import contextlib
 import dataclasses
 import hashlib
@@ -292,3 +293,60 @@ async def async_temporary_directory(
         yield pathlib.Path(temp_dir_path)
     finally:
         await asyncio.to_thread(shutil.rmtree, temp_dir_path, 
ignore_errors=True)
+
+
+async def read_file_for_viewer(full_path: pathlib.Path, max_size: int) -> 
tuple[str | None, bool, bool, str | None]:
+    """Read file content for viewer."""
+    content: str | None = None
+    is_text = False
+    is_truncated = False
+    error_message: str | None = None
+
+    try:
+        if not await aiofiles.os.path.exists(full_path):
+            return None, False, False, "File does not exist"
+        if not await aiofiles.os.path.isfile(full_path):
+            return None, False, False, "Path is not a file"
+
+        file_size = await aiofiles.os.path.getsize(full_path)
+        read_size = min(file_size, max_size)
+
+        if file_size > max_size:
+            is_truncated = True
+
+        if file_size == 0:
+            is_text = True
+            content = "(Empty file)"
+            raw_content = b""
+        else:
+            async with aiofiles.open(full_path, "rb") as f:
+                raw_content = await f.read(read_size)
+
+        if file_size > 0:
+            try:
+                if b"\x00" in raw_content:
+                    raise UnicodeDecodeError("utf-8", b"", 0, 1, "Null byte 
found")
+                content = raw_content.decode("utf-8")
+                is_text = True
+            except UnicodeDecodeError:
+                is_text = False
+                content = _generate_hexdump(raw_content)
+
+    except Exception as e:
+        error_message = f"An error occurred reading the file: {e!s}"
+
+    return content, is_text, is_truncated, error_message
+
+
+def _generate_hexdump(data: bytes) -> str:
+    """Generate a formatted hexdump string from bytes."""
+    hex_lines = []
+    for i in range(0, len(data), 16):
+        chunk = data[i : i + 16]
+        hex_part = binascii.hexlify(chunk).decode("ascii")
+        hex_part = hex_part.ljust(32)
+        hex_part_spaced = " ".join(hex_part[j : j + 2] for j in range(0, 
len(hex_part), 2))
+        ascii_part = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk)
+        line_num = f"{i:08x}"
+        hex_lines.append(f"{line_num}  {hex_part_spaced}  |{ascii_part}|")
+    return "\n".join(hex_lines)


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

Reply via email to