Skip to content

Commit dd42c02

Browse files
committed
Add rsync-based "copy tree" strategy
It is the preferred strategy, because, in the future, it will allow excluding files and directories (think ".gitignore"). However, since `rsync` requires a temporary directory to play correctly with runners like Testing Farm, the strategy will do nothing when caller provides no way to create such a temporary directory.
1 parent 136a6d9 commit dd42c02

5 files changed

Lines changed: 165 additions & 49 deletions

File tree

tests/unit/test_filesystem.py

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import contextlib
12
import os
23
import stat
34
import time
4-
from typing import Optional, cast
5+
from typing import Any, Generator, Optional, cast
56
from unittest import mock
67
from unittest.mock import MagicMock
78

@@ -64,6 +65,15 @@ def fixture_copy_tree_paths(tmppath: Path) -> CopyTreePathConfig:
6465
return source_dir, dest_dir, symlinks_supported
6566

6667

68+
@pytest.fixture(name="tmpdir_creator")
69+
def fixture_tmpdir_creator(tmppath: Path) -> tmt.utils.filesystem.TmpDirCreator:
70+
@contextlib.contextmanager
71+
def _tmpdir_creator(prefix: Optional[str] = None, suffix: Optional[str] = None) -> Generator[Path, None, None]:
72+
yield tmppath
73+
74+
return _tmpdir_creator
75+
76+
6777
def _assert_permissions_copied(src_path: Path, dest_path: Path) -> None:
6878
"""
6979
Assert that file/directory permissions are copied correctly.
@@ -120,6 +130,7 @@ def _run_metadata_test_for_item(
120130
@pytest.mark.parametrize('strategy', _STRATEGIES)
121131
def test_copy_tree_basic(
122132
strategy: tmt.utils.filesystem.CopyStrategy,
133+
tmpdir_creator: tmt.utils.filesystem.TmpDirCreator,
123134
copy_tree_paths: CopyTreePathConfig,
124135
root_logger: tmt.log.Logger,
125136
) -> None:
@@ -129,7 +140,7 @@ def test_copy_tree_basic(
129140

130141
source_dir, dest_dir, symlinks_supported = copy_tree_paths
131142

132-
strategy(source_dir, dest_dir, root_logger)
143+
strategy(src=source_dir, dst=dest_dir, tmpdir_creator=tmpdir_creator, logger=root_logger)
133144

134145
# Check if all files were copied and their content is correct
135146
for path, content in _EXPECTED_TEST_FILES.items():
@@ -147,7 +158,7 @@ def test_copy_tree_basic(
147158

148159
@pytest.mark.parametrize('strategy', _STRATEGIES)
149160
def test_copy_empty_source_directory(
150-
strategy: tmt.utils.filesystem.CopyStrategy, tmppath: Path, root_logger: tmt.log.Logger
161+
strategy: tmt.utils.filesystem.CopyStrategy, tmpdir_creator: tmt.utils.filesystem.TmpDirCreator, tmppath: Path, root_logger: tmt.log.Logger
151162
) -> None:
152163
"""
153164
Test copying an empty source directory.
@@ -157,7 +168,7 @@ def test_copy_empty_source_directory(
157168
empty_dst = tmppath / "empty_dst"
158169
empty_src.mkdir()
159170

160-
strategy(empty_src, empty_dst, root_logger)
171+
strategy(src=empty_src, dst=empty_dst, tmpdir_creator=tmpdir_creator, logger=root_logger)
161172

162173
# Verify the destination directory exists and is empty
163174
assert empty_dst.exists()
@@ -167,7 +178,7 @@ def test_copy_empty_source_directory(
167178

168179
@pytest.mark.parametrize('strategy', _STRATEGIES)
169180
def test_deeply_nested_directories(
170-
strategy: tmt.utils.filesystem.CopyStrategy, tmppath: Path, root_logger: tmt.log.Logger
181+
strategy: tmt.utils.filesystem.CopyStrategy, tmpdir_creator: tmt.utils.filesystem.TmpDirCreator, tmppath: Path, root_logger: tmt.log.Logger
171182
) -> None:
172183
"""Test copying deeply nested directory structures."""
173184
deep_src = tmppath / "deep_src"
@@ -181,7 +192,7 @@ def test_deeply_nested_directories(
181192
current_dir.mkdir()
182193
(current_dir / f"file_at_level_{level}.txt").write_text(f"{test_content} at level {level}")
183194

184-
strategy(deep_src, deep_dst, root_logger)
195+
strategy(src=deep_src, dst=deep_dst, tmpdir_creator=tmpdir_creator, logger=root_logger)
185196

186197
# Check if the deepest directory and file exist in the copied structure
187198
deepest_path = Path(
@@ -207,7 +218,7 @@ def test_permission_error_handling(
207218
dest_dir.chmod(0o444)
208219

209220
with pytest.raises(tmt.utils.GeneralError, match=r'(?i)Failed to copy tree'):
210-
tmt.utils.filesystem.copy_tree(source_dir, dest_dir, root_logger)
221+
tmt.utils.filesystem.copy_tree(src=source_dir, dst=dest_dir, logger=root_logger)
211222

212223

213224
def test_nonexistent_source_directory(tmppath: Path, root_logger: tmt.log.Logger) -> None:
@@ -219,7 +230,7 @@ def test_nonexistent_source_directory(tmppath: Path, root_logger: tmt.log.Logger
219230
destination = tmppath / "destination"
220231

221232
with pytest.raises(tmt.utils.GeneralError, match=r'.*not a directory or does not exist.*'):
222-
tmt.utils.filesystem.copy_tree(nonexistent_src, destination, root_logger)
233+
tmt.utils.filesystem.copy_tree(src=nonexistent_src, dst=destination, logger=root_logger)
223234

224235

225236
@mock.patch(
@@ -241,10 +252,10 @@ def test_fallback(
241252

242253
mock_copy_tree_cp, mock_copy_tree_shutil = tmt.utils.filesystem._COPY_TREE_STRATEGIES
243254

244-
tmt.utils.filesystem.copy_tree(source_dir, dest_dir, root_logger)
255+
tmt.utils.filesystem.copy_tree(src=source_dir, dst=dest_dir, logger=root_logger)
245256

246-
cast(MagicMock, mock_copy_tree_cp).assert_called_once_with(source_dir, dest_dir, mock.ANY)
247-
cast(MagicMock, mock_copy_tree_shutil).assert_called_once_with(source_dir, dest_dir, mock.ANY)
257+
cast(MagicMock, mock_copy_tree_cp).assert_called_once_with(src=source_dir, dst=dest_dir, tmpdir_creator=None, logger=mock.ANY)
258+
cast(MagicMock, mock_copy_tree_shutil).assert_called_once_with(src=source_dir, dst=dest_dir, tmpdir_creator=None, logger=mock.ANY)
248259

249260
# Verify files were copied using the fallback approach (shutil.copytree)
250261
for file_path in _EXPECTED_TEST_FILES:
@@ -254,6 +265,7 @@ def test_fallback(
254265
@pytest.mark.parametrize('strategy', _STRATEGIES)
255266
def test_metadata_preservation(
256267
strategy: tmt.utils.filesystem.CopyStrategy,
268+
tmpdir_creator: tmt.utils.filesystem.TmpDirCreator,
257269
copy_tree_paths: CopyTreePathConfig,
258270
root_logger: tmt.log.Logger,
259271
) -> None:
@@ -277,7 +289,7 @@ def test_metadata_preservation(
277289
source_dir, "meta_dir_shutil", is_dir=True, mode=0o500, atime=timestamp, mtime=timestamp
278290
)
279291

280-
strategy(source_dir, dest_dir, root_logger)
292+
strategy(src=source_dir, dst=dest_dir, tmpdir_creator=tmpdir_creator, logger=root_logger)
281293

282294
_run_metadata_test_for_item(dest_dir, test_file)
283295
_run_metadata_test_for_item(dest_dir, test_dir)
@@ -302,7 +314,7 @@ def test_all_strategies_fail(
302314
mock_copy_tree_cp, mock_copy_tree_shutil = tmt.utils.filesystem._COPY_TREE_STRATEGIES
303315

304316
with pytest.raises(tmt.utils.GeneralError):
305-
tmt.utils.filesystem.copy_tree(source_dir, dest_dir, root_logger)
317+
tmt.utils.filesystem.copy_tree(src=source_dir, dst=dest_dir, logger=root_logger)
306318

307319
cast(MagicMock, mock_copy_tree_cp).assert_called_once()
308320
cast(MagicMock, mock_copy_tree_shutil).assert_called_once()
@@ -311,6 +323,7 @@ def test_all_strategies_fail(
311323
@pytest.mark.parametrize('strategy', _STRATEGIES)
312324
def test_copy_to_existing_destination(
313325
strategy: tmt.utils.filesystem.CopyStrategy,
326+
tmpdir_creator: tmt.utils.filesystem.TmpDirCreator,
314327
copy_tree_paths: CopyTreePathConfig,
315328
root_logger: tmt.log.Logger,
316329
) -> None:
@@ -326,7 +339,7 @@ def test_copy_to_existing_destination(
326339
(dest_dir / "subdir" / "existing_in_subdir.txt").write_text("pre-existing in subdir")
327340
(dest_dir / "file1.txt").write_text("old file1 content")
328341

329-
strategy(source_dir, dest_dir, root_logger)
342+
strategy(src=source_dir, dst=dest_dir, tmpdir_creator=tmpdir_creator, logger=root_logger)
330343

331344
# Check that source files were copied and overwrite conflicting ones
332345
assert (dest_dir / "file1.txt").read_text() == _EXPECTED_TEST_FILES["file1.txt"]

tmt/libraries/beakerlib.py

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -432,7 +432,9 @@ def _do_fetch(self, directory: Path) -> None:
432432
self.parent.debug(f"Failed to find library {self} at {self.url}")
433433
raise LibraryError
434434
self.parent.debug(f"Library {self} is copied into {directory}")
435-
tmt.utils.filesystem.copy_tree(library_path, local_library_path, self._logger)
435+
tmt.utils.filesystem.copy_tree(
436+
src=library_path, dst=local_library_path, logger=self._logger
437+
)
436438

437439
self.parent.verbose(
438440
'using remote git library',
@@ -462,15 +464,15 @@ def _do_fetch(self, directory: Path) -> None:
462464

463465
# Copy fmf metadata
464466
tmt.utils.filesystem.copy_tree(
465-
clone_dir / '.fmf',
466-
directory / '.fmf',
467-
self._logger,
467+
src=clone_dir / '.fmf',
468+
dst=directory / '.fmf',
469+
logger=self._logger,
468470
)
469471
if self.path:
470472
tmt.utils.filesystem.copy_tree(
471-
clone_dir / self.path.unrooted() / '.fmf',
472-
directory / self.path.unrooted() / '.fmf',
473-
self._logger,
473+
src=clone_dir / self.path.unrooted() / '.fmf',
474+
dst=directory / self.path.unrooted() / '.fmf',
475+
logger=self._logger,
474476
)
475477

476478

@@ -530,12 +532,14 @@ def _do_fetch(self, directory: Path) -> None:
530532

531533
self.parent.debug(f"Copy local library '{self.fmf_node_path}' to '{directory}'.", level=3)
532534
# Copy only the required library
533-
tmt.utils.filesystem.copy_tree(library_path, local_library_path, self._logger)
535+
tmt.utils.filesystem.copy_tree(
536+
src=library_path, dst=local_library_path, logger=self._logger
537+
)
534538
# Remove metadata file(s) and create one with full data
535539
self._merge_metadata(library_path, local_library_path)
536540
# Copy fmf metadata
537541
tmt.utils.filesystem.copy_tree(
538-
self.path / '.fmf',
539-
directory / '.fmf',
540-
self._logger,
542+
src=self.path / '.fmf',
543+
dst=directory / '.fmf',
544+
logger=self._logger,
541545
)

tmt/steps/discover/__init__.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -386,9 +386,9 @@ def prune_tree(
386386
# Save fmf metadata
387387
for file_path in tmt.utils.filter_paths(tree_path, [r'\.fmf']):
388388
tmt.utils.filesystem.copy_tree(
389-
file_path,
390-
clone_tree_path / file_path.relative_to(tree_path),
391-
self._logger,
389+
src=file_path,
390+
dst=clone_tree_path / file_path.relative_to(tree_path),
391+
logger=self._logger,
392392
)
393393

394394
# Save upgrade plan
@@ -405,9 +405,9 @@ def prune_tree(
405405
assert test.path is not None # narrow type
406406
relative_test_path = test.path.unrooted()
407407
tmt.utils.filesystem.copy_tree(
408-
tree_path / relative_test_path,
409-
clone_tree_path / relative_test_path,
410-
self._logger,
408+
src=tree_path / relative_test_path,
409+
dst=clone_tree_path / relative_test_path,
410+
logger=self._logger,
411411
)
412412

413413
# Copy all parent main.fmf files
@@ -425,7 +425,7 @@ def prune_tree(
425425
# Clean phase.test_dir and copy back only required tests and files from clone_dir
426426
# This is to have correct paths in tests
427427
shutil.rmtree(self.test_dir, ignore_errors=True)
428-
tmt.utils.filesystem.copy_tree(clone_dir, self.test_dir, self._logger)
428+
tmt.utils.filesystem.copy_tree(src=clone_dir, dst=self.test_dir, logger=self._logger)
429429

430430
if self.clone_dirpath.exists():
431431
shutil.rmtree(self.clone_dirpath, ignore_errors=True)

tmt/steps/discover/fmf.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -627,7 +627,7 @@ def _fetch_local_repository(self) -> Optional[Path]:
627627
directory = fmf_root
628628
self.info('directory', directory, 'green')
629629
self.debug(f"Copy '{directory}' to '{self.test_dir}'.")
630-
tmt.utils.filesystem.copy_tree(directory, self.test_dir, self._logger)
630+
tmt.utils.filesystem.copy_tree(src=directory, dst=self.test_dir, logger=self._logger)
631631
return path
632632

633633
def go(self, *, path: Optional[Path] = None, logger: Optional[tmt.log.Logger] = None) -> None:
@@ -676,9 +676,9 @@ def process_distgit_source(self, distgit_dir: Path) -> None:
676676

677677
# Copy rest of files so TMT_SOURCE_DIR has patches, sources and spec file
678678
tmt.utils.filesystem.copy_tree(
679-
distgit_dir,
680-
self.source_dir,
681-
self._logger,
679+
src=distgit_dir,
680+
dst=self.source_dir,
681+
logger=self._logger,
682682
)
683683

684684
# patch & rediscover will happen later in the prepare step
@@ -902,7 +902,9 @@ def post_dist_git(self, created_content: list[Path]) -> None:
902902
f"Directory '{self.step.plan.node.root}' is not in a git repository."
903903
) from error
904904
self.debug(f"Copy '{git_root}' to '{self.test_dir}'.")
905-
tmt.utils.filesystem.copy_tree(git_root, self.test_dir, self._logger)
905+
tmt.utils.filesystem.copy_tree(
906+
src=git_root, dst=self.test_dir, logger=self._logger
907+
)
906908
else:
907909
if not dist_git_merge:
908910
if self.data.path:
@@ -926,9 +928,9 @@ def post_dist_git(self, created_content: list[Path]) -> None:
926928
src = self.source_dir / to_copy
927929
if src.is_dir():
928930
tmt.utils.filesystem.copy_tree(
929-
self.source_dir / to_copy,
930-
self.test_dir if flatten else self.test_dir / to_copy,
931-
self._logger,
931+
src=self.source_dir / to_copy,
932+
dst=self.test_dir if flatten else self.test_dir / to_copy,
933+
logger=self._logger,
932934
)
933935
else:
934936
shutil.copyfile(src, self.test_dir / to_copy)

0 commit comments

Comments
 (0)