PyHardLinkBackup 1.8.3__tar.gz → 1.9.0__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 (64) hide show
  1. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/.pre-commit-config.yaml +1 -1
  2. pyhardlinkbackup-1.9.0/.venv-app/lib/python3.14/site-packages/cli_base/tests/shell_complete_snapshots/.gitignore +1 -0
  3. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/PKG-INFO +12 -5
  4. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/PyHardLinkBackup/__init__.py +1 -1
  5. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/PyHardLinkBackup/backup.py +3 -2
  6. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/PyHardLinkBackup/cli_app/__init__.py +3 -2
  7. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/PyHardLinkBackup/tests/test_backup.py +25 -19
  8. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/PyHardLinkBackup/tests/test_project_setup.py +1 -1
  9. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/PyHardLinkBackup/tests/test_readme.py +0 -6
  10. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/PyHardLinkBackup/utilities/file_hash_database.py +3 -10
  11. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/PyHardLinkBackup/utilities/tests/test_file_hash_database.py +27 -7
  12. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/README.md +10 -3
  13. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/docs/README.md +9 -4
  14. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/pyproject.toml +6 -3
  15. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/uv.lock +262 -252
  16. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/.editorconfig +0 -0
  17. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/.github/workflows/tests.yml +0 -0
  18. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/.gitignore +0 -0
  19. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/.idea/.gitignore +0 -0
  20. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/.pre-commit-hooks.yaml +0 -0
  21. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/.run/Template Python tests.run.xml +0 -0
  22. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/.run/Unittests - __all__.run.xml +0 -0
  23. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/.run/cli.py --help.run.xml +0 -0
  24. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/.run/dev-cli update.run.xml +0 -0
  25. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/.run/only DocTests.run.xml +0 -0
  26. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/.run/only DocWrite.run.xml +0 -0
  27. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/.venv-app/.gitignore +0 -0
  28. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/LICENSE +0 -0
  29. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/PyHardLinkBackup/__main__.py +0 -0
  30. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/PyHardLinkBackup/cli_app/phlb.py +0 -0
  31. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/PyHardLinkBackup/cli_dev/__init__.py +0 -0
  32. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/PyHardLinkBackup/cli_dev/__main__.py +0 -0
  33. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/PyHardLinkBackup/cli_dev/benchmark.py +0 -0
  34. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/PyHardLinkBackup/cli_dev/code_style.py +0 -0
  35. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/PyHardLinkBackup/cli_dev/debugging.py +0 -0
  36. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/PyHardLinkBackup/cli_dev/packaging.py +0 -0
  37. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/PyHardLinkBackup/cli_dev/testing.py +0 -0
  38. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/PyHardLinkBackup/cli_dev/update_readme_history.py +0 -0
  39. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/PyHardLinkBackup/compare_backup.py +0 -0
  40. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/PyHardLinkBackup/constants.py +0 -0
  41. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/PyHardLinkBackup/logging_setup.py +0 -0
  42. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/PyHardLinkBackup/rebuild_databases.py +0 -0
  43. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/PyHardLinkBackup/tests/__init__.py +0 -0
  44. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/PyHardLinkBackup/tests/test_compare_backup.py +0 -0
  45. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/PyHardLinkBackup/tests/test_doc_write.py +0 -0
  46. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/PyHardLinkBackup/tests/test_doctests.py +0 -0
  47. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/PyHardLinkBackup/tests/test_readme_history.py +0 -0
  48. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/PyHardLinkBackup/tests/test_rebuild_database.py +0 -0
  49. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/PyHardLinkBackup/utilities/__init__.py +0 -0
  50. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/PyHardLinkBackup/utilities/file_size_database.py +0 -0
  51. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/PyHardLinkBackup/utilities/filesystem.py +0 -0
  52. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/PyHardLinkBackup/utilities/humanize.py +0 -0
  53. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/PyHardLinkBackup/utilities/rich_utils.py +0 -0
  54. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/PyHardLinkBackup/utilities/sha256sums.py +0 -0
  55. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/PyHardLinkBackup/utilities/tee.py +0 -0
  56. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/PyHardLinkBackup/utilities/tests/__init__.py +0 -0
  57. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/PyHardLinkBackup/utilities/tests/test_file_size_database.py +0 -0
  58. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/PyHardLinkBackup/utilities/tests/test_filesystem.py +0 -0
  59. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/PyHardLinkBackup/utilities/tests/unittest_utilities.py +0 -0
  60. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/PyHardLinkBackup/utilities/tyro_cli_shared_args.py +0 -0
  61. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/cli.py +0 -0
  62. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/dev-cli.py +0 -0
  63. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/dist/.gitignore +0 -0
  64. {pyhardlinkbackup-1.8.3 → pyhardlinkbackup-1.9.0}/noxfile.py +0 -0
