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 e6887dac Add a continuation passing style version of the method to 
create a revision
e6887dac is described below

commit e6887dac439d71dec5101f23b7cb594351683b68
Author: Sean B. Palmer <[email protected]>
AuthorDate: Wed Feb 18 14:41:29 2026 +0000

    Add a continuation passing style version of the method to create a revision
---
 atr/storage/writers/revision.py    | 166 ++++++++++++++++++++++++++++++++++++-
 tests/unit/test_create_revision.py |  67 +++++++++++++++
 2 files changed, 232 insertions(+), 1 deletion(-)

diff --git a/atr/storage/writers/revision.py b/atr/storage/writers/revision.py
index b2cd2a38..1d67d7da 100644
--- a/atr/storage/writers/revision.py
+++ b/atr/storage/writers/revision.py
@@ -41,7 +41,7 @@ import atr.tasks as tasks
 import atr.util as util
 
 if TYPE_CHECKING:
-    from collections.abc import AsyncGenerator
+    from collections.abc import AsyncGenerator, Awaitable, Callable
 
 
 class SafeSession:
@@ -270,6 +270,170 @@ class CommitteeParticipant(FoundationCommitter):
                     # Must use caller_data here because we acquired the write 
lock
                     await tasks.draft_checks(asf_uid, project_name, 
version_name, new_revision.number, caller_data=data)
 
