This is an automated email from the ASF dual-hosted git repository.

sbp pushed a commit to branch sbp
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git


The following commit(s) were added to refs/heads/sbp by this push:
     new a5745c15 Migrate revision creators that modify existing files
a5745c15 is described below

commit a5745c1515b0e17e8da120d362cb76ddeaafa25f
Author: Sean B. Palmer <[email protected]>
AuthorDate: Wed Feb 18 15:09:24 2026 +0000

    Migrate revision creators that modify existing files
---
 atr/storage/writers/keys.py        |  16 ++++--
 atr/storage/writers/release.py     | 103 ++++++++++++++++++++++++-------------
 atr/storage/writers/revision.py    |   6 +--
 tests/unit/test_create_revision.py |   6 +--
 4 files changed, 85 insertions(+), 46 deletions(-)

diff --git a/atr/storage/writers/keys.py b/atr/storage/writers/keys.py
index ae0e78f8..f2cbe783 100644
--- a/atr/storage/writers/keys.py
+++ b/atr/storage/writers/keys.py
@@ -25,7 +25,10 @@ import asyncio
 import datetime
 import tempfile
 import textwrap
-from typing import NoReturn
+from typing import TYPE_CHECKING, NoReturn
+
+if TYPE_CHECKING:
+    import pathlib
 
 import aiofiles
 import aiofiles.os
@@ -481,11 +484,14 @@ class CommitteeParticipant(FoundationCommitter):
         # Remove the KEYS file if 100% imported
         if (outcomes.result_count > 0) and (outcomes.error_count == 0):
             description = "Removed KEYS file after successful import through 
web interface"
-            async with self.__write_as.revision.create_and_manage(
-                project_name, version_name, self.__asf_uid, 
description=description
-            ) as creating:
-                path_in_new_revision = creating.interim_path / "KEYS"
+
+            async def modify(path: pathlib.Path, _old_rev: sql.Revision | 
None) -> None:
+                path_in_new_revision = path / "KEYS"
                 await aiofiles.os.remove(path_in_new_revision)
+
+            await self.__write_as.revision.create_revision(
+                project_name, version_name, self.__asf_uid, 
description=description, modify=modify
+            )
         return outcomes
 
     def __block_models(self, key_block: str, ldap_data: dict[str, str]) -> 
list[types.Key | Exception]:
diff --git a/atr/storage/writers/release.py b/atr/storage/writers/release.py
index d0753546..72d9f3b4 100644
--- a/atr/storage/writers/release.py
+++ b/atr/storage/writers/release.py
@@ -171,25 +171,34 @@ class CommitteeParticipant(FoundationCommitter):
         self, project_name: str, version_name: str, dir_to_delete_rel: 
pathlib.Path
     ) -> str | None:
         description = f"Delete empty directory {dir_to_delete_rel} via web 