@@ -2,6 +2,6 @@
2
2
  # See https://pre-commit.com for more information
3
3
  repos:
4
4
  - repo: https://github.com/jedie/cli-base-utilities
5
- rev: v0.29.0
5
+ rev: v0.30.0
6
6
  hooks:
7
7
  - id: update-readme-history
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyHardLinkBackup
3
- Version: 1.8.3
3
+ Version: 1.9.0
4
4
  Summary: HardLink/Deduplication Backups with Python
5
5
  Project-URL: Documentation, https://github.com/jedie/PyHardLinkBackup
6
6
  Project-URL: Source, https://github.com/jedie/PyHardLinkBackup
@@ -9,7 +9,7 @@ License: GPL-3.0-or-later
9
9
  License-File: LICENSE
10
10
  Requires-Python: >=3.12
11
11
  Requires-Dist: bx-py-utils
12
- Requires-Dist: cli-base-utilities>=0.27.1
12
+ Requires-Dist: cli-base-utilities
13
13
  Requires-Dist: rich
14
14
  Requires-Dist: tyro
15
15
  Description-Content-Type: text/markdown
@@ -298,6 +298,13 @@ Overview of main changes:
298
298
 
299
299
  [comment]: <> (✂✂✂ auto generated history start ✂✂✂)
300
300
 
