FileBackup 0.4.2__tar.gz → 0.4.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. {filebackup-0.4.2 → filebackup-0.4.4}/PKG-INFO +1 -1
  2. {filebackup-0.4.2 → filebackup-0.4.4}/src/FileBackup/Offsite.py +58 -36
  3. {filebackup-0.4.2 → filebackup-0.4.4}/src/FileBackup/__init__.py +1 -1
  4. {filebackup-0.4.2 → filebackup-0.4.4}/tests/Offsite_UnitTest.py +44 -1
  5. {filebackup-0.4.2 → filebackup-0.4.4}/.copier-answers.yml +0 -0
  6. {filebackup-0.4.2 → filebackup-0.4.4}/.github/CODEOWNERS +0 -0
  7. {filebackup-0.4.2 → filebackup-0.4.4}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  8. {filebackup-0.4.2 → filebackup-0.4.4}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  9. {filebackup-0.4.2 → filebackup-0.4.4}/.github/ISSUE_TEMPLATE/custom.md +0 -0
  10. {filebackup-0.4.2 → filebackup-0.4.4}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  11. {filebackup-0.4.2 → filebackup-0.4.4}/.github/actions/release_impl/action.yml +0 -0
  12. {filebackup-0.4.2 → filebackup-0.4.4}/.github/pull_request_template.md +0 -0
  13. {filebackup-0.4.2 → filebackup-0.4.4}/.github/release_sources.yaml +0 -0
  14. {filebackup-0.4.2 → filebackup-0.4.4}/.github/workflows/CICD.yml +0 -0
  15. {filebackup-0.4.2 → filebackup-0.4.4}/.github/workflows/CICD_impl.yml +0 -0
  16. {filebackup-0.4.2 → filebackup-0.4.4}/.github/workflows/codeql.yml +0 -0
  17. {filebackup-0.4.2 → filebackup-0.4.4}/.gitignore +0 -0
  18. {filebackup-0.4.2 → filebackup-0.4.4}/.pre-commit-config.yaml +0 -0
  19. {filebackup-0.4.2 → filebackup-0.4.4}/.python-version +0 -0
  20. {filebackup-0.4.2 → filebackup-0.4.4}/BuildBinary.py +0 -0
  21. {filebackup-0.4.2 → filebackup-0.4.4}/CODE_OF_CONDUCT.md +0 -0
  22. {filebackup-0.4.2 → filebackup-0.4.4}/CONTRIBUTING.md +0 -0
  23. {filebackup-0.4.2 → filebackup-0.4.4}/DEVELOPMENT.md +0 -0
  24. {filebackup-0.4.2 → filebackup-0.4.4}/GOVERNANCE.md +0 -0
  25. {filebackup-0.4.2 → filebackup-0.4.4}/LICENSE +0 -0
  26. {filebackup-0.4.2 → filebackup-0.4.4}/MAINTAINERS.md +0 -0
  27. {filebackup-0.4.2 → filebackup-0.4.4}/README.md +0 -0
  28. {filebackup-0.4.2 → filebackup-0.4.4}/SECURITY.md +0 -0
  29. {filebackup-0.4.2 → filebackup-0.4.4}/minisign_key.pub +0 -0
  30. {filebackup-0.4.2 → filebackup-0.4.4}/pyproject.toml +0 -0
  31. {filebackup-0.4.2 → filebackup-0.4.4}/src/AutoGitSemVer.yaml +0 -0
  32. {filebackup-0.4.2 → filebackup-0.4.4}/src/FileBackup/CommandLine/CommandLineArguments.py +0 -0
  33. {filebackup-0.4.2 → filebackup-0.4.4}/src/FileBackup/CommandLine/EntryPoint.py +0 -0
  34. {filebackup-0.4.2 → filebackup-0.4.4}/src/FileBackup/CommandLine/MirrorEntryPoint.py +0 -0
  35. {filebackup-0.4.2 → filebackup-0.4.4}/src/FileBackup/CommandLine/OffsiteEntryPoint.py +0 -0
  36. {filebackup-0.4.2 → filebackup-0.4.4}/src/FileBackup/CommandLine/__init__.py +0 -0
  37. {filebackup-0.4.2 → filebackup-0.4.4}/src/FileBackup/DataStore/FastGlacierDataStore.py +0 -0
  38. {filebackup-0.4.2 → filebackup-0.4.4}/src/FileBackup/DataStore/FileSystemDataStore.py +0 -0
  39. {filebackup-0.4.2 → filebackup-0.4.4}/src/FileBackup/DataStore/Interfaces/BulkStorageDataStore.py +0 -0
  40. {filebackup-0.4.2 → filebackup-0.4.4}/src/FileBackup/DataStore/Interfaces/DataStore.py +0 -0
  41. {filebackup-0.4.2 → filebackup-0.4.4}/src/FileBackup/DataStore/Interfaces/FileBasedDataStore.py +0 -0
  42. {filebackup-0.4.2 → filebackup-0.4.4}/src/FileBackup/DataStore/Interfaces/__init__.py +0 -0
  43. {filebackup-0.4.2 → filebackup-0.4.4}/src/FileBackup/DataStore/S3BrowserDataStore.py +0 -0
  44. {filebackup-0.4.2 → filebackup-0.4.4}/src/FileBackup/DataStore/SFTPDataStore.py +0 -0
  45. {filebackup-0.4.2 → filebackup-0.4.4}/src/FileBackup/DataStore/__init__.py +0 -0
  46. {filebackup-0.4.2 → filebackup-0.4.4}/src/FileBackup/Impl/Common.py +0 -0
  47. {filebackup-0.4.2 → filebackup-0.4.4}/src/FileBackup/Impl/__init__.py +0 -0
  48. {filebackup-0.4.2 → filebackup-0.4.4}/src/FileBackup/Mirror.py +0 -0
  49. {filebackup-0.4.2 → filebackup-0.4.4}/src/FileBackup/Snapshot.py +0 -0
  50. {filebackup-0.4.2 → filebackup-0.4.4}/src/FileBackup/py.typed +0 -0
  51. {filebackup-0.4.2 → filebackup-0.4.4}/tests/CommandLine/EntryPoint_UnitTest.py +0 -0
  52. {filebackup-0.4.2 → filebackup-0.4.4}/tests/Mirror_UnitTest.py +0 -0
  53. {filebackup-0.4.2 → filebackup-0.4.4}/tests/Snapshot_UnitTest.py +0 -0
  54. {filebackup-0.4.2 → filebackup-0.4.4}/tests/TestHelpers.py +0 -0
  55. {filebackup-0.4.2 → filebackup-0.4.4}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: FileBackup
