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]