301
+ * [v1.9.0](https://github.com/jedie/PyHardLinkBackup/compare/v1.8.4...v1.9.0)
302
+ * 2026-04-14 - Update existing links in has database
303
+ * 2026-04-14 - Update requirements
304
+ * [v1.8.4](https://github.com/jedie/PyHardLinkBackup/compare/v1.8.3...v1.8.4)
305
+ * 2026-04-09 - Update requirements
306
+ * 2026-04-09 - Apply project updates
307
+ * 2026-04-08 - Bump cryptography from 46.0.6 to 46.0.7
301
308
  * [v1.8.3](https://github.com/jedie/PyHardLinkBackup/compare/v1.8.2...v1.8.3)
302
309
  * 2026-04-01 - Update requirements
303
310
  * 2026-03-30 - Bump pygments from 2.19.2 to 2.20.0
@@ -307,6 +314,9 @@ Overview of main changes:
307
314
  * 2026-03-28 - Update requirements
308
315
  * 2026-03-28 - apply manageprojects updates
309
316
  * 2026-03-25 - fix some code styles
317
+
318
+ <details><summary>Expand older history entries ...</summary>
319
+
310
320
  * [v1.8.1](https://github.com/jedie/PyHardLinkBackup/compare/v1.8.0...v1.8.1)
311
321
  * 2026-01-24 - Update packaging commands related to new direct "uv" usage
312
322
  * 2026-01-24 - Bugfix "rebuild" command
@@ -321,9 +331,6 @@ Overview of main changes:
321
331
  * 2026-01-22 - Do not cross filesystem boundaries as default
322
332
  * 2026-01-22 - Display progress also for large unique file copy
323
333
  * 2026-01-22 - Optimize progress bars for smaller screens
324
-
325
- <details><summary>Expand older history entries ...</summary>
326
-
327
334
  * [v1.7.3](https://github.com/jedie/PyHardLinkBackup/compare/v1.7.2...v1.7.3)
328
335
  * 2026-01-21 - Handle directory symlinks correct
329
336
  * [v1.7.2](https://github.com/jedie/PyHardLinkBackup/compare/v1.7.1...v1.7.2)
@@ -3,5 +3,5 @@
3
3
  """
4
4
 
5
5
  # See https://packaging.python.org/en/latest/specifications/version-specifiers/
6
- __version__ = '1.8.3'
6
+ __version__ = '1.9.0'
7
7
  __author__ = 'Jens Diemer <PyHardLinkBackup@jensdiemer.de>'
@@ -130,7 +130,6 @@ def backup_one_file(
130
130
  else:
131
131
  logger.info('Store unique file: %s to %s', src_path, dst_path)
132
132
  dst_path.write_bytes(file_content)
133
- hash_db[file_hash] = dst_path
134
133
  backup_result.copied_files += 1
135
134
  backup_result.copied_size += size
136
135
 
@@ -146,10 +145,12 @@ def backup_one_file(
146
145
  else:
147
146
  logger.info('Copy unique file: %s to %s', src_path, dst_path)
148
147
  copy_with_progress(src_path, dst_path, progress=progress, total_size=size)
149
- hash_db[file_hash] = dst_path
150
148
  backup_result.copied_files += 1
151
149
  backup_result.copied_size += size
152
150
 
151
+ # Store new file in hash database or update existing entry to latest backuped file:
152
+ hash_db[file_hash] = dst_path
153
+
153
154
  # Keep original file metadata (permission bits, time stamps, and flags)
154
155
  shutil.copystat(src_path, dst_path)
155
156
  else:
@@ -31,9 +31,10 @@ def version():
31
31
 
32
32
 
33
33
  def main(args: Sequence[str] | None = None):
34
- print_version(PyHardLinkBackup)
34
+ project_name = 'phlb' # Enforce program name if pipx used
35
+ print_version(PyHardLinkBackup, project_name=project_name)
35
36
  app.cli(
36
- prog='phlb', # Enforce program name if pipx used
37
+ prog=project_name,
37
38
  description=constants.CLI_EPILOG,
38
39
  use_underscores=False, # use hyphens instead of underscores
39
40
  sort_subcommands=True,
@@ -274,6 +274,7 @@ class BackupTreeTestCase(
274
274
  'wb backups/source/2026-01-01-123456/min_sized_file1.bin',
275
275
  'w backups/.phlb/hash-lookup/bb/c4/bbc4de2ca238d1ec41fb622b75b5cf7d31a6d2ac92405043dd8f8220364fefc8',
276
276
  'a backups/source/2026-01-01-123456/SHA256SUMS',
277
+ 'w backups/.phlb/hash-lookup/bb/c4/bbc4de2ca238d1ec41fb622b75b5cf7d31a6d2ac92405043dd8f8220364fefc8',
277
278
  'a backups/source/2026-01-01-123456/SHA256SUMS',
278
279
  'w backups/source/2026-01-01-123456-summary.txt',
279
280
  ],
@@ -320,7 +321,7 @@ class BackupTreeTestCase(
320
321
  assert_hash_db_info(
321
322
  backup_root=self.backup_root,
322
323
  expected="""
323
- bb/c4/bbc4de2ca238d1… -> source/2026-01-01-123456/min_sized_file1.bin
324
+ bb/c4/bbc4de2ca238d1… -> source/2026-01-01-123456/min_sized_file2.bin
324
325
  e3/71/e3711d0eacddeb… -> source/2026-01-01-123456/large_file1.bin
325
326
  """,
326
327
  )
@@ -416,15 +417,15 @@ class BackupTreeTestCase(
416
417
  redirected_out.stdout,
417
418
  )
418
419
 
419
- # The FileHashDatabase remains the same:
420
+ # The FileHashDatabase always points to the latest backed-up files:
420
421
  with self.assertLogs('PyHardLinkBackup', level=logging.DEBUG):
421
422
  assert_hash_db_info(
422
423
  backup_root=self.backup_root,
423
424
  expected="""
424
425
  23/d2/23d2ce40d26211… -> source/2026-01-02-123456/min_sized_file_newA.bin
425
426
  9a/56/9a567077114134… -> source/2026-01-02-123456/min_sized_file_newB.bin
426
- bb/c4/bbc4de2ca238d1… -> source/2026-01-01-123456/min_sized_file1.bin
427
- e3/71/e3711d0eacddeb… -> source/2026-01-01-123456/large_file1.bin
427
+ bb/c4/bbc4de2ca238d1… -> source/2026-01-02-123456/min_sized_file2.bin
428
+ e3/71/e3711d0eacddeb… -> source/2026-01-02-123456/large_file2.bin
428
429
  """,
429
430
  )
430
431
 
@@ -463,9 +464,13 @@ class BackupTreeTestCase(
463
464
  'a backups/source/2026-01-02-123456/SHA256SUMS',
464
465
  'wb backups/source/2026-01-02-123456/hardlink2file1',
465
466
  'a backups/source/2026-01-02-123456/SHA256SUMS',
467
+ 'w backups/.phlb/hash-lookup/e3/71/e3711d0eacddeb105af4ad9b0d63069d759acf32e49712663419e68dc294a94a',
466
468
  'a backups/source/2026-01-02-123456/SHA256SUMS',
469
+ 'w backups/.phlb/hash-lookup/e3/71/e3711d0eacddeb105af4ad9b0d63069d759acf32e49712663419e68dc294a94a',
467
470
  'a backups/source/2026-01-02-123456/SHA256SUMS',
471
+ 'w backups/.phlb/hash-lookup/bb/c4/bbc4de2ca238d1ec41fb622b75b5cf7d31a6d2ac92405043dd8f8220364fefc8',
468
472
  'a backups/source/2026-01-02-123456/SHA256SUMS',
473
+ 'w backups/.phlb/hash-lookup/bb/c4/bbc4de2ca238d1ec41fb622b75b5cf7d31a6d2ac92405043dd8f8220364fefc8',
469
474
  'a backups/source/2026-01-02-123456/SHA256SUMS',
470
475
  'wb backups/source/2026-01-02-123456/min_sized_file_newA.bin',
471
476
  'w backups/.phlb/hash-lookup/23/d2/23d2ce40d26211a9ffe8096fd1f927f2abd094691839d24f88440f7c5168d500',
@@ -499,8 +504,8 @@ class BackupTreeTestCase(
499
504
  # Don't create broken hardlinks!
500
505
 
501
506
  """DocWrite: README.md ## FileHashDatabase - Missing hardlink target file
502
- If a hardlink source from a old backup is missing, we cannot create a hardlink to it.
503
- But it still works to hardlink same files within the current backup.
507
+ Deleting files from old backups is safe: the hash DB entry always points to the
508
+ most recently backed-up file, so subsequent backups can still create hardlinks.
504
509
  """
505
510
 
506
511
  # Let's remove one of the files used for hardlinking from the first backup:
@@ -515,8 +520,8 @@ class BackupTreeTestCase(
515
520
  self.assertIn('Backup complete', redirected_out.stdout)
516
521
  backup_dir = result.backup_dir
517
522
 
518
- # Note: min_sized_file1.bin and min_sized_file2.bin are hardlinked,
519
- # but not with the first backup anymore! So it's only nlink=2 now!
523
+ # Note: min_sized_file1.bin and min_sized_file2.bin accumulate hardlinks
524
+ # because hash_db always points to the latest backup file.
520
525
  with self.assertLogs('PyHardLinkBackup', level=logging.DEBUG):
521
526
  assert_fs_tree_overview(
522
527
  root=backup_dir,
@@ -527,8 +532,8 @@ class BackupTreeTestCase(
527
532
  hardlink2file1 12:00:00 file 1 14 8a11514a
528
533
  large_file1.bin 12:00:00 hardlink 5 1001 fb3014ff
529
534
  large_file2.bin 12:00:00 hardlink 5 1001 fb3014ff
530
- min_sized_file1.bin 12:00:00 hardlink 2 1000 f0d93de4
531
- min_sized_file2.bin 12:00:00 hardlink 2 1000 f0d93de4
535
+ min_sized_file1.bin 12:00:00 hardlink 5 1000 f0d93de4
536
+ min_sized_file2.bin 12:00:00 hardlink 5 1000 f0d93de4
532
537
  min_sized_file_newA.bin 12:00:00 hardlink 2 1001 a48f0e33
533
538
  min_sized_file_newB.bin 12:00:00 hardlink 2 1000 7d9c564d
534
539
  small_file_newA.txt 12:00:00 file 1 10 76d1acf1
@@ -547,26 +552,26 @@ class BackupTreeTestCase(
547
552
  backup_count=12,
548
553
  backup_size=6091,
549
554
  symlink_files=1,
550
- hardlinked_files=5,
551
- hardlinked_size=5003,
552
- copied_files=6,
553
- copied_size=1074,
555
+ hardlinked_files=6,
556
+ hardlinked_size=6003,
557
+ copied_files=5,
558
+ copied_size=74,
554
559
  copied_small_files=5,
555
560
  copied_small_size=74,
556
561
  error_count=0,
557
562
  ),
558
563
  )
559
564
 
560
- # Note: min_sized_file1.bin is now from the 2026-01-03 backup!
565
+ # All files points now to "2026-01-03" and non of them to the first "2026-01-01" backup:
561
566
  self.assertEqual(backup_dir.name, '2026-01-03-123456') # Latest backup dir name
562
567
  with self.assertLogs('PyHardLinkBackup', level=logging.DEBUG):
563
568
  assert_hash_db_info(
564
569
  backup_root=self.backup_root,
565
570
  expected="""
566
- 23/d2/23d2ce40d26211… -> source/2026-01-02-123456/min_sized_file_newA.bin
567
- 9a/56/9a567077114134… -> source/2026-01-02-123456/min_sized_file_newB.bin
568
- bb/c4/bbc4de2ca238d1… -> source/2026-01-03-123456/min_sized_file1.bin
569
- e3/71/e3711d0eacddeb… -> source/2026-01-01-123456/large_file1.bin
571
+ 23/d2/23d2ce40d26211… -> source/2026-01-03-123456/min_sized_file_newA.bin
572
+ 9a/56/9a567077114134… -> source/2026-01-03-123456/min_sized_file_newB.bin
573
+ bb/c4/bbc4de2ca238d1… -> source/2026-01-03-123456/min_sized_file2.bin
574
+ e3/71/e3711d0eacddeb… -> source/2026-01-03-123456/large_file2.bin
570
575
  """,
571
576
  )
572
577
 
@@ -916,6 +921,7 @@ class BackupTreeTestCase(
916
921
  [
917
922
  'w backups/.phlb_test',
918
923
  'a backups/source/2026-02-22-123456-backup.log',
924
+ 'w backups/.phlb/hash-lookup/23/d2/23d2ce40d26211a9ffe8096fd1f927f2abd094691839d24f88440f7c5168d500',
919
925
  'a backups/source/2026-02-22-123456/SHA256SUMS',
920
926
  'wb backups/source/2026-02-22-123456/large_fileB.txt',
921
927
  'w backups/.phlb/hash-lookup/2a/92/2a925556d3ec9e4258624a324cd9300a9a3d9c86dac6bbbb63071bdb7787afd2',
@@ -22,7 +22,7 @@ class ProjectSetupTestCase(TestCase):
22
22
  assert_is_file(cli_bin)
23
23
 
24
24
  output = subprocess.check_output([cli_bin, 'version'], text=True)
25
- self.assertIn(f'PyHardLinkBackup v{__version__}', output)
25
+ self.assertIn(f'phlb v{__version__}', output)
26
26
 
27
27
  dev_cli_bin = PACKAGE_ROOT / 'dev-cli.py'
28
28
  assert_is_file(dev_cli_bin)
@@ -47,9 +47,6 @@ class ReadmeTestCase(BaseTestCase):
47
47
  ),
48
48
  )
49
49
 
50
- # Installed via pipx is called 'phlb', not 'cli.py':
51
- stdout = stdout.replace('./cli.py', 'phlb')
52
-
53
50
  assert_cli_help_in_readme(text_block=stdout, marker='main help')
54
51
 
55
52
  def test_backup_help(self):
@@ -63,9 +60,6 @@ class ReadmeTestCase(BaseTestCase):
63
60
  ),
64
61
  )
65
62
 
66
- # Installed via pipx is called 'phlb', not 'cli.py':
67
- stdout = stdout.replace('./cli.py', 'phlb')
68
-
69
63
  assert_cli_help_in_readme(text_block=stdout, marker='backup help')
70
64
 
71
65
  def test_dev_help(self):
@@ -5,10 +5,6 @@ from pathlib import Path
5
5
  logger = logging.getLogger(__name__)
6
6
 
7
7
 
8
- class HashAlreadyExistsError(ValueError):
9
- pass
10
-
11
-
12
8
  class FileHashDatabase:
13
9
  """DocWrite: README.md ## FileHashDatabase
14
10
  A simple "database" to store file content hash <-> relative path mappings.
@@ -54,12 +50,9 @@ class FileHashDatabase:
54
50
  return abs_file_path
55
51
 
56
52
  def __setitem__(self, hash: str, abs_file_path: Path):
53
+ """
54
+ Create or update the hash entry with the given absolute file path.
55
+ """
57
56
  hash_path = self._get_hash_path(hash)
58
57
  hash_path.parent.mkdir(parents=True, exist_ok=True)
59
-
60
- # File should be found before and results in hardlink creation!
61
- # So deny change of existing hashes:
62
- if hash_path.exists():
63
- raise HashAlreadyExistsError(f'Hash {hash} already exists in the database!')
64
-
65
58
  hash_path.write_text(str(abs_file_path.relative_to(self.backup_root)))
@@ -8,7 +8,7 @@ from bx_py_utils.test_utils.assertion import assert_text_equal
8
8
  from bx_py_utils.test_utils.log_utils import NoLogs
9
9
  from cli_base.cli_tools.test_utils.base_testcases import BaseTestCase
10
10
 
11
- from PyHardLinkBackup.utilities.file_hash_database import FileHashDatabase, HashAlreadyExistsError
11
+ from PyHardLinkBackup.utilities.file_hash_database import FileHashDatabase
12
12
  from PyHardLinkBackup.utilities.filesystem import iter_scandir_files
13
13
 
14
14
 
@@ -127,10 +127,28 @@ class FileHashDatabaseTestCase(BaseTestCase):
127
127
  )
128
128
 
129
129
  ########################################################################################
130
- # Deny "overwrite" of existing hash:
130
+ # Update existing hash to point to a newer file:
131
131
 
132
- with self.assertRaises(HashAlreadyExistsError):
133
- hash_db['12abcd345678abcdef'] = 'foo/bar/baz' # already exists!
132
+ """DocWrite: README.md ## FileHashDatabase
133
+ The entry for each hash is always updated to point to the most recently backed-up file.
134
+ This means you can safely delete old backups: the hash DB will still point to a valid
135
+ file in the most recent backup, so deduplication continues to work correctly.
136
+ """
137
+
138
+ file_c_path = backup_root_path / 'rel/path/to/file-C'
139
+ file_c_path.parent.mkdir(parents=True, exist_ok=True)
140
+ file_c_path.touch()
141
+
142
+ hash_db['12abcd345678abcdef'] = file_c_path
143
+ self.assertEqual(hash_db.get('12abcd345678abcdef'), file_c_path)
144
+ with self.assertLogs('PyHardLinkBackup', level=logging.DEBUG):
145
+ assert_hash_db_info(
146
+ backup_root=hash_db.backup_root,
147
+ expected="""
148
+ 12/34/12345678abcdef… -> rel/path/to/file-A
149
+ 12/ab/12abcd345678ab… -> rel/path/to/file-C
150
+ """,
151
+ )
134
152
 
135
153
  ########################################################################################
136
154
  # Don't use stale entries pointing to missing files:
@@ -139,8 +157,10 @@ class FileHashDatabaseTestCase(BaseTestCase):
139
157
  file_a_path.unlink()
140
158
 
141
159
  """DocWrite: README.md ## FileHashDatabase - Missing hardlink target file
142
- We check if the hardlink source file still exists. If not, we remove the hash entry from the database.
143
- A warning is logged in this case."""
160
+ The `get()` method checks whether the referenced file still exists.
161
+ If not, the stale entry is removed and a warning is logged.
162
+ On the next backup run, the file is then copied fresh instead of hardlinked.
163
+ """
144
164
  with self.assertLogs(level=logging.WARNING) as logs:
145
165
  self.assertIs(hash_db.get('12345678abcdef'), None)
146
166
  self.assertIn('Hash database entry found, but file does not exist', ''.join(logs.output))
@@ -148,6 +168,6 @@ class FileHashDatabaseTestCase(BaseTestCase):
148
168
  assert_hash_db_info(
149
169
  backup_root=hash_db.backup_root,
150
170
  expected="""
151
- 12/ab/12abcd345678ab… -> rel/path/to/file-B
171
+ 12/ab/12abcd345678ab… -> rel/path/to/file-C
152
172
  """,
153
173
  )
@@ -282,6 +282,13 @@ Overview of main changes:
282
282
 
283
283
  [comment]: <> (✂✂✂ auto generated history start ✂✂✂)
284
284
 
285
+ * [v1.9.0](https://github.com/jedie/PyHardLinkBackup/compare/v1.8.4...v1.9.0)
286
+ * 2026-04-14 - Update existing links in has database
287
+ * 2026-04-14 - Update requirements
288
+ * [v1.8.4](https://github.com/jedie/PyHardLinkBackup/compare/v1.8.3...v1.8.4)
289
+ * 2026-04-09 - Update requirements
290
+ * 2026-04-09 - Apply project updates
291
+ * 2026-04-08 - Bump cryptography from 46.0.6 to 46.0.7
285
292
  * [v1.8.3](https://github.com/jedie/PyHardLinkBackup/compare/v1.8.2...v1.8.3)
286
293
  * 2026-04-01 - Update requirements
287
294
  * 2026-03-30 - Bump pygments from 2.19.2 to 2.20.0
@@ -291,6 +298,9 @@ Overview of main changes:
291
298
  * 2026-03-28 - Update requirements
292
299
  * 2026-03-28 - apply manageprojects updates
293
300
  * 2026-03-25 - fix some code styles
301
+
302
+ <details><summary>Expand older history entries ...</summary>
303
+
294
304
  * [v1.8.1](https://github.com/jedie/PyHardLinkBackup/compare/v1.8.0...v1.8.1)
295
305
  * 2026-01-24 - Update packaging commands related to new direct "uv" usage
296
306
  * 2026-01-24 - Bugfix "rebuild" command
@@ -305,9 +315,6 @@ Overview of main changes:
305
315
  * 2026-01-22 - Do not cross filesystem boundaries as default
306
316
  * 2026-01-22 - Display progress also for large unique file copy
307
317
  * 2026-01-22 - Optimize progress bars for smaller screens
308
-
309
- <details><summary>Expand older history entries ...</summary>
310
-
311
318
  * [v1.7.3](https://github.com/jedie/PyHardLinkBackup/compare/v1.7.2...v1.7.3)
312
319
  * 2026-01-21 - Handle directory symlinks correct
313
320
  * [v1.7.2](https://github.com/jedie/PyHardLinkBackup/compare/v1.7.1...v1.7.2)
@@ -36,13 +36,18 @@ Notes:
36
36
  * The "relative path" that will be stored is not validated, so it can be any string.
37
37
  * We don't "cache" anything in Memory, to avoid high memory consumption for large datasets.
38
38
 
39
+ The entry for each hash is always updated to point to the most recently backed-up file.
40
+ This means you can safely delete old backups: the hash DB will still point to a valid
41
+ file in the most recent backup, so deduplication continues to work correctly.
42
+
39
43
  ## FileHashDatabase - Missing hardlink target file
40
44
 
41
- If a hardlink source from a old backup is missing, we cannot create a hardlink to it.
42
- But it still works to hardlink same files within the current backup.
45
+ Deleting files from old backups is safe: the hash DB entry always points to the
46
+ most recently backed-up file, so subsequent backups can still create hardlinks.
43
47
 
44
- We check if the hardlink source file still exists. If not, we remove the hash entry from the database.
45
- A warning is logged in this case.
48
+ The `get()` method checks whether the referenced file still exists.
49
+ If not, the stale entry is removed and a warning is logged.
50
+ On the next backup run, the file is then copied fresh instead of hardlinked.
46
51
 
47
52
  ## FileSizeDatabase
48
53
 
@@ -9,7 +9,7 @@ authors = [
9
9
  ]
10
10
  requires-python = ">=3.12"
11
11
  dependencies = [
12
- "cli-base-utilities>=0.27.1", # https://github.com/jedie/cli-base-utilities
12
+ "cli-base-utilities", # https://github.com/jedie/cli-base-utilities
13
13
  "bx_py_utils", # https://github.com/boxine/bx_py_utils
14
14
  "tyro", # https://github.com/brentyi/tyro
15
15
  "rich", # https://github.com/Textualize/rich
@@ -40,10 +40,12 @@ dev = [
40
40
  # Don't install bleed-edge versions of dependencies, to avoid undetected issues and supply chain attacks.
41
41
  exclude-newer = "1 week"
42
42
 
43
+ [tool.uv.exclude-newer-package]
43
44
  # Exclude own packages from the "exclude-newer" rule and
44
45
  # add external packages temporarily to fix known issues or current CVEs
45
- # TODO: Remove all fixed packages from this list:
46
- exclude-newer-package = { requests = "2026-03-25T18:00:00Z", cryptography="2026-03-27T00:00:00Z", pygments="2026-04-01T00:00:00Z" }
46
+ uv = "2026-04-13T12:00:00Z"
47
+ cli-base-utilities = "2026-04-13T12:00:00Z"
48
+ cryptography = "2026-04-08T12:00:00Z"
47
49
 
48
50
 
49
51
  [tool.cli_base.pip_audit]
@@ -150,6 +152,7 @@ cookiecutter_directory = "uv-python"
150
152
  applied_migrations = [
151
153
  "ae7c1ba", # 2025-12-16T19:28:11+01:00
152
154
  "69ee933", # 2026-03-28T10:01:57+01:00
155
+ "3cbc419", # 2026-04-09T17:37:01+02:00
153
156
  ]
154
157
 
155
158
  [manageprojects.cookiecutter_context.cookiecutter]