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 83e7d6c9 Migrate the revision creator that clones from a specific
revision
83e7d6c9 is described below
commit 83e7d6c931fb8b6e17a6074e087ad863e06e34d3
Author: Sean B. Palmer <[email protected]>
AuthorDate: Wed Feb 18 16:25:49 2026 +0000
Migrate the revision creator that clones from a specific revision
---
atr/post/revisions.py | 17 +---
atr/storage/writers/revision.py | 26 ++++--
tests/unit/test_create_revision.py | 157 ++++++++++++++++++++++++++++++++++++-
3 files changed, 181 insertions(+), 19 deletions(-)
diff --git a/atr/post/revisions.py b/atr/post/revisions.py
index 09a74f30..5906200c 100644
--- a/atr/post/revisions.py
+++ b/atr/post/revisions.py
@@ -15,7 +15,6 @@
# specific language governing permissions and limitations
# under the License.
-import aioshutil
import asfquart.base as base
import atr.blueprints.post as post
@@ -24,7 +23,6 @@ import atr.get as get
import atr.models.sql as sql
import atr.shared as shared
import atr.storage as storage
-import atr.util as util
import atr.web as web
@@ -50,7 +48,6 @@ async def _set_revision(
async with db.session() as data:
release = await session.release(project_name, version_name,
phase=None, data=data)
- selected_revision_dir = util.release_directory_base(release) /
selected_revision_number
if release.phase not in {sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
sql.ReleasePhase.RELEASE_PREVIEW}:
raise base.ASFQuartException("Cannot set revision for non-draft or
preview release", errorcode=400)
@@ -67,18 +64,12 @@ async def _set_revision(
description = f"Copy of revision {selected_revision_number} through web
interface"
async with storage.write(session) as write:
wacp = await write.as_project_committee_participant(project_name)
- async with wacp.revision.create_and_manage(
- project_name, version_name, session.uid, description=description
- ) as creating:
- # TODO: Stop create_and_manage from hard linking the parent first
- await aioshutil.rmtree(creating.interim_path)
- await util.create_hard_link_clone(selected_revision_dir,
creating.interim_path)
-
- if creating.new is None:
- raise base.ASFQuartException("Internal error: New revision not
found", errorcode=500)
+ new_revision = await wacp.revision.create_revision(
+ project_name, version_name, session.uid, description=description,
clone_from=selected_revision_number
+ )
return await session.redirect(
get.revisions.selected,
- success=f"Copied revision {selected_revision_number} to new latest
revision, {creating.new.number}",
+ success=f"Copied revision {selected_revision_number} to new latest
revision, {new_revision.number}",
project_name=project_name,
version_name=version_name,
)
diff --git a/atr/storage/writers/revision.py b/atr/storage/writers/revision.py
index b2992e06..8009853a 100644
--- a/atr/storage/writers/revision.py
+++ b/atr/storage/writers/revision.py
@@ -278,6 +278,7 @@ class CommitteeParticipant(FoundationCommitter):
description: str | None = None,
use_check_cache: bool = True,
modify: Callable[[pathlib.Path, sql.Revision | None], Awaitable[None]]
| None = None,
+ clone_from: str | None = None,
) -> sql.Revision:
"""Create a new revision."""
# Get the release
@@ -286,7 +287,18 @@ class CommitteeParticipant(FoundationCommitter):
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)
+ if clone_from is not None:
+ old_revision = await data.revision(release_name=release_name,
number=clone_from).demand(
+ RuntimeError(f"Revision {clone_from} does not exist")
+ )
+ else:
+ old_revision = await interaction.latest_revision(release)
+
+ if clone_from is not None:
+ old_release_dir = util.release_directory_base(release) / clone_from
+ else:
+ old_release_dir = util.release_directory(release)
+ merge_enabled = clone_from is None
# Create a temporary directory
# We ensure, below, that it's removed on any exception
@@ -299,7 +311,6 @@ class CommitteeParticipant(FoundationCommitter):
# 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 modify is not None:
@@ -338,8 +349,8 @@ class CommitteeParticipant(FoundationCommitter):
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)
+ if merge_enabled and (old_revision is not None):
+ base_dir = old_release_dir
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)
@@ -375,7 +386,12 @@ class CommitteeParticipant(FoundationCommitter):
# 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):
+ if (
+ merge_enabled
+ and (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(
diff --git a/tests/unit/test_create_revision.py
b/tests/unit/test_create_revision.py
index d785a6d6..7d836f06 100644
--- a/tests/unit/test_create_revision.py
+++ b/tests/unit/test_create_revision.py
@@ -21,10 +21,158 @@ import unittest.mock as mock
import pytest
+import atr.models.sql as sql
import atr.storage.types as types
import atr.storage.writers.revision as revision
+class AsyncContextManager:
+ async def __aenter__(self):
+ return None
+
+ async def __aexit__(self, _exc_type, _exc, _tb):
+ return False
+
+
+class FakeRevision:
+ def __init__(
+ self,
+ release_name: str,
+ release: object,
+ asfuid: str,
+ created: object,
+ phase: sql.ReleasePhase,
+ description: str | None,
+ use_check_cache: bool,
+ ):
+ self.asfuid = asfuid
+ self.created = created
+ self.description = description
+ self.name = ""
+ self.number = ""
+ self.parent_name: str | None = None
+ self.phase = phase
+ self.release = release
+ self.release_name = release_name
+ self.use_check_cache = use_check_cache
+
+
+class MockSafeData:
+ def __init__(self, parent_name: str):
+ self._new_revision: FakeRevision | None = None
+ self._parent_name = parent_name
+ self.add = mock.MagicMock(side_effect=self._add)
+ self.begin = mock.MagicMock(return_value=AsyncContextManager())
+ self.begin_immediate = mock.AsyncMock()
+ self.commit = mock.AsyncMock()
+ self.flush = mock.AsyncMock(side_effect=self._flush)
+ self.refresh = mock.AsyncMock()
+
+ def _add(self, new_revision: "FakeRevision") -> None:
+ self._new_revision = new_revision
+
+ async def _flush(self) -> None:
+ if self._new_revision is None:
+ raise RuntimeError("Expected data.add to set _new_revision before
flush")
+ self._new_revision.name = f"{self._new_revision.release_name} 00006"
+ self._new_revision.number = "00006"
+ self._new_revision.parent_name = self._parent_name
+
+
+class MockSafeSession:
+ def __init__(self, data: MockSafeData):
+ self._data = data
+
+ async def __aenter__(self) -> MockSafeData:
+ return self._data
+
+ async def __aexit__(self, _exc_type, _exc, _tb):
+ return False
+
+
[email protected]
+async def
test_clone_from_older_revision_skips_merge_without_intervening_change(tmp_path:
pathlib.Path):
+ release = mock.MagicMock()
+ release.phase = sql.ReleasePhase.RELEASE_PREVIEW
+ release.project = mock.MagicMock()
+ release.project.release_policy = None
+ release.release_policy = None
+ release_name = sql.release_name("proj", "1.0")
+
+ latest_revision = mock.MagicMock()
+ latest_revision.name = f"{release_name} 00005"
+ latest_revision.number = "00005"
+
+ selected_revision = mock.MagicMock()
+ selected_revision.name = f"{release_name} 00002"
+ selected_revision.number = "00002"
+
+ mock_session = _mock_db_session(release,
selected_revision=selected_revision)
+ participant = _make_participant()
+ safe_data = MockSafeData(parent_name=latest_revision.name)
+ merge_mock = mock.AsyncMock()
+
+ with (
+ mock.patch.object(revision.aiofiles.os, "makedirs",
new_callable=mock.AsyncMock),
+ mock.patch.object(revision.aiofiles.os, "rename",
new_callable=mock.AsyncMock),
+ mock.patch.object(
+ revision.attestable, "load", new_callable=mock.AsyncMock,
return_value=mock.MagicMock(paths={})
+ ),
+ mock.patch.object(
+ revision.attestable, "paths_to_hashes_and_sizes",
new_callable=mock.AsyncMock, return_value=({}, {})
+ ),
+ mock.patch.object(revision.attestable, "write_files_data",
new_callable=mock.AsyncMock),
+ mock.patch.object(revision.db, "session", return_value=mock_session),
+ mock.patch.object(revision.detection, "validate_directory",
return_value=[]),
+ mock.patch.object(
+ revision.interaction, "latest_revision",
new_callable=mock.AsyncMock, return_value=latest_revision
+ ),
+ mock.patch.object(revision.merge, "merge", new=merge_mock),
+ mock.patch.object(revision.sql, "Revision",
side_effect=_make_fake_revision),
+ mock.patch.object(revision, "SafeSession",
return_value=MockSafeSession(safe_data)),
+ mock.patch.object(revision.tasks, "draft_checks",
new_callable=mock.AsyncMock),
+ mock.patch.object(revision.util, "chmod_directories"),
+ mock.patch.object(revision.util, "chmod_files"),
+ mock.patch.object(
+ revision.util, "create_hard_link_clone",
new_callable=mock.AsyncMock
+ ) as create_hard_link_clone_mock,
+ mock.patch.object(revision.util, "get_tmp_dir", return_value=tmp_path),
+ mock.patch.object(revision.util, "paths_to_inodes", return_value={})
as paths_to_inodes_mock,
+ mock.patch.object(revision.util, "release_directory",
return_value=tmp_path / "releases" / "00006"),
+ mock.patch.object(revision.util, "release_directory_base",
return_value=tmp_path / "releases"),
+ ):
+ await participant.create_revision("proj", "1.0", "test",
clone_from="00002")
+
+ if merge_mock.called:
+ raise AssertionError(
+ "Expected create_revision(clone_from=...) to skip merge when no
concurrent revision exists"
+ )
+ if create_hard_link_clone_mock.await_count != 1:
+ raise AssertionError("Expected one hard-link clone from clone_from
source revision")
+ if paths_to_inodes_mock.call_count != 1:
+ raise AssertionError("Expected one paths_to_inodes call when
clone_from disables merge")
+
+ clone_await_args = create_hard_link_clone_mock.await_args
+ inodes_call_args = paths_to_inodes_mock.call_args
+ if clone_await_args is None:
+ raise AssertionError("Expected create_hard_link_clone await args")
+ if inodes_call_args is None:
+ raise AssertionError("Expected paths_to_inodes call args")
+
+ clone_source = clone_await_args.args[0]
+ clone_destination = clone_await_args.args[1]
+ clone_kwargs = clone_await_args.kwargs
+ inodes_input = inodes_call_args.args[0]
+ expected_clone_source = tmp_path / "releases" / "00002"
+
+ if clone_source != expected_clone_source:
+ raise AssertionError("Expected hard-link clone source to match
clone_from revision directory")
+ if clone_kwargs.get("do_not_create_dest_dir") is not True:
+ raise AssertionError("Expected hard-link clone to use
do_not_create_dest_dir=True")
+ if clone_destination != inodes_input:
+ raise AssertionError("Expected inode scan to run only for the
temporary working directory")
+
+
@pytest.mark.asyncio
async def test_modify_failed_error_propagates_and_cleans_up(tmp_path:
pathlib.Path):
received_args: dict[str, object] = {}
@@ -51,17 +199,24 @@ async def
test_modify_failed_error_propagates_and_cleans_up(tmp_path: pathlib.Pa
assert not os.listdir(tmp_path)
+def _make_fake_revision(**kwargs) -> FakeRevision:
+ return FakeRevision(**kwargs)
+
+
def _make_participant() -> revision.CommitteeParticipant:
mock_write = mock.MagicMock()
mock_write.authorisation.asf_uid = "test"
return revision.CommitteeParticipant(mock_write, mock.MagicMock(),
mock.MagicMock(), "test")
-def _mock_db_session(release: mock.MagicMock) -> mock.MagicMock:
+def _mock_db_session(release: mock.MagicMock, selected_revision:
mock.MagicMock | None = None) -> mock.MagicMock:
mock_query = mock.MagicMock()
mock_query.demand = mock.AsyncMock(return_value=release)
+ mock_selected_query = mock.MagicMock()
+ mock_selected_query.demand = mock.AsyncMock(return_value=selected_revision)
mock_data = mock.AsyncMock()
mock_data.release = mock.MagicMock(return_value=mock_query)
+ mock_data.revision = mock.MagicMock(return_value=mock_selected_query)
mock_data.__aenter__ = mock.AsyncMock(return_value=mock_data)
mock_data.__aexit__ = mock.AsyncMock(return_value=False)
return mock_data
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]