interface"
-        async with self.create_and_manage_revision(project_name, version_name, 
description) as creating:
-            path_to_remove = creating.interim_path / dir_to_delete_rel
-            
path_to_remove.resolve().relative_to(creating.interim_path.resolve())
+
+        async def modify(path: pathlib.Path, _old_rev: sql.Revision | None) -> 
None:
+            path_to_remove = path / dir_to_delete_rel
+            resolved = await asyncio.to_thread(path_to_remove.resolve)
+            resolved.relative_to(await asyncio.to_thread(path.resolve))
             if not await aiofiles.os.path.isdir(path_to_remove):
                 raise types.FailedError(f"Path '{dir_to_delete_rel}' is not a 
directory.")
             if await aiofiles.os.listdir(path_to_remove):
                 raise types.FailedError(f"Directory '{dir_to_delete_rel}' is 
not empty.")
             await aiofiles.os.rmdir(path_to_remove)
-        if creating.failed is not None:
-            return str(creating.failed)
+
+        try:
+            await self.__write_as.revision.create_revision(
+                project_name, version_name, self.__asf_uid, 
description=description, modify=modify
+            )
+        except types.FailedError as e:
+            return str(e)
         return None
 
     async def delete_file(self, project_name: str, version: str, 
rel_path_to_delete: pathlib.Path) -> int:
         metadata_files_deleted = 0
         description = "File deletion through web interface"
-        async with self.create_and_manage_revision(project_name, version, 
description) as creating:
+
+        async def modify(path: pathlib.Path, _old_rev: sql.Revision | None) -> 
None:
+            nonlocal metadata_files_deleted
             # Uses new_revision_number for logging only
             # Path to delete within the new revision directory
-            path_in_new_revision = creating.interim_path / rel_path_to_delete
+            path_in_new_revision = path / rel_path_to_delete
 
             # Check that the file exists in the new revision
             if not await aiofiles.os.path.exists(path_in_new_revision):
@@ -202,22 +211,27 @@ class CommitteeParticipant(FoundationCommitter):
                 # If so, delete all associated metadata files in the new 
revision
                 async for p in 
util.paths_recursive(path_in_new_revision.parent):
                     # Construct full path within the new revision
-                    metadata_path_obj = creating.interim_path / p
+                    metadata_path_obj = path / p
                     if p.name.startswith(rel_path_to_delete.name + "."):
                         await aiofiles.os.remove(metadata_path_obj)
                         metadata_files_deleted += 1
 
             # Delete the file
             await aiofiles.os.remove(path_in_new_revision)
+
+        await self.__write_as.revision.create_revision(
+            project_name, version, self.__asf_uid, description=description, 
modify=modify
+        )
         return metadata_files_deleted
 
     async def generate_hash_file(self, project_name: str, version_name: str, 
rel_path: pathlib.Path) -> None:
         description = "Hash generation through web interface"
-        async with self.create_and_manage_revision(project_name, version_name, 
description) as creating:
+
+        async def modify(path: pathlib.Path, _old_rev: sql.Revision | None) -> 
None:
             # Uses new_revision_number for logging only
-            path_in_new_revision = creating.interim_path / rel_path
+            path_in_new_revision = path / rel_path
             hash_path_rel = rel_path.name + ".sha512"
-            hash_path_in_new_revision = creating.interim_path / 
rel_path.parent / hash_path_rel
+            hash_path_in_new_revision = path / rel_path.parent / hash_path_rel
 
             # Check that the source file exists in the new revision
             if not await aiofiles.os.path.exists(path_in_new_revision):
@@ -239,6 +253,10 @@ class CommitteeParticipant(FoundationCommitter):
             async with aiofiles.open(hash_path_in_new_revision, "w") as f:
                 await f.write(f"{hash_value}  {rel_path.name}\n")
 
+        await self.__write_as.revision.create_revision(
+            project_name, version_name, self.__asf_uid, 
description=description, modify=modify
+        )
+
     async def import_from_svn(
         self, project_name: str, version_name: str, svn_url: str, revision: 
str, target_subdirectory: str | None
     ) -> sql.Task:
@@ -271,16 +289,22 @@ class CommitteeParticipant(FoundationCommitter):
         moved_files_names: list[str] = []
         skipped_files_names: list[str] = []
 
-        async with self.create_and_manage_revision(project_name, version_name, 
description) as creating:
+        async def modify(path: pathlib.Path, _old_rev: sql.Revision | None) -> 
None:
             await self.__setup_revision(
                 source_files_rel,
                 target_dir_rel,
-                creating,
+                path,
                 moved_files_names,
                 skipped_files_names,
             )
-        creation_error = str(creating.failed) if (creating.failed is not None) 
else None
-        return creation_error, moved_files_names, skipped_files_names
+
+        try:
+            await self.__write_as.revision.create_revision(
+                project_name, version_name, self.__asf_uid, 
description=description, modify=modify
+            )
+        except types.FailedError as e:
+            return str(e), moved_files_names, skipped_files_names
+        return None, moved_files_names, skipped_files_names
 
     async def promote_to_candidate(
         self,
@@ -348,11 +372,19 @@ class CommitteeParticipant(FoundationCommitter):
     async def remove_rc_tags(self, project_name: str, version_name: str) -> 
tuple[str | None, int, list[str]]:
         description = "Remove RC tags from paths via web interface"
         error_messages: list[str] = []
+        renamed_count = 0
 
-        async with self.create_and_manage_revision(project_name, version_name, 
description) as creating:
-            renamed_count = await self.__remove_rc_tags_revision(creating, 
error_messages)
-        creation_error = str(creating.failed) if (creating.failed is not None) 
else None
-        return creation_error, renamed_count, error_messages
+        async def modify(path: pathlib.Path, _old_rev: sql.Revision | None) -> 
None:
+            nonlocal renamed_count
+            renamed_count = await self.__remove_rc_tags_revision(path, 
error_messages)
+
+        try:
+            await self.__write_as.revision.create_revision(
+                project_name, version_name, self.__asf_uid, 
description=description, modify=modify
+            )
+        except types.FailedError as e:
+            return str(e), renamed_count, error_messages
+        return None, renamed_count, error_messages
 
     async def start(self, project_name: str, version: str) -> 
tuple[sql.Release, sql.Project]:  # noqa: C901
         """Creates the initial release draft record and revision directory."""
@@ -479,9 +511,9 @@ class CommitteeParticipant(FoundationCommitter):
         creation_error = str(creating.failed) if (creating.failed is not None) 
else None
         return creation_error, len(files)
 
-    async def __current_paths(self, creating: types.Creating) -> 
list[pathlib.Path]:
+    async def __current_paths(self, interim_path: pathlib.Path) -> 
list[pathlib.Path]:
         all_current_paths_interim: list[pathlib.Path] = []
-        async for p_rel_interim in 
util.paths_recursive_all(creating.interim_path):
+        async for p_rel_interim in util.paths_recursive_all(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
@@ -544,18 +576,18 @@ class CommitteeParticipant(FoundationCommitter):
 
     async def __remove_rc_tags_revision(
         self,
-        creating: types.Creating,
+        interim_path: pathlib.Path,
         error_messages: list[str],
     ) -> int:
-        all_current_paths_interim = await self.__current_paths(creating)
+        all_current_paths_interim = await self.__current_paths(interim_path)
         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
+                full_original_path = interim_path / path_rel_original_interim
+                full_stripped_path = interim_path / path_rel_stripped_interim
 
                 skip, renamed_count_local = await 
self.__remove_rc_tags_revision_item(
                     path_rel_original_interim,
@@ -622,13 +654,14 @@ class CommitteeParticipant(FoundationCommitter):
         self,
         source_files_rel: list[pathlib.Path],
         target_dir_rel: pathlib.Path,
-        creating: types.Creating,
+        interim_path: pathlib.Path,
         moved_files_names: list[str],
         skipped_files_names: list[str],
     ) -> None:
-        target_path = creating.interim_path / target_dir_rel
+        target_path = interim_path / target_dir_rel
         try:
-            target_path.resolve().relative_to(creating.interim_path.resolve())
+            resolved = await asyncio.to_thread(target_path.resolve)
+            resolved.relative_to(await asyncio.to_thread(interim_path.resolve))
         except ValueError:
             # Path traversal detected
             raise types.FailedError("Paths must be restricted to the release 
directory")
@@ -650,14 +683,14 @@ class CommitteeParticipant(FoundationCommitter):
 
         for source_file_rel in source_files_rel:
             await self.__setup_revision_item(
-                source_file_rel, target_dir_rel, creating, moved_files_names, 
skipped_files_names, target_path
+                source_file_rel, target_dir_rel, interim_path, 
moved_files_names, skipped_files_names, target_path
             )
 
     async def __setup_revision_item(
         self,
         source_file_rel: pathlib.Path,
         target_dir_rel: pathlib.Path,
-        creating: types.Creating,
+        interim_path: pathlib.Path,
         moved_files_names: list[str],
         skipped_files_names: list[str],
         target_path: pathlib.Path,
@@ -666,10 +699,10 @@ class CommitteeParticipant(FoundationCommitter):
             skipped_files_names.append(source_file_rel.name)
             return
 
-        full_source_item_path = creating.interim_path / source_file_rel
+        full_source_item_path = interim_path / source_file_rel
 
         if await aiofiles.os.path.isdir(full_source_item_path):
-            if (target_dir_rel == source_file_rel) or (creating.interim_path / 
target_dir_rel).resolve().is_relative_to(
+            if (target_dir_rel == source_file_rel) or (interim_path / 
target_dir_rel).resolve().is_relative_to(
                 full_source_item_path.resolve()
             ):
                 raise types.FailedError("Cannot move a directory into itself 
or a subdirectory of itself")
@@ -682,9 +715,9 @@ class CommitteeParticipant(FoundationCommitter):
             moved_files_names.append(source_file_rel.name)
         else:
             related_files = self.__related_files(source_file_rel)
-            bundle = [f for f in related_files if await 
aiofiles.os.path.exists(creating.interim_path / f)]
+            bundle = [f for f in related_files if await 
aiofiles.os.path.exists(interim_path / f)]
             for f_check in bundle:
-                if await aiofiles.os.path.isdir(creating.interim_path / 
f_check):
+                if await aiofiles.os.path.isdir(interim_path / f_check):
                     raise types.FailedError("A related 'file' is actually a 
directory")
 
             collisions = [f.name for f in bundle if await 
aiofiles.os.path.exists(target_path / f.name)]
@@ -692,7 +725,7 @@ class CommitteeParticipant(FoundationCommitter):
                 raise types.FailedError("A related file already exists in the 
target directory")
 
             for f in bundle:
-                await aiofiles.os.rename(creating.interim_path / f, 
target_path / f.name)
+                await aiofiles.os.rename(interim_path / f, target_path / 
f.name)
                 if f == source_file_rel:
                     moved_files_names.append(f.name)
 
diff --git a/atr/storage/writers/revision.py b/atr/storage/writers/revision.py
index 1d67d7da..b2992e06 100644
--- a/atr/storage/writers/revision.py
+++ b/atr/storage/writers/revision.py
@@ -277,7 +277,7 @@ class CommitteeParticipant(FoundationCommitter):
         asf_uid: str,
         description: str | None = None,
         use_check_cache: bool = True,
-        modifier: Callable[[pathlib.Path, sql.Revision | None], 
Awaitable[None]] | None = None,
+        modify: Callable[[pathlib.Path, sql.Revision | None], Awaitable[None]] 
| None = None,
     ) -> sql.Revision:
         """Create a new revision."""
         # Get the release
@@ -302,8 +302,8 @@ class CommitteeParticipant(FoundationCommitter):
                 old_release_dir = util.release_directory(release)
                 await util.create_hard_link_clone(old_release_dir, 
temp_dir_path, do_not_create_dest_dir=True)
             # The directory is either empty or its files are hard linked to 
the previous revision
-            if modifier is not None:
-                await modifier(temp_dir_path, old_revision)
+            if modify is not None:
+                await modify(temp_dir_path, old_revision)
         except types.FailedError:
             await aioshutil.rmtree(temp_dir)
             raise
diff --git a/tests/unit/test_create_revision.py 
b/tests/unit/test_create_revision.py
index dcb443e3..62fe3150 100644
--- a/tests/unit/test_create_revision.py
+++ b/tests/unit/test_create_revision.py
@@ -26,10 +26,10 @@ import atr.storage.writers.revision as revision
 
 
 @pytest.mark.asyncio
-async def test_modifier_failed_error_propagates_and_cleans_up(tmp_path: 
pathlib.Path):
+async def test_modify_failed_error_propagates_and_cleans_up(tmp_path: 
pathlib.Path):
     received_args: dict[str, object] = {}
 
-    async def modifier(path: pathlib.Path, old_rev: object) -> None:
+    async def modify(path: pathlib.Path, old_rev: object) -> None:
         received_args["path"] = path
         received_args["old_rev"] = old_rev
         (path / "file.txt").write_text("Should be cleaned up.")
@@ -44,7 +44,7 @@ async def 
test_modifier_failed_error_propagates_and_cleans_up(tmp_path: pathlib.
         patch.object(revision.util, "get_tmp_dir", return_value=tmp_path),
     ):
         with pytest.raises(types.FailedError, match="Intentional error"):
-            await participant.create_revision("proj", "1.0", "test", 
modifier=modifier)
+            await participant.create_revision("proj", "1.0", "test", 
modify=modify)
 
     assert isinstance(received_args["path"], pathlib.Path)
     assert received_args["old_rev"] is None


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

Reply via email to