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 804185b Move artifact and metadata files together as a bundle, and
report any errors
804185b is described below
commit 804185b696413a7b15792f7892040b1664bbb2d9
Author: Sean B. Palmer <[email protected]>
AuthorDate: Mon May 19 15:44:21 2025 +0100
Move artifact and metadata files together as a bundle, and report any errors
---
atr/routes/finish.py | 184 ++++++++++++++++++++++------------
atr/static/js/finish-selected-move.js | 33 +++---
atr/static/ts/finish-selected-move.ts | 33 +++---
atr/templates/finish-selected.html | 4 +
4 files changed, 155 insertions(+), 99 deletions(-)
diff --git a/atr/routes/finish.py b/atr/routes/finish.py
index 151863e..22648df 100644
--- a/atr/routes/finish.py
+++ b/atr/routes/finish.py
@@ -21,6 +21,7 @@ from typing import Final
import aiofiles.os
import quart
+import quart.wrappers.response as quart_response
import werkzeug.wrappers.response as response
import wtforms
@@ -31,6 +32,8 @@ import atr.routes as routes
import atr.routes.root as root
import atr.util as util
+SPECIAL_SUFFIXES: Final[frozenset[str]] = frozenset({".asc", ".sha256",
".sha512"})
+
_LOGGER: Final = logging.getLogger(__name__)
@@ -53,7 +56,9 @@ class MoveFileForm(util.QuartFormTyped):
@routes.committer("/finish/<project_name>/<version_name>", methods=["GET",
"POST"])
-async def selected(session: routes.CommitterSession, project_name: str,
version_name: str) -> response.Response | str:
+async def selected(
+ session: routes.CommitterSession, project_name: str, version_name: str
+) -> tuple[quart_response.Response, int] | response.Response | str:
"""Finish a release preview."""
await session.check_access(project_name)
@@ -64,24 +69,8 @@ async def selected(session: routes.CommitterSession,
project_name: str, version_
user_ssh_keys = await data.ssh_key(asf_uid=session.uid).all()
latest_revision_dir = util.release_directory(release)
- source_files_rel: list[pathlib.Path] = []
- target_dirs: set[pathlib.Path] = {pathlib.Path(".")}
-
try:
- async for item_rel_path in
util.paths_recursive_all(latest_revision_dir):
- current_parent = item_rel_path.parent
- while True:
- target_dirs.add(current_parent)
- if current_parent == pathlib.Path("."):
- break
- current_parent = current_parent.parent
-
- item_abs_path = latest_revision_dir / item_rel_path
- if await aiofiles.os.path.isfile(item_abs_path):
- source_files_rel.append(item_rel_path)
- elif await aiofiles.os.path.isdir(item_abs_path):
- target_dirs.add(item_rel_path)
-
+ source_files_rel, target_dirs = await
_sources_and_targets(latest_revision_dir)
except FileNotFoundError:
await quart.flash("Preview revision directory not found.", "error")
return await session.redirect(root.index)
@@ -94,11 +83,13 @@ async def selected(session: routes.CommitterSession,
project_name: str, version_
can_move = (len(target_dirs) > 1) and (len(source_files_rel) > 0)
if (quart.request.method == "POST") and can_move:
- match r := await _move_file(form, session, project_name, version_name):
+ match await _move_file(form, session, project_name, version_name):
case None:
pass
- case response.Response():
- return r
+ case tuple() as resp_tuple:
+ return resp_tuple
+ case resp_obj if isinstance(resp_obj, response.Response):
+ return resp_obj
# resp = await quart.current_app.make_response(template_rendered)
# resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
@@ -118,53 +109,118 @@ async def selected(session: routes.CommitterSession,
project_name: str, version_
)
+def _related_files(path: pathlib.Path) -> list[pathlib.Path]:
+ base_path = path.with_suffix("") if (path.suffix in SPECIAL_SUFFIXES) else
path
+ parent_dir = base_path.parent
+ name_without_ext = base_path.name
+ return [
+ parent_dir / name_without_ext,
+ parent_dir / f"{name_without_ext}.asc",
+ parent_dir / f"{name_without_ext}.sha256",
+ parent_dir / f"{name_without_ext}.sha512",
+ ]
+
+
async def _move_file(
form: MoveFileForm, session: routes.CommitterSession, project_name: str,
version_name: str
-) -> response.Response | None:
+) -> tuple[quart_response.Response, int] | response.Response | None:
+ wants_json = "application/json" in quart.request.headers.get("Accept", "")
if await form.validate_on_submit():
source_file_rel = pathlib.Path(form.source_file.data)
target_dir_rel = pathlib.Path(form.target_directory.data)
-
- try:
- description = "File move through web interface"
- async with revision.create_and_manage(project_name, version_name,
session.uid, description=description) as (
- new_revision_dir,
- new_revision_number,
- ):
- source_path_in_new = new_revision_dir / source_file_rel
- target_path_in_new = new_revision_dir / target_dir_rel /
source_file_rel.name
-
- if await aiofiles.os.path.exists(target_path_in_new):
- await quart.flash(
- f"File '{source_file_rel.name}' already exists in
'{target_dir_rel}' in new revision.",
- "error",
- )
- return await session.redirect(selected,
project_name=project_name, version_name=version_name)
-
- _LOGGER.info(
- f"Moving {source_path_in_new} to {target_path_in_new} in
new revision {new_revision_number}"
- )
- await aiofiles.os.rename(source_path_in_new,
target_path_in_new)
-
- await quart.flash(
- f"File '{source_file_rel.name}' moved successfully to
'{target_dir_rel}' in new revision.", "success"
- )
- return await session.redirect(selected, project_name=project_name,
version_name=version_name)
-
- except FileNotFoundError:
- _LOGGER.exception("File not found during move operation in new
revision")
- await quart.flash("Error: Source file not found during move
operation.", "error")
- except OSError as e:
- _LOGGER.exception("Error moving file in new revision")
- await quart.flash(f"Error moving file: {e}", "error")
- except Exception as e:
- _LOGGER.exception("Unexpected error during file move")
- await quart.flash(f"An unexpected error occurred: {e!s}", "error")
- return await session.redirect(selected, project_name=project_name,
version_name=version_name)
+ return await _move_file_to_revision(
+ source_file_rel, target_dir_rel, session, project_name,
version_name, wants_json
+ )
else:
- for field, errors in form.errors.items():
- field_label = getattr(getattr(form, field, None), "label", None)
- label_text = field_label.text if field_label else
field.replace("_", " ").title()
- for error in errors:
- await quart.flash(f"{label_text}: {error}", "warning")
+ if wants_json:
+ error_messages = []
+ for field_name, field_errors in form.errors.items():
+ field_object = getattr(form, field_name, None)
+ label_object = getattr(field_object, "label", None)
+ label_text = label_object.text if label_object else
field_name.replace("_", " ").title()
+ error_messages.append(f"{label_text}: {',
'.join(field_errors)}")
+ error_string = "; ".join(error_messages)
+ return quart.jsonify(error=error_string), 400
+ else:
+ for field_name, field_errors in form.errors.items():
+ field_object = getattr(form, field_name, None)
+ label_object = getattr(field_object, "label", None)
+ label_text = label_object.text if label_object else
field_name.replace("_", " ").title()
+ for error_message_text in field_errors:
+ await quart.flash(f"{label_text}: {error_message_text}",
"warning")
return None
+
+
+async def _move_file_to_revision(
+ source_file_rel: pathlib.Path,
+ target_dir_rel: pathlib.Path,
+ session: routes.CommitterSession,
+ project_name: str,
+ version_name: str,
+ wants_json: bool,
+) -> tuple[quart_response.Response, int] | response.Response | None:
+ try:
+ description = "File move through web interface"
+ async with revision.create_and_manage(project_name, version_name,
session.uid, description=description) as (
+ new_revision_dir,
+ _new_revision_number,
+ ):
+ related_files = _related_files(source_file_rel)
+ bundle = [f for f in related_files if await
aiofiles.os.path.exists(new_revision_dir / f)]
+ collisions = [
+ f.name for f in bundle if await
aiofiles.os.path.exists(new_revision_dir / target_dir_rel / f.name)
+ ]
+ if collisions:
+ msg = f"Files already exist in '{target_dir_rel}': {',
'.join(collisions)}"
+ if wants_json:
+ return quart.jsonify(error=msg), 400
+ await quart.flash(msg, "error")
+ return await session.redirect(selected,
project_name=project_name, version_name=version_name)
+
+ for f in bundle:
+ await aiofiles.os.rename(new_revision_dir / f,
new_revision_dir / target_dir_rel / f.name)
+
+ await quart.flash(f"Moved {', '.join(f.name for f in bundle)}",
"success")
+ return await session.redirect(selected, project_name=project_name,
version_name=version_name)
+
+ except FileNotFoundError:
+ _LOGGER.exception("File not found during move operation in new
revision")
+ msg = "Error: Source file not found during move operation."
+ if wants_json:
+ return quart.jsonify(error=msg), 400
+ await quart.flash(msg, "error")
+ except OSError as e:
+ _LOGGER.exception("Error moving file in new revision")
+ msg = f"Error moving file: {e}"
+ if wants_json:
+ return quart.jsonify(error=msg), 500
+ await quart.flash(msg, "error")
+ except Exception as e:
+ _LOGGER.exception("Unexpected error during file move")
+ msg = f"An unexpected error occurred: {e!s}"
+ if wants_json:
+ return quart.jsonify(error=msg), 500
+ await quart.flash(msg, "error")
+
+ return await session.redirect(selected, project_name=project_name,
version_name=version_name)
+
+
+async def _sources_and_targets(latest_revision_dir: pathlib.Path) ->
tuple[list[pathlib.Path], set[pathlib.Path]]:
+ source_files_rel: list[pathlib.Path] = []
+ target_dirs: set[pathlib.Path] = {pathlib.Path(".")}
+
+ async for item_rel_path in util.paths_recursive_all(latest_revision_dir):
+ current_parent = item_rel_path.parent
+ while True:
+ target_dirs.add(current_parent)
+ if current_parent == pathlib.Path("."):
+ break
+ current_parent = current_parent.parent
+
+ item_abs_path = latest_revision_dir / item_rel_path
+ if await aiofiles.os.path.isfile(item_abs_path):
+ source_files_rel.append(item_rel_path)
+ elif await aiofiles.os.path.isdir(item_abs_path):
+ target_dirs.add(item_rel_path)
+
+ return source_files_rel, target_dirs
diff --git a/atr/static/js/finish-selected-move.js
b/atr/static/js/finish-selected-move.js
index 875604c..6f96fc6 100644
--- a/atr/static/js/finish-selected-move.js
+++ b/atr/static/js/finish-selected-move.js
@@ -19,6 +19,7 @@ const DIR_DATA_ID = "dir-data";
const DIR_FILTER_INPUT_ID = "dir-filter-input";
const DIR_LIST_MORE_INFO_ID = "dir-list-more-info";
const DIR_LIST_TABLE_BODY_ID = "dir-list-table-body";
+const ERROR_ALERT_ID = "move-error-alert";
const FILE_DATA_ID = "file-data";
const FILE_FILTER_ID = "file-filter";
const FILE_LIST_MORE_INFO_ID = "file-list-more-info";
@@ -39,6 +40,7 @@ let dirFilterInput;
let dirListTableBody;
let confirmMoveButton;
let currentMoveSelectionInfoElement;
+let errorAlert;
let uiState;
function getParentPath(filePathString) {
if (!filePathString || typeof filePathString !== "string")
@@ -170,16 +172,10 @@ function handleFileSelection(filePath) {
}
uiState.currentlySelectedFilePath = filePath;
renderAllLists();
- if (!confirmMoveButton.disabled) {
- confirmMoveButton.focus();
- }
}
function handleDirSelection(dirPath) {
uiState.currentlyChosenDirectoryPath = dirPath;
renderAllLists();
- if (!confirmMoveButton.disabled) {
- confirmMoveButton.focus();
- }
}
function onFileListClick(event) {
const targetElement = event.target;
@@ -217,6 +213,7 @@ function onMaxFilesChange(event) {
}
function onConfirmMoveClick() {
return __awaiter(this, void 0, void 0, function* () {
+ errorAlert.classList.add("d-none");
if (uiState.currentlySelectedFilePath &&
uiState.currentlyChosenDirectoryPath && uiState.csrfToken) {
const formData = new FormData();
formData.append("csrf_token", uiState.csrfToken);
@@ -236,29 +233,30 @@ function onConfirmMoveClick() {
}
else {
let errorMsg = `An error occurred while moving the file
(Status: ${response.status})`;
- if (response.status === 403) {
+ if (response.status === 403)
errorMsg = "Permission denied to move the file.";
- }
- else if (response.status === 400) {
- errorMsg = "Invalid request to move the file (e.g.
source or target invalid).";
- }
+ if (response.status === 400)
+ errorMsg = "Invalid request to move the file.";
try {
const errorData = yield response.json();
- if (errorData && errorData.error) {
+ if (errorData && typeof errorData.error === "string")
errorMsg = errorData.error;
- }
}
- catch (e) { }
- alert(errorMsg);
+ catch (_) { }
+ errorAlert.textContent = errorMsg;
+ errorAlert.classList.remove("d-none");
+ return;
}
}
catch (error) {
console.error("Network or fetch error:", error);
- alert("A network error occurred. Please check your connection
and try again.");
+ errorAlert.textContent = "A network error occurred. Please
check your connection and try again.";
+ errorAlert.classList.remove("d-none");
}
}
else {
- alert("Please select both a file to move and a destination
directory.");
+ errorAlert.textContent = "Please select both a file to move and a
destination directory.";
+ errorAlert.classList.remove("d-none");
}
});
}
@@ -272,6 +270,7 @@ document.addEventListener("DOMContentLoaded", function () {
confirmMoveButton =
assertElementPresent(document.querySelector(`#${CONFIRM_MOVE_BUTTON_ID}`),
CONFIRM_MOVE_BUTTON_ID);
currentMoveSelectionInfoElement =
assertElementPresent(document.getElementById(CURRENT_MOVE_SELECTION_INFO_ID),
CURRENT_MOVE_SELECTION_INFO_ID);
currentMoveSelectionInfoElement.setAttribute("aria-live", "polite");
+ errorAlert = assertElementPresent(document.getElementById(ERROR_ALERT_ID),
ERROR_ALERT_ID);
let initialFilePaths = [];
let initialTargetDirs = [];
try {
diff --git a/atr/static/ts/finish-selected-move.ts
b/atr/static/ts/finish-selected-move.ts
index 2b27c94..f969642 100644
--- a/atr/static/ts/finish-selected-move.ts
+++ b/atr/static/ts/finish-selected-move.ts
@@ -11,6 +11,7 @@ const DIR_DATA_ID = "dir-data";
const DIR_FILTER_INPUT_ID = "dir-filter-input";
const DIR_LIST_MORE_INFO_ID = "dir-list-more-info";
const DIR_LIST_TABLE_BODY_ID = "dir-list-table-body";
+const ERROR_ALERT_ID = "move-error-alert";
const FILE_DATA_ID = "file-data";
const FILE_FILTER_ID = "file-filter";
const FILE_LIST_MORE_INFO_ID = "file-list-more-info";
@@ -75,6 +76,7 @@ let dirFilterInput: HTMLInputElement;
let dirListTableBody: HTMLTableSectionElement;
let confirmMoveButton: HTMLButtonElement;
let currentMoveSelectionInfoElement: HTMLElement;
+let errorAlert: HTMLElement;
let uiState: UIState;
@@ -232,17 +234,11 @@ function handleFileSelection(filePath: string | null):
void {
}
uiState.currentlySelectedFilePath = filePath;
renderAllLists();
- if (!confirmMoveButton.disabled) {
- confirmMoveButton.focus();
- }
}
function handleDirSelection(dirPath: string | null): void {
uiState.currentlyChosenDirectoryPath = dirPath;
renderAllLists();
- if (!confirmMoveButton.disabled) {
- confirmMoveButton.focus();
- }
}
function onFileListClick(event: Event): void {
@@ -284,6 +280,7 @@ function onMaxFilesChange(event: FilterInputEvent): void {
}
async function onConfirmMoveClick(): Promise<void> {
+ errorAlert.classList.add("d-none");
if (uiState.currentlySelectedFilePath &&
uiState.currentlyChosenDirectoryPath && uiState.csrfToken) {
const formData = new FormData();
formData.append("csrf_token", uiState.csrfToken);
@@ -304,25 +301,24 @@ async function onConfirmMoveClick(): Promise<void> {
window.location.reload();
} else {
let errorMsg = `An error occurred while moving the file
(Status: ${response.status})`;
- if (response.status === 403) {
- errorMsg = "Permission denied to move the file.";
- } else if (response.status === 400) {
- errorMsg = "Invalid request to move the file (e.g. source
or target invalid).";
- }
+ if (response.status === 403) errorMsg = "Permission denied to
move the file.";
+ if (response.status === 400) errorMsg = "Invalid request to
move the file.";
try {
const errorData = await response.json();
- if (errorData && errorData.error) {
- errorMsg = errorData.error;
- }
- } catch (e) { }
- alert(errorMsg);
+ if (errorData && typeof errorData.error === "string")
errorMsg = errorData.error;
+ } catch (_) { }
+ errorAlert.textContent = errorMsg;
+ errorAlert.classList.remove("d-none");
+ return;
}
} catch (error) {
console.error("Network or fetch error:", error);
- alert("A network error occurred. Please check your connection and
try again.");
+ errorAlert.textContent = "A network error occurred. Please check
your connection and try again.";
+ errorAlert.classList.remove("d-none");
}
} else {
- alert("Please select both a file to move and a destination
directory.");
+ errorAlert.textContent = "Please select both a file to move and a
destination directory.";
+ errorAlert.classList.remove("d-none");
}
}
@@ -336,6 +332,7 @@ document.addEventListener("DOMContentLoaded", function () {
confirmMoveButton =
assertElementPresent(document.querySelector<HTMLButtonElement>(`#${CONFIRM_MOVE_BUTTON_ID}`),
CONFIRM_MOVE_BUTTON_ID);
currentMoveSelectionInfoElement =
assertElementPresent(document.getElementById(CURRENT_MOVE_SELECTION_INFO_ID) as
HTMLElement, CURRENT_MOVE_SELECTION_INFO_ID);
currentMoveSelectionInfoElement.setAttribute("aria-live", "polite");
+ errorAlert = assertElementPresent(document.getElementById(ERROR_ALERT_ID)
as HTMLElement, ERROR_ALERT_ID);
let initialFilePaths: string[] = [];
let initialTargetDirs: string[] = [];
diff --git a/atr/templates/finish-selected.html
b/atr/templates/finish-selected.html
index d70ca36..b8d28fc 100644
--- a/atr/templates/finish-selected.html
+++ b/atr/templates/finish-selected.html
@@ -101,6 +101,10 @@
{% if can_move %}
<h2>Move a file to a different directory</h2>
+ <div id="move-error-alert"
+ class="alert alert-danger d-none"
+ role="alert"
+ aria-live="assertive"></div>
<form class="atr-canary">
<div class="row">
<div class="col-lg-6">
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]