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 fce674b Add a form to remove RC tags from paths in bulk
fce674b is described below
commit fce674bde643c5edf4dde100ef2eb5789cac9c72
Author: Sean B. Palmer <[email protected]>
AuthorDate: Wed May 28 20:43:54 2025 +0100
Add a form to remove RC tags from paths in bulk
---
atr/analysis.py | 13 +
atr/routes/announce.py | 5 +-
atr/routes/finish.py | 516 +++++++++++++++++++++++--------------
atr/templates/finish-selected.html | 35 ++-
atr/util.py | 14 +-
5 files changed, 372 insertions(+), 211 deletions(-)
diff --git a/atr/analysis.py b/atr/analysis.py
index 8620c73..3526649 100755
--- a/atr/analysis.py
+++ b/atr/analysis.py
@@ -177,6 +177,19 @@ def architecture_pattern() -> str:
return "(" + "|".join(architectures) + ")(?=[_.-])"
+def candidate_highlight(path: pathlib.Path, prefix: str = "<strong>", suffix:
str = "</strong>") -> str:
+ parts = []
+ for part in path.parts:
+ if ("<" in part) or (">" in part) or ("&" in part):
+ # TODO: Should perhaps check for ' and " too for attribute value
safety
+ raise ValueError(f"Invalid path segment: {part}")
+ if _CANDIDATE_WHOLE.match(part):
+ parts.append(f"{prefix}{part}{suffix}")
+ continue
+ parts.append(_CANDIDATE_PARTIAL.sub(rf"{prefix}\g<0>{suffix}", part))
+ return str(pathlib.Path(*parts))
+
+
def candidate_match(segment: str) -> re.Match[str] | None:
return _CANDIDATE_WHOLE.match(segment) or
_CANDIDATE_PARTIAL.search(segment)
diff --git a/atr/routes/announce.py b/atr/routes/announce.py
index 404985d..40b6e10 100644
--- a/atr/routes/announce.py
+++ b/atr/routes/announce.py
@@ -175,7 +175,8 @@ async def selected_post(
# Prepare paths for file operations
source_base = util.release_directory_base(release)
- source = str(source_base / release.unwrap_revision_number)
+ source_path = source_base / release.unwrap_revision_number
+ source = str(source_path)
await _promote(release, data, preview_revision_number)
await data.commit()
@@ -200,7 +201,7 @@ async def selected_post(
raise routes.FlashError("Release already exists")
# Ensure that the permissions of every directory are 755
- await asyncio.to_thread(util.chmod_directories, target_path)
+ await asyncio.to_thread(util.chmod_directories, source_path)
try:
await aioshutil.move(source, target)
diff --git a/atr/routes/finish.py b/atr/routes/finish.py
index 979a745..db252e6 100644
--- a/atr/routes/finish.py
+++ b/atr/routes/finish.py
@@ -15,9 +15,11 @@
# specific language governing permissions and limitations
# under the License.
+import dataclasses
import logging
import pathlib
-from typing import Final
+from collections.abc import Awaitable, Callable
+from typing import Any, Final
import aiofiles.os
import quart
@@ -27,6 +29,7 @@ import werkzeug.wrappers.response as response
import wtforms
import wtforms.fields as fields
+import atr.analysis as analysis
import atr.db as db
import atr.db.models as models
import atr.revision as revision
@@ -40,13 +43,16 @@ SPECIAL_SUFFIXES: Final[frozenset[str]] =
frozenset({".asc", ".sha256", ".sha512
_LOGGER: Final = logging.getLogger(__name__)
+Respond = Callable[[int, str], Awaitable[tuple[quart_response.Response, int] |
response.Response]]
+
+
class DeleteEmptyDirectoryForm(util.QuartFormTyped):
"""Form for deleting an empty directory within a preview revision."""
directory_to_delete = wtforms.SelectField(
"Directory to delete", choices=[],
validators=[wtforms.validators.DataRequired()]
)
- submit = wtforms.SubmitField("Delete directory")
+ submit_delete_empty_dir = wtforms.SubmitField("Delete directory")
class MoveFileForm(util.QuartFormTyped):
@@ -78,6 +84,31 @@ class MoveFileForm(util.QuartFormTyped):
)
+class RemoveRCTagsForm(util.QuartFormTyped):
+ submit_remove_rc_tags = wtforms.SubmitField("Remove RC tags")
+
+
[email protected]
+class ProcessFormDataArgs:
+ formdata: datastructures.MultiDict
+ session: routes.CommitterSession
+ project_name: str
+ version_name: str
+ move_form: MoveFileForm
+ delete_dir_form: DeleteEmptyDirectoryForm
+ remove_rc_tags_form: RemoveRCTagsForm
+ can_move: bool
+ wants_json: bool
+ respond: Respond
+
+
[email protected]
+class RCTagAnalysisResult:
+ affected_paths_preview: list[tuple[str, str]]
+ affected_count: int
+ total_paths: int
+
+
@routes.committer("/finish/<project_name>/<version_name>", methods=["GET",
"POST"])
async def selected(
session: routes.CommitterSession, project_name: str, version_name: str
@@ -85,6 +116,24 @@ async def selected(
"""Finish a release preview."""
await session.check_access(project_name)
+ wants_json =
quart.request.accept_mimetypes.best_match(["application/json", "text/html"]) ==
"application/json"
+
+ async def respond(
+ http_status: int,
+ msg: str,
+ ) -> tuple[quart_response.Response, int] | response.Response:
+ """Helper to respond with JSON or flash message and redirect."""
+ nonlocal session
+ nonlocal project_name
+ nonlocal version_name
+ nonlocal wants_json
+
+ ok = http_status < 300
+ if wants_json:
+ return quart.jsonify(ok=ok, message=msg), http_status
+ await quart.flash(msg, "success" if ok else "error")
+ return await session.redirect(selected, project_name=project_name,
version_name=version_name)
+
async with db.session() as data:
release = await session.release(
project_name, version_name,
phase=models.ReleasePhase.RELEASE_PREVIEW, data=data
@@ -106,37 +155,36 @@ async def selected(
data=formdata if (formdata and formdata.get("form_action") !=
"create_dir") else None
)
delete_dir_form = await DeleteEmptyDirectoryForm.create_form(
- data=formdata if (formdata and formdata.get("form_action") ==
"delete_empty_dir") else None
+ data=formdata if (formdata and ("submit_delete_empty_dir" in
formdata)) else None
+ )
+ remove_rc_tags_form = await RemoveRCTagsForm.create_form(
+ data=formdata if (formdata and ("submit_remove_rc_tags" in formdata))
else None
)
# Populate choices dynamically for both GET and POST
move_form.source_files.choices = sorted([(str(p), str(p)) for p in
source_files_rel])
move_form.target_directory.choices = sorted([(str(d), str(d)) for d in
target_dirs])
can_move = (len(target_dirs) > 1) and (len(source_files_rel) > 0)
-
- empty_deletable_dirs: list[pathlib.Path] = []
- if latest_revision_dir.exists():
- for d_rel in target_dirs:
- if d_rel == pathlib.Path("."):
- # Disallow deletion of the root directory
- continue
- d_full = latest_revision_dir / d_rel
- if await aiofiles.os.path.isdir(d_full) and not await
aiofiles.os.listdir(d_full):
- empty_deletable_dirs.append(d_rel)
- delete_dir_form.directory_to_delete.choices = sorted([(str(p), str(p)) for
p in empty_deletable_dirs])
+ delete_dir_form.directory_to_delete.choices = await
_deletable_choices(latest_revision_dir, target_dirs)
if formdata:
- result = await _process_formdata(
- formdata, session, project_name, version_name, move_form,
delete_dir_form, can_move
+ pfd_args = ProcessFormDataArgs(
+ formdata=formdata,
+ session=session,
+ project_name=project_name,
+ version_name=version_name,
+ move_form=move_form,
+ delete_dir_form=delete_dir_form,
+ remove_rc_tags_form=remove_rc_tags_form,
+ can_move=can_move,
+ wants_json=wants_json,
+ respond=respond,
)
+ result = await _submission_process(pfd_args)
if result is not None:
return result
- # resp = await quart.current_app.make_response(template_rendered)
- # resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
- # resp.headers["Pragma"] = "no-cache"
- # resp.headers["Expires"] = "0"
- # return resp
+ rc_analysis_result = await _analyse_rc_tags(latest_revision_dir)
return await template.render(
"finish-selected.html",
asf_id=session.uid,
@@ -148,122 +196,95 @@ async def selected(
user_ssh_keys=user_ssh_keys,
target_dirs=sorted(list(target_dirs)),
max_files_to_show=10,
+ remove_rc_tags_form=remove_rc_tags_form,
+ rc_affected_paths_preview=rc_analysis_result.affected_paths_preview,
+ rc_affected_count=rc_analysis_result.affected_count,
+ rc_total_paths=rc_analysis_result.total_paths,
)
-async def _process_formdata(
- formdata: datastructures.MultiDict,
- session: routes.CommitterSession,
- project_name: str,
- version_name: str,
- move_form: MoveFileForm,
- delete_dir_form: DeleteEmptyDirectoryForm,
- can_move: bool,
-) -> tuple[quart_response.Response, int] | response.Response | str | None:
- form_action = formdata.get("form_action")
-
- if (
- (quart.request.method == "POST")
- and ("source_files" in formdata)
- and ("target_directory" in formdata)
- and (not form_action)
- ):
- source_files_data = formdata.getlist("source_files")
- target_dir_data = formdata.get("target_directory")
- wants_json =
quart.request.accept_mimetypes.best_match(["application/json", "text/html"]) ==
"application/json"
-
- if not source_files_data or not target_dir_data:
- return await _respond(
- session,
- project_name,
- version_name,
- wants_json,
- False,
- "Missing source file(s) or target directory.",
- 400,
- )
-
- source_files_rel = [pathlib.Path(sf) for sf in source_files_data]
- target_dir_rel = pathlib.Path(target_dir_data)
-
- if not source_files_rel:
- return await _respond(
- session, project_name, version_name, wants_json, False, "No
source files selected.", 400
- )
- return await _move_file_to_revision(
- source_files_rel, target_dir_rel, session, project_name,
version_name, wants_json
- )
+async def _analyse_rc_tags(latest_revision_dir: pathlib.Path) ->
RCTagAnalysisResult:
+ r = RCTagAnalysisResult(
+ affected_paths_preview=[],
+ affected_count=0,
+ total_paths=0,
+ )
- elif form_action == "delete_empty_dir":
- wants_json =
quart.request.accept_mimetypes.best_match(["application/json", "text/html"]) ==
"application/json"
- if await delete_dir_form.validate_on_submit():
- dir_to_delete_str = delete_dir_form.directory_to_delete.data
- return await _delete_empty_dir_action(
- pathlib.Path(dir_to_delete_str), session, project_name,
version_name, wants_json
- )
- elif wants_json:
- error_messages = []
- for field_name_str, error_list in delete_dir_form.errors.items():
- field_obj = getattr(delete_dir_form, field_name_str, None)
- label_text = field_name_str.replace("_", " ").title()
- if field_obj and hasattr(field_obj, "label") and
field_obj.label:
- label_text = field_obj.label.text
- error_messages.append(f"{label_text}: {', '.join(error_list)}")
- error_msg = "; ".join(error_messages)
- return await _respond(session, project_name, version_name, True,
False, error_msg or "Invalid input.", 400)
-
- elif ((form_action != "create_dir") or (form_action is None)) and can_move:
- return await _move_file(move_form, session, project_name, version_name)
- return None
+ if not latest_revision_dir.exists():
+ return r
+
+ async for p_rel in util.paths_recursive_all(latest_revision_dir):
+ r.total_paths += 1
+ original_path_str = str(p_rel)
+ stripped_path_str = str(analysis.candidate_removed(p_rel))
+ if original_path_str == stripped_path_str:
+ continue
+ r.affected_count += 1
+ if len(r.affected_paths_preview) >= 5:
+ # Can't break here, because we need to update the counts
+ continue
+ highlighted_preview = analysis.candidate_highlight(p_rel)
+ r.affected_paths_preview.append((highlighted_preview,
stripped_path_str))
+
+ return r
+
+
+async def _current_paths(creating: revision.Creating) -> list[pathlib.Path]:
+ all_current_paths_interim: list[pathlib.Path] = []
+ async for p_rel_interim in util.paths_recursive_all(creating.interim_path):
+ all_current_paths_interim.append(p_rel_interim)
+
+ # This manner of sorting is necessary to ensure that directories are
removed after their contents
+ all_current_paths_interim.sort(key=lambda p: (-len(p.parts), str(p)))
+ return all_current_paths_interim
+
+
+async def _deletable_choices(latest_revision_dir: pathlib.Path, target_dirs:
set[pathlib.Path]) -> Any:
+ # This should be -> list[tuple[str, str]], but that causes pyright to
complain incorrectly
+ # Details in
pyright/dist/dist/typeshed-fallback/stubs/WTForms/wtforms/fields/choices.pyi
+ # _Choice: TypeAlias = tuple[Any, str] | tuple[Any, str, dict[str, Any]]
+ # Then it wants us to use list[_Choice] (= list[tuple[Any, str]])
+ # But it says, incorrectly, that list[tuple[str, str]] is not a
list[_Choice]
+ # This mistake is not made by mypy
+ empty_deletable_dirs: list[pathlib.Path] = []
+ if latest_revision_dir.exists():
+ for d_rel in target_dirs:
+ if d_rel == pathlib.Path("."):
+ # Disallow deletion of the root directory
+ continue
+ d_full = latest_revision_dir / d_rel
+ if await aiofiles.os.path.isdir(d_full) and not await
aiofiles.os.listdir(d_full):
+ empty_deletable_dirs.append(d_rel)
+ return sorted([(str(p), str(p)) for p in empty_deletable_dirs])
-async def _move_file(
- form: MoveFileForm,
+async def _delete_empty_directory(
+ dir_to_delete_rel: pathlib.Path,
session: routes.CommitterSession,
project_name: str,
version_name: str,
-) -> 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_files_rel_str_list = form.source_files.data
- target_dir_rel_str = form.target_directory.data
- if not source_files_rel_str_list or not target_dir_rel_str:
- return await _respond(
- session,
- project_name,
- version_name,
- wants_json,
- False,
- "Source file(s) or target directory missing.",
- 400,
- )
+ respond: Respond,
+) -> tuple[quart_response.Response, int] | response.Response:
+ try:
+ description = f"Delete empty directory {dir_to_delete_rel} via web
interface"
+ async with revision.create_and_manage(
+ project_name, version_name, session.uid, description=description
+ ) as creating:
+ path_to_remove = creating.interim_path / dir_to_delete_rel
+
path_to_remove.resolve().relative_to(creating.interim_path.resolve())
+ if not await aiofiles.os.path.isdir(path_to_remove):
+ raise revision.FailedError(f"Path '{dir_to_delete_rel}' is not
a directory.")
+ if await aiofiles.os.listdir(path_to_remove):
+ raise revision.FailedError(f"Directory '{dir_to_delete_rel}'
is not empty.")
+ await aiofiles.os.rmdir(path_to_remove)
- source_files_rel = [pathlib.Path(sf_str) for sf_str in
source_files_rel_str_list]
- target_dir_rel = pathlib.Path(target_dir_rel_str)
- return await _move_file_to_revision(
- source_files_rel, target_dir_rel, session, project_name,
version_name, wants_json
- )
- else:
- if wants_json:
- error_messages = []
- for field_name, field_errors in form.errors.items():
- field_object = getattr(form, field_name, None)
- label_text = field_name.replace("_", " ").title()
- if field_object and hasattr(field_object, "label") and
field_object.label:
- label_text = field_object.label.text
- error_messages.append(f"{label_text}: {',
'.join(field_errors)}")
- error_string = "; ".join(error_messages)
- return await _respond(session, project_name, version_name, True,
False, error_string, 400)
- else:
- for field_name, field_errors in form.errors.items():
- field_object = getattr(form, field_name, None)
- label_text = field_name.replace("_", " ").title()
- if field_object and hasattr(field_object, "label") and
field_object.label:
- label_text = field_object.label.text
- for error_message_text in field_errors:
- await quart.flash(f"{label_text}: {error_message_text}",
"warning")
- return None
+ except Exception:
+ _LOGGER.exception(f"Unexpected error deleting directory
{dir_to_delete_rel} for {project_name}/{version_name}")
+ return await respond(500, "An unexpected error occurred.")
+
+ if creating.failed is not None:
+ return await respond(400, str(creating.failed))
+ return await respond(200, f"Deleted empty directory
'{dir_to_delete_rel}'.")
async def _move_file_to_revision(
@@ -272,7 +293,7 @@ async def _move_file_to_revision(
session: routes.CommitterSession,
project_name: str,
version_name: str,
- wants_json: bool,
+ respond: Respond,
) -> tuple[quart_response.Response, int] | response.Response:
try:
description = "File move through web interface"
@@ -291,15 +312,7 @@ async def _move_file_to_revision(
)
if creating.failed is not None:
- return await _respond(
- session,
- project_name,
- version_name,
- wants_json,
- False,
- str(creating.failed),
- 409,
- )
+ return await respond(409, str(creating.failed))
response_messages = []
if moved_files_names:
@@ -309,32 +322,88 @@ async def _move_file_to_revision(
if not response_messages:
if not source_files_rel:
- return await _respond(
- session, project_name, version_name, wants_json, False,
"No source files specified for move.", 400
- )
+ return await respond(400, "No source files specified for
move.")
msg = f"No files were moved. {', '.join(skipped_files_names)}
already in '{target_dir_rel}'."
- return await _respond(session, project_name, version_name,
wants_json, True, msg, 200)
+ return await respond(200, msg)
- final_msg = ". ".join(response_messages) + "."
- return await _respond(session, project_name, version_name, wants_json,
True, final_msg, 200)
+ return await respond(200, ". ".join(response_messages) + ".")
except FileNotFoundError:
_LOGGER.exception("File not found during move operation in new
revision")
- return await _respond(
- session,
- project_name,
- version_name,
- wants_json,
- False,
- "Error: Source file not found during move operation.",
- 400,
- )
+ return await respond(400, "Error: Source file not found during move
operation.")
except OSError as e:
_LOGGER.exception("Error moving file in new revision")
- return await _respond(session, project_name, version_name, wants_json,
False, f"Error moving file: {e}", 500)
+ return await respond(500, f"Error moving file: {e}")
except Exception as e:
_LOGGER.exception("Unexpected error during file move")
- return await _respond(session, project_name, version_name, wants_json,
False, f"ERROR: {e!s}", 500)
+ return await respond(500, f"ERROR: {e!s}")
+
+
+async def _submission_process(
+ args: ProcessFormDataArgs,
+) -> tuple[quart_response.Response, int] | response.Response | str | None:
+ delete_empty_directory = "submit_delete_empty_dir" in args.formdata
+ remove_rc_tags = "submit_remove_rc_tags" in args.formdata
+ move_file = ("source_files" in args.formdata) and ("target_directory" in
args.formdata)
+
+ if delete_empty_directory:
+ return await _submission_process_delete_empty_directory(args)
+
+ if remove_rc_tags:
+ return await _submission_process_remove_rc_tags(args)
+
+ if move_file:
+ return await _submission_process_move_file(args)
+
+ return None
+
+
+async def _submission_process_delete_empty_directory(
+ args: ProcessFormDataArgs,
+) -> tuple[quart_response.Response, int] | response.Response | str | None:
+ if await args.delete_dir_form.validate_on_submit():
+ dir_to_delete_str = args.delete_dir_form.directory_to_delete.data
+ return await _delete_empty_directory(
+ pathlib.Path(dir_to_delete_str), args.session, args.project_name,
args.version_name, args.respond
+ )
+ elif args.wants_json:
+ error_messages = []
+ for field_name_str, error_list in args.delete_dir_form.errors.items():
+ field_obj = getattr(args.delete_dir_form, field_name_str, None)
+ label_text = field_name_str.replace("_", " ").title()
+ if field_obj and hasattr(field_obj, "label") and field_obj.label:
+ label_text = field_obj.label.text
+ error_messages.append(f"{label_text}: {', '.join(error_list)}")
+ error_msg = "; ".join(error_messages)
+ return await args.respond(400, error_msg or "Invalid input.")
+ return None
+
+
+async def _submission_process_move_file(
+ args: ProcessFormDataArgs,
+) -> tuple[quart_response.Response, int] | response.Response | str | None:
+ source_files_data = args.formdata.getlist("source_files")
+ target_dir_data = args.formdata.get("target_directory")
+
+ if not source_files_data or not target_dir_data:
+ return await args.respond(400, "Missing source file(s) or target
directory.")
+ source_files_rel = [pathlib.Path(sf) for sf in source_files_data]
+ target_dir_rel = pathlib.Path(target_dir_data)
+ if not source_files_rel:
+ return await args.respond(400, "No source files selected.")
+ return await _move_file_to_revision(
+ source_files_rel, target_dir_rel, args.session, args.project_name,
args.version_name, args.respond
+ )
+
+
+async def _submission_process_remove_rc_tags(
+ args: ProcessFormDataArgs,
+) -> tuple[quart_response.Response, int] | response.Response | str | None:
+ if await args.remove_rc_tags_form.validate_on_submit():
+ return await _remove_rc_tags(args.session, args.project_name,
args.version_name, args.respond)
+ elif args.wants_json:
+ return await args.respond(400, "Invalid request for RC tag removal.")
+ return None
def _related_files(path: pathlib.Path) -> list[pathlib.Path]:
@@ -349,20 +418,106 @@ def _related_files(path: pathlib.Path) ->
list[pathlib.Path]:
]
-async def _respond(
+async def _remove_rc_tags(
session: routes.CommitterSession,
project_name: str,
version_name: str,
- wants_json: bool,
- ok: bool,
- msg: str,
- http_status: int = 200,
+ respond: Respond,
) -> tuple[quart_response.Response, int] | response.Response:
- """Helper to respond with JSON or flash message and redirect."""
- if wants_json:
- return quart.jsonify(ok=ok, message=msg), http_status
- await quart.flash(msg, "success" if ok else "error")
- return await session.redirect(selected, project_name=project_name,
version_name=version_name)
+ description = "Remove RC tags from paths via web interface"
+ error_messages: list[str] = []
+
+ try:
+ async with revision.create_and_manage(
+ project_name, version_name, session.uid, description=description
+ ) as creating:
+ renamed_count = await _remove_rc_tags_revision(creating,
error_messages)
+
+ if creating.failed is not None:
+ return await respond(409, str(creating.failed))
+
+ if error_messages:
+ status_ok = renamed_count > 0
+ # TODO: Ideally HTTP would have a general mixed status, like 207
but for anything
+ http_status = 200 if status_ok else 500
+ msg = f"RC tags removed for {renamed_count} item(s) with some
errors: {'; '.join(error_messages)}"
+ return await respond(http_status, msg)
+
+ if renamed_count > 0:
+ return await respond(200, f"Successfully removed RC tags from
{renamed_count} item(s).")
+
+ return await respond(200, "No items required RC tag removal or no
changes were made.")
+
+ except Exception as e:
+ return await respond(500, f"Unexpected error: {e!s}")
+
+
+async def _remove_rc_tags_revision(
+ creating: revision.Creating,
+ error_messages: list[str],
+) -> int:
+ all_current_paths_interim = await _current_paths(creating)
+ renamed_count_local = 0
+ for path_rel_original_interim in all_current_paths_interim:
+ path_rel_stripped_interim =
analysis.candidate_removed(path_rel_original_interim)
+
+ if path_rel_original_interim != path_rel_stripped_interim:
+ # Absolute paths of the source and destination
+ full_original_path = creating.interim_path /
path_rel_original_interim
+ full_stripped_path = creating.interim_path /
path_rel_stripped_interim
+
+ skip, renamed_count_local = await _remove_rc_tags_revision_item(
+ path_rel_original_interim,
+ full_original_path,
+ full_stripped_path,
+ error_messages,
+ renamed_count_local,
+ )
+ if skip:
+ continue
+
+ try:
+ if not await
aiofiles.os.path.exists(full_stripped_path.parent):
+ # This could happen if e.g. a file is in an RC tagged
directory
+ await aiofiles.os.makedirs(full_stripped_path.parent,
exist_ok=True)
+
+ if await aiofiles.os.path.exists(full_stripped_path):
+ error_messages.append(
+ f"Skipped '{path_rel_original_interim}': target
'{path_rel_stripped_interim}' already exists."
+ )
+ continue
+
+ await aiofiles.os.rename(full_original_path,
full_stripped_path)
+ renamed_count_local += 1
+ except Exception as e:
+ error_messages.append(f"Error renaming
'{path_rel_original_interim}': {e}")
+ return renamed_count_local
+
+
+async def _remove_rc_tags_revision_item(
+ path_rel_original_interim: pathlib.Path,
+ full_original_path: pathlib.Path,
+ full_stripped_path: pathlib.Path,
+ error_messages: list[str],
+ renamed_count_local: int,
+) -> tuple[bool, int]:
+ if await aiofiles.os.path.isdir(full_original_path):
+ # If moving an RC tagged directory to an existing directory...
+ is_target_dir_and_exists = await
aiofiles.os.path.isdir(full_stripped_path)
+ if is_target_dir_and_exists and (full_stripped_path !=
full_original_path):
+ try:
+ # And the source directory is empty...
+ if not await aiofiles.os.listdir(full_original_path):
+ # This means we probably moved files out of the RC tagged
directory
+ # In any case, we can't move it, so we have to delete it
+ await aiofiles.os.rmdir(full_original_path)
+ renamed_count_local += 1
+ else:
+ error_messages.append(f"Source RC directory
'{path_rel_original_interim}' is not empty, skipping.")
+ except OSError as e:
+ error_messages.append(f"Error removing source RC directory
'{path_rel_original_interim}': {e}")
+ return True, renamed_count_local
+ return False, renamed_count_local
async def _setup_revision(
@@ -464,36 +619,3 @@ async def _sources_and_targets(latest_revision_dir:
pathlib.Path) -> tuple[list[
target_dirs.add(item_rel_path)
return source_items_rel, target_dirs
-
-
-async def _delete_empty_dir_action(
- dir_to_delete_rel: pathlib.Path,
- session: routes.CommitterSession,
- project_name: str,
- version_name: str,
- wants_json: bool,
-) -> tuple[quart_response.Response, int] | response.Response:
- try:
- description = f"Delete empty directory {dir_to_delete_rel} via web
interface"
- async with revision.create_and_manage(
- project_name, version_name, session.uid, description=description
- ) as creating:
- path_to_remove = creating.interim_path / dir_to_delete_rel
-
path_to_remove.resolve().relative_to(creating.interim_path.resolve())
- if not await aiofiles.os.path.isdir(path_to_remove):
- raise revision.FailedError(f"Path '{dir_to_delete_rel}' is not
a directory.")
- if await aiofiles.os.listdir(path_to_remove):
- raise revision.FailedError(f"Directory '{dir_to_delete_rel}'
is not empty.")
- await aiofiles.os.rmdir(path_to_remove)
-
- except Exception:
- _LOGGER.exception(f"Unexpected error deleting directory
{dir_to_delete_rel} for {project_name}/{version_name}")
- return await _respond(
- session, project_name, version_name, wants_json, False, "An
unexpected error occurred.", 500
- )
-
- if creating.failed is not None:
- return await _respond(session, project_name, version_name, wants_json,
False, str(creating.failed), 400)
- return await _respond(
- session, project_name, version_name, wants_json, True, f"Deleted empty
directory '{dir_to_delete_rel}'.", 200
- )
diff --git a/atr/templates/finish-selected.html
b/atr/templates/finish-selected.html
index 60c1255..1b14f92 100644
--- a/atr/templates/finish-selected.html
+++ b/atr/templates/finish-selected.html
@@ -103,7 +103,7 @@
<div class="alert alert-warning mb-4" role="alert">
<p class="fw-semibold mb-1">TODO</p>
<p class="mb-1">
- We plan to add tools to help release managers to distribute release
artifacts on distribution networks. Currently you must do this manually. Other
planned enhancements include removing "RC" tags from filename, and allowing the
creation and deletion of directories.
+ We plan to add tools to help release managers to distribute release
artifacts on distribution networks. Currently you must do this manually.
</p>
</div>
@@ -176,14 +176,39 @@
{{ delete_dir_form.hidden_tag() }}
<div class="input-group">
{{ delete_dir_form.directory_to_delete(class="form-select") }}
- <button type="submit"
- class="btn btn-danger"
- name="form_action"
- value="delete_empty_dir">Delete directory</button>
+ {{ delete_dir_form.submit_delete_empty_dir(class="btn btn-danger") }}
</div>
{{ forms.errors(delete_dir_form.directory_to_delete,
classes="text-danger small mt-1") }}
</form>
{% endif %}
+
+ <h2>Remove release candidate tags</h2>
+ {% if rc_affected_count > 0 %}
+ <div class="alert alert-info mb-3">
+ <p class="mb-3 fw-semibold">
+ {{ rc_affected_count }} / {{ rc_total_paths }} paths would be affected
by RC tag removal.
+ </p>
+ {% if rc_affected_paths_preview %}
+ <p class="mb-2">Preview of first {{ rc_affected_paths_preview | length
}} changes:</p>
+ <table class="table table-sm table-striped border mt-3">
+ <tbody>
+ {% for original, stripped in rc_affected_paths_preview %}
+ <tr>
+ <td>{{ original | safe }}</td>
+ <td>{{ stripped }}</td>
+ </tr>
+ {% endfor %}
+ </tbody>
+ </table>
+ {% endif %}
+ </div>
+ <form method="post" class="mb-4 atr-canary">
+ {{ remove_rc_tags_form.hidden_tag() }}
+ {{ remove_rc_tags_form.submit_remove_rc_tags(class="btn btn-warning") }}
+ </form>
+ {% else %}
+ <p>No paths with RC tags found to remove.</p>
+ {% endif %}
{% endblock content %}
{% block javascripts %}
diff --git a/atr/util.py b/atr/util.py
index a97b49f..0326d88 100644
--- a/atr/util.py
+++ b/atr/util.py
@@ -147,6 +147,13 @@ async def async_temporary_directory(
pass
+def chmod_directories(path: pathlib.Path, permissions: int = 0o755) -> None:
+ os.chmod(path, permissions)
+ for dir_path in path.rglob("*"):
+ if dir_path.is_dir():
+ os.chmod(dir_path, permissions)
+
+
def compute_sha3_256(file_data: bytes) -> str:
"""Compute SHA3-256 hash of file data."""
return hashlib.sha3_256(file_data).hexdigest()
@@ -161,13 +168,6 @@ async def compute_sha512(file_path: pathlib.Path) -> str:
return sha512.hexdigest()
-def chmod_directories(path: pathlib.Path, permissions: int = 0o755) -> None:
- os.chmod(path, permissions)
- for dir_path in path.rglob("*"):
- if dir_path.is_dir():
- os.chmod(dir_path, permissions)
-
-
async def content_list(
phase_subdir: pathlib.Path, project_name: str, version_name: str,
revision_name: str | None = None
) -> AsyncGenerator[FileStat]:
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]