3
- Version: 0.4.2
3
+ Version: 0.4.4
4
4
  Summary: Tool for creating and restoring file system backups.
5
5
  Project-URL: Homepage, https://github.com/davidbrownell/FileBackup
6
6
  Project-URL: Documentation, https://github.com/davidbrownell/FileBackup
@@ -555,7 +555,7 @@ def Restore(
555
555
  )
556
556
  return
557
557
 
558
- with _YieldTempDirectory("staging content") as staging_directory:
558
+ with _YieldTempDirectory(working_dir / "staging", "staging content") as staging_directory:
559
559
  # ----------------------------------------------------------------------
560
560
  @dataclass(frozen=True)
561
561
  class Instruction:
@@ -709,15 +709,26 @@ def Restore(
709
709
  def ExecuteTask(
710
710
  status: ExecuteTasks.Status,
711
711
  ) -> Path:
712
- destination_dir = working_dir / directory
713
-
714
- if destination_dir.is_dir():
712
+ # This function will create the following directory structure:
713
+ #
714
+ # <working_dir>
715
+ # └── <directory>
716
+ # └── transferred (temporary)
717
+ # └── decompressed (temporary)
718
+ # └── final
719
+
720
+ assert working_dir is not None
721
+ this_working_dir = working_dir / directory
722
+ final_dir = this_working_dir / "final"
723
+
724
+ if final_dir.is_dir():
715
725
  # The destination already exists, no need to process it further
716
- return destination_dir
726
+ return final_dir
717
727
 
718
- with _YieldRestoredArchive(
728
+ with _YieldTransferredArchive(
719
729
  data_store, # type: ignore
720
730
  directory,
731
+ this_working_dir / "transferred",
721
732
  lambda bytes_transferred: cast(
722
733
  None,
723
734
  status.OnProgress(
@@ -725,10 +736,11 @@ def Restore(
725
736
  bytes_transferred,
726
737
  ),
727
738
  ),
728
- ) as (archive_directory, archive_directory_is_temporary):
729
- with _YieldRestoredFiles(
739
+ ) as (transferred_dir, transferred_dir_is_temporary):
740
+ with _YieldDecompressedFiles(
741
+ transferred_dir,
742
+ this_working_dir / "decompressed",
730
743
  directory,
731
- archive_directory,
732
744
  encryption_password,
733
745
  lambda message: cast(
734
746
  None,
@@ -737,11 +749,11 @@ def Restore(
737
749
  message,
738
750
  ),
739
751
  ),
740
- ) as (contents_dir, contents_dir_is_temporary):
752
+ ) as (decompressed_dir, decompressed_dir_is_temporary):
741
753
  # Validate the contents
742
- _VerifyRestoredFiles(
754
+ _VerifyFiles(
743
755
  directory,
744
- contents_dir,
756
+ decompressed_dir,
745
757
  lambda message: cast(
746
758
  None,
747
759
  status.OnProgress(
@@ -755,7 +767,7 @@ def Restore(
755
767
  # directory structure and doesn't do anything to account for
756
768
  # nested dirs. This assumption matches the current archive
757
769
  # format.
758
- if archive_directory_is_temporary or contents_dir_is_temporary:
770
+ if transferred_dir_is_temporary or decompressed_dir_is_temporary:
759
771
  func = cast(Callable[[Path, Path], None], shutil.move)
760
772
  else:
761
773
  # ----------------------------------------------------------------------
@@ -770,14 +782,14 @@ def Restore(
770
782
 
771
783
  func = CreateSymLink
772
784
 
773
- temp_dest_dir = destination_dir.parent / (destination_dir.name + "__temp__")
785
+ temp_dest_dir = final_dir.parent / (final_dir.name + "__temp__")
774
786
 
775
787
  shutil.rmtree(temp_dest_dir, ignore_errors=True)
776
788
  temp_dest_dir.mkdir(parents=True)
777
789
 
778
790
  items = [
779
791
  item
780
- for item in contents_dir.iterdir()
792
+ for item in decompressed_dir.iterdir()
781
793
  if item.name != INDEX_HASH_FILENAME
782
794
  ]
783
795
 
@@ -789,9 +801,9 @@ def Restore(
789
801
 
790
802
  func(item, temp_dest_dir)
791
803
 
792
- shutil.move(temp_dest_dir, destination_dir)
804
+ shutil.move(temp_dest_dir, final_dir)
793
805
 
794
- return destination_dir
806
+ return final_dir
795
807
 
796
808
  # ----------------------------------------------------------------------
797
809
 
@@ -864,9 +876,9 @@ def Restore(
864
876
  directory_working_dir
865
877
  )
866
878
 
867
- dest_filename.parent.mkdir(parents=True, exist_ok=True)
868
-
869
- os.symlink(fullpath, dest_filename)
879
+ if not dest_filename.is_file():
880
+ dest_filename.parent.mkdir(parents=True, exist_ok=True)
881
+ os.symlink(fullpath, dest_filename)
870
882
 
871
883
  # Read the instructions
872
884
  with (directory_working_dir / INDEX_FILENAME).open() as f:
@@ -960,7 +972,8 @@ def Restore(
960
972
  with dm.Nested("\nProcessing instructions...") as all_instructions_dm:
961
973
  all_instructions_dm.WriteLine("")
962
974
 
963
- temp_directory = PathEx.CreateTempDirectory()
975
+ temp_directory = working_dir / "instructions"
976
+ temp_directory.mkdir(parents=True, exist_ok=True)
964
977
 
965
978
  with ExitStack(lambda: shutil.rmtree(temp_directory)):
966
979
  commit_actions: list[Callable[[], None]] = []
@@ -1287,9 +1300,9 @@ def _CommitFileBasedDataStore(
1287
1300
  # ----------------------------------------------------------------------
1288
1301
  @contextmanager
1289
1302
  def _YieldTempDirectory(
1303
+ temp_directory: Path,
1290
1304
  desc: str,
1291
1305
  ) -> Iterator[Path]:
1292
- temp_directory = PathEx.CreateTempDirectory()
1293
1306
  should_delete = True
1294
1307
 
1295
1308
  try:
@@ -1299,7 +1312,8 @@ def _YieldTempDirectory(
1299
1312
  raise
1300
1313
  finally:
1301
1314
  if should_delete:
1302
- shutil.rmtree(temp_directory)
1315
+ if temp_directory.is_dir():
1316
+ shutil.rmtree(temp_directory)
1303
1317
  else:
1304
1318
  sys.stderr.write(
1305
1319
  f"**** The temporary directory '{temp_directory}' was preserved due to errors while {desc}.\n",
@@ -1308,9 +1322,10 @@ def _YieldTempDirectory(
1308
1322
 
1309
1323
  # ----------------------------------------------------------------------
1310
1324
  @contextmanager
1311
- def _YieldRestoredArchive(
1325
+ def _YieldTransferredArchive(
1312
1326
  data_store: FileBasedDataStore,
1313
1327
  directory: str,
1328
+ working_dir: Path,
1314
1329
  status_func: Callable[[str], None],
1315
1330
  ) -> Iterator[
1316
1331
  tuple[
@@ -1318,6 +1333,8 @@ def _YieldRestoredArchive(
1318
1333
  bool, # is temporary directory
1319
1334
  ],
1320
1335
  ]:
1336
+ """Transfer content from the data store to the local filesystem."""
1337
+
1321
1338
  if data_store.is_local_filesystem:
1322
1339
  working_dir = data_store.GetWorkingDir() / directory
1323
1340
  assert working_dir.is_dir(), working_dir
@@ -1327,7 +1344,7 @@ def _YieldRestoredArchive(
1327
1344
 
1328
1345
  status_func("Calculating files to transfer...")
1329
1346
 
1330
- with _YieldTempDirectory("transferring archive files") as temp_directory:
1347
+ with _YieldTempDirectory(working_dir, "transferring archive files") as working_dir:
1331
1348
  # Map the remote filenames to local filenames
1332
1349
  filename_map: dict[Path, Path] = {}
1333
1350
 
@@ -1339,7 +1356,7 @@ def _YieldRestoredArchive(
1339
1356
  relative_root = root.relative_to(data_store_dir)
1340
1357
 
1341
1358
  for filename in filenames:
1342
- filename_map[root / filename] = temp_directory / relative_root / filename
1359
+ filename_map[root / filename] = working_dir / relative_root / filename
1343
1360
 
1344
1361
  if not filename_map:
1345
1362
  raise Exception(f"The directory '{directory}' does not contain any files.")
@@ -1359,14 +1376,15 @@ def _YieldRestoredArchive(
1359
1376
  ),
1360
1377
  )
1361
1378
 
1362
- yield temp_directory, True
1379
+ yield working_dir, True
1363
1380
 
1364
1381
 
1365
1382
  # ----------------------------------------------------------------------
1366
1383
  @contextmanager
1367
- def _YieldRestoredFiles(
1384
+ def _YieldDecompressedFiles(
1385
+ transferred_dir: Path,
1386
+ decompressed_dir: Path,
1368
1387
  directory_name: str,
1369
- archive_dir: Path,
1370
1388
  encryption_password: str | None,
1371
1389
  status_func: Callable[[str], None],
1372
1390
  ) -> Iterator[
@@ -1375,8 +1393,10 @@ def _YieldRestoredFiles(
1375
1393
  bool, # is temporary directory
1376
1394
  ],
1377
1395
  ]:
1378
- if (archive_dir / INDEX_FILENAME).is_file():
1379
- yield archive_dir, False
1396
+ """Decompress the archive if necessary"""
1397
+
1398
+ if (transferred_dir / INDEX_FILENAME).is_file():
1399
+ yield transferred_dir, False
1380
1400
  return
1381
1401
 
1382
1402
  # By default, 7zip will prompt for a password with archives that were created
@@ -1394,7 +1414,7 @@ def _YieldRestoredFiles(
1394
1414
  # Validate
1395
1415
  status_func("Validating archive...")
1396
1416
 
1397
- archive_filename = archive_dir / (ARCHIVE_FILENAME + ".001")
1417
+ archive_filename = transferred_dir / (ARCHIVE_FILENAME + ".001")
1398
1418
 
1399
1419
  if not archive_filename.is_file():
1400
1420
  raise Exception(f"The archive file '{archive_filename.name}' was not found.")
@@ -1424,10 +1444,12 @@ def _YieldRestoredFiles(
1424
1444
  # Extract
1425
1445
  status_func("Extracting archive...")
1426
1446
 
1427
- with _YieldTempDirectory("extracting the archive") as temp_directory:
1447
+ with _YieldTempDirectory(decompressed_dir, "extracting the archive") as decompressed_dir:
1448
+ decompressed_dir.mkdir(parents=True, exist_ok=True)
1449
+
1428
1450
  result = SubprocessEx.Run(
1429
1451
  f'{_GetZipBinary()} x "{archive_filename}" "-p{password}"',
1430
- cwd=temp_directory,
1452
+ cwd=decompressed_dir,
1431
1453
  )
1432
1454
 
1433
1455
  if result.returncode != 0:
@@ -1450,11 +1472,11 @@ def _YieldRestoredFiles(
1450
1472
  ),
1451
1473
  )
1452
1474
 
1453
- yield temp_directory, True
1475
+ yield decompressed_dir, True
1454
1476
 
1455
1477
 
1456
1478
  # ----------------------------------------------------------------------
1457
- def _VerifyRestoredFiles(
1479
+ def _VerifyFiles(
1458
1480
  directory_name: str,
1459
1481
  contents_dir: Path,
1460
1482
  status_func: Callable[[str], None],
@@ -9,4 +9,4 @@
9
9
  # Note that this value will be overwritten by calls to `python ../../Build.py update_version` based
10
10
  # on changes observed in the git repository. The default value below will be used until the value
11
11
  # here is explicitly updated by the Continuous Integration system as part of a commit.
12
- __version__ = "0.4.2"
12
+ __version__ = "0.4.4"
@@ -1111,6 +1111,49 @@ class TestRestore(object):
1111
1111
  sep=os.path.sep,
1112
1112
  )
1113
1113
 
1114
+ # ----------------------------------------------------------------------
1115
+ @pytest.mark.parametrize("is_local_filesystem", [True, False])
1116
+ @pytest.mark.parametrize("encryption_password", [None, str(uuid.uuid4())])
1117
+ @pytest.mark.parametrize("compress", [False, True])
1118
+ def test_RestoreMultipleBackupsSameFile(
1119
+ self, _working_dir, tmp_path_factory, compress, encryption_password, is_local_filesystem
1120
+ ):
1121
+ with _YieldInitializedBackupHelper(
1122
+ tmp_path_factory, _working_dir, compress, encryption_password
1123
+ ) as backup_helper:
1124
+ restore_helper = _RestoreHelper.Create(
1125
+ _working_dir,
1126
+ tmp_path_factory,
1127
+ encryption_password,
1128
+ is_local_filesystem,
1129
+ backup_helper.backup_name,
1130
+ backup_helper.output_dir,
1131
+ )
1132
+
1133
+ # Read the contents of a file. We will remove this file, create a backup, and then
1134
+ # restore the same file.
1135
+ file_under_test = _working_dir / "one" / "A"
1136
+ assert file_under_test.is_file(), file_under_test
1137
+
1138
+ file_under_test_contents = file_under_test.read_text(encoding="utf-8")
1139
+
1140
+ backup_file_under_test = restore_helper.backup_dir / "one" / "A"
1141
+
1142
+ # Remove the file
1143
+ file_under_test.unlink()
1144
+
1145
+ backup_helper.ExecuteBackup(_working_dir, compress, encryption_password)
1146
+
1147
+ # Write the file
1148
+ file_under_test.write_text(file_under_test_contents, encoding="utf-8")
1149
+
1150
+ backup_helper.ExecuteBackup(_working_dir, compress, encryption_password)
1151
+
1152
+ # Restore the backup
1153
+ restore_helper.ExecuteRestore(10)
1154
+
1155
+ assert Path(restore_helper.output_dir / "one" / "A").is_file()
1156
+
1114
1157
 
1115
1158
  # ----------------------------------------------------------------------
1116
1159
  class TestRestoreErrors:
@@ -1487,7 +1530,7 @@ class _RestoreHelper(object):
1487
1530
 
1488
1531
  output = cast(str, next(dm_and_content))
1489
1532
 
1490
- assert dm.result == expected_result
1533
+ assert dm.result == expected_result, output
1491
1534
 
1492
1535
  if expected_num_files is not None:
1493
1536
  TestHelpers.CompareFileSystemSourceAndDestination(
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes