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]