+    async def create_revision(  # noqa: C901
+        self,
+        project_name: str,
+        version_name: str,
+        asf_uid: str,
+        description: str | None = None,
+        use_check_cache: bool = True,
+        modifier: Callable[[pathlib.Path, sql.Revision | None], 
Awaitable[None]] | None = None,
+    ) -> sql.Revision:
+        """Create a new revision."""
+        # Get the release
+        release_name = sql.release_name(project_name, version_name)
+        async with db.session() as data:
+            release = await data.release(name=release_name, 
_release_policy=True, _project_release_policy=True).demand(
+                RuntimeError("Release does not exist for new revision 
creation")
+            )
+            old_revision = await interaction.latest_revision(release)
+
+        # Create a temporary directory
+        # We ensure, below, that it's removed on any exception
+        # Use the tmp subdirectory of state, to ensure that it is on the same 
filesystem
+        prefix_token = secrets.token_hex(16)
+        temp_dir: str = await asyncio.to_thread(tempfile.mkdtemp, 
prefix=prefix_token + "-", dir=util.get_tmp_dir())
+        temp_dir_path = pathlib.Path(temp_dir)
+
+        try:
+            # The directory was created by mkdtemp, but it's empty
+            if old_revision is not None:
+                # If this is not the first revision, hard link the previous 
revision
+                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)
+        except types.FailedError:
+            await aioshutil.rmtree(temp_dir)
+            raise
+        except Exception:
+            await aioshutil.rmtree(temp_dir)
+            raise
+
+        validation_errors = await 
asyncio.to_thread(detection.validate_directory, temp_dir_path)
+        if validation_errors:
+            await aioshutil.rmtree(temp_dir)
+            raise types.FailedError("File validation failed:\n" + 
"\n".join(validation_errors))
+
+        # Ensure that the permissions of every directory are 755
+        try:
+            await asyncio.to_thread(util.chmod_directories, temp_dir_path)
+        except Exception:
+            await aioshutil.rmtree(temp_dir)
+            raise
+
+        # Make files read only to prevent them from being modified through 
hard links
+        try:
+            await asyncio.to_thread(util.chmod_files, temp_dir_path, 0o444)
+        except Exception:
+            await aioshutil.rmtree(temp_dir)
+            raise
+
+        try:
+            path_to_hash, path_to_size = await 
attestable.paths_to_hashes_and_sizes(temp_dir_path)
+            parent_revision_number = old_revision.number if old_revision else 
None
+            previous_attestable = None
+            if parent_revision_number is not None:
+                previous_attestable = await attestable.load(project_name, 
version_name, parent_revision_number)
+            base_inodes: dict[str, int] = {}
+            base_hashes: dict[str, str] = {}
+            if old_revision is not None:
+                base_dir = util.release_directory(release)
+                base_inodes = await asyncio.to_thread(util.paths_to_inodes, 
base_dir)
+                base_hashes = dict(previous_attestable.paths) if 
(previous_attestable is not None) else {}
+            n_inodes = await asyncio.to_thread(util.paths_to_inodes, 
temp_dir_path)
+        except Exception:
+            await aioshutil.rmtree(temp_dir)
+            raise
+
+        async with SafeSession(temp_dir) as data:
+            try:
+                # This is the only place where models.Revision is constructed
+                # That makes models.populate_revision_sequence_and_name safe 
against races
+                # Because that event is called when data.add is called below
+                # And we have a write lock at that point through the use of 
data.begin_immediate
+                new_revision = sql.Revision(
+                    release_name=release_name,
+                    release=release,
+                    asfuid=asf_uid,
+                    created=datetime.datetime.now(datetime.UTC),
+                    phase=release.phase,
+                    description=description,
+                    use_check_cache=use_check_cache,
+                )
+
+                # Acquire the write lock and add the row
+                # We need this write lock for moving the directory below 
atomically
+                # But it also helps to make 
models.populate_revision_sequence_and_name safe against races
+                await data.begin_immediate()
+                data.add(new_revision)
+
+                # Flush but do not commit the new revision row to get its name 
and number
+                # The row will still be invisible to other sessions after 
flushing
+                await data.flush()
+
+                # Merge with the prior revision if there was an intervening 
change
+                prior_name = new_revision.parent_name
+                if (old_revision is not None) and (prior_name is not None) and 
(prior_name != old_revision.name):
+                    prior_number = prior_name.split()[-1]
+                    prior_dir = util.release_directory_base(release) / 
prior_number
+                    await merge.merge(
+                        base_inodes,
+                        base_hashes,
+                        prior_dir,
+                        project_name,
+                        version_name,
+                        prior_number,
+                        temp_dir_path,
+                        n_inodes,
+                        path_to_hash,
+                        path_to_size,
+                    )
+                    previous_attestable = await attestable.load(project_name, 
version_name, prior_number)
+
+                # Rename the directory to the new revision number
+                await data.refresh(release)
+                new_revision_dir = util.release_directory(release)
+
+                # Ensure that the parent directory exists
+                await aiofiles.os.makedirs(new_revision_dir.parent, 
exist_ok=True)
+
+                # Rename the temporary interim directory to the new revision 
number
+                await aiofiles.os.rename(temp_dir, new_revision_dir)
+            except Exception:
+                await aioshutil.rmtree(temp_dir)
+                raise
+
+            policy = release.release_policy or release.project.release_policy
+
+            await attestable.write_files_data(
+                project_name,
+                version_name,
+                new_revision.number,
+                policy.model_dump() if policy else None,
+                asf_uid,
+                previous_attestable,
+                path_to_hash,
+                path_to_size,
+            )
+
+            # Commit to end the transaction started by data.begin_immediate
+            # We must commit the revision before starting the checks
+            # This also releases the write lock
+            await data.commit()
+
+            async with data.begin():
+                # Run checks if in DRAFT phase
+                # We could also run this outside the data Session
+                # But then it would create its own new Session
+                # It does, however, need a transaction to be created using 
data.begin()
+                if release.phase == sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
+                    # Must use caller_data here because we acquired the write 
lock
+                    await tasks.draft_checks(asf_uid, project_name, 
version_name, new_revision.number, caller_data=data)
+
+        return new_revision
+
 
 class CommitteeMember(CommitteeParticipant):
     def __init__(
diff --git a/tests/unit/test_create_revision.py 
b/tests/unit/test_create_revision.py
new file mode 100644
index 00000000..dcb443e3
--- /dev/null
+++ b/tests/unit/test_create_revision.py
@@ -0,0 +1,67 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import os
+import pathlib
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+import atr.storage.types as types
+import atr.storage.writers.revision as revision
+
+
[email protected]
+async def test_modifier_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:
+        received_args["path"] = path
+        received_args["old_rev"] = old_rev
+        (path / "file.txt").write_text("Should be cleaned up.")
+        raise types.FailedError("Intentional error")
+
+    mock_session = _mock_db_session(MagicMock())
+    participant = _make_participant()
+
+    with (
+        patch.object(revision.db, "session", return_value=mock_session),
+        patch.object(revision.interaction, "latest_revision", 
new_callable=AsyncMock, return_value=None),
+        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)
+
+    assert isinstance(received_args["path"], pathlib.Path)
+    assert received_args["old_rev"] is None
+    assert not os.listdir(tmp_path)
+
+
+def _make_participant() -> revision.CommitteeParticipant:
+    mock_write = MagicMock()
+    mock_write.authorisation.asf_uid = "test"
+    return revision.CommitteeParticipant(mock_write, MagicMock(), MagicMock(), 
"test")
+
+
+def _mock_db_session(release: MagicMock) -> MagicMock:
+    mock_query = MagicMock()
+    mock_query.demand = AsyncMock(return_value=release)
+    mock_data = AsyncMock()
+    mock_data.release = MagicMock(return_value=mock_query)
+    mock_data.__aenter__ = AsyncMock(return_value=mock_data)
+    mock_data.__aexit__ = AsyncMock(return_value=False)
+    return mock_data


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

Reply via email to