PyHardLinkBackup 1.0.1__tar.gz → 1.1.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.
- pyhardlinkbackup-1.0.1/README.md → pyhardlinkbackup-1.1.0/PKG-INFO +29 -3
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/PyHardLinkBackup/__init__.py +1 -1
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/PyHardLinkBackup/backup.py +1 -1
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/PyHardLinkBackup/tests/test_backup.py +134 -109
- pyhardlinkbackup-1.1.0/PyHardLinkBackup/utilities/tests/base_testcases.py +88 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/PyHardLinkBackup/utilities/tests/test_file_hash_database.py +40 -35
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/PyHardLinkBackup/utilities/tests/test_file_size_database.py +48 -41
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/PyHardLinkBackup/utilities/tests/test_filesystem.py +2 -2
- pyhardlinkbackup-1.0.1/PKG-INFO → pyhardlinkbackup-1.1.0/README.md +14 -18
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/.editorconfig +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/.github/workflows/tests.yml +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/.gitignore +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/.idea/.gitignore +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/.pre-commit-config.yaml +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/.pre-commit-hooks.yaml +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/.run/Template Python tests.run.xml +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/.run/Unittests - __all__.run.xml +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/.run/cli.py --help.run.xml +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/.run/dev-cli update.run.xml +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/.run/only DocTests.run.xml +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/.run/only DocWrite.run.xml +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/.venv-app/lib/python3.12/site-packages/cli_base/tests/shell_complete_snapshots/.gitignore +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/PyHardLinkBackup/__main__.py +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/PyHardLinkBackup/cli_app/__init__.py +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/PyHardLinkBackup/cli_app/phlb.py +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/PyHardLinkBackup/cli_dev/__init__.py +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/PyHardLinkBackup/cli_dev/benchmark.py +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/PyHardLinkBackup/cli_dev/code_style.py +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/PyHardLinkBackup/cli_dev/packaging.py +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/PyHardLinkBackup/cli_dev/shell_completion.py +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/PyHardLinkBackup/cli_dev/testing.py +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/PyHardLinkBackup/cli_dev/update_readme_history.py +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/PyHardLinkBackup/constants.py +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/PyHardLinkBackup/tests/__init__.py +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/PyHardLinkBackup/tests/test_doc_write.py +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/PyHardLinkBackup/tests/test_doctests.py +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/PyHardLinkBackup/tests/test_project_setup.py +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/PyHardLinkBackup/tests/test_readme.py +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/PyHardLinkBackup/tests/test_readme_history.py +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/PyHardLinkBackup/utilities/__init__.py +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/PyHardLinkBackup/utilities/file_hash_database.py +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/PyHardLinkBackup/utilities/file_size_database.py +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/PyHardLinkBackup/utilities/filesystem.py +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/PyHardLinkBackup/utilities/humanize.py +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/PyHardLinkBackup/utilities/rich_utils.py +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/PyHardLinkBackup/utilities/tests/__init__.py +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/cli.py +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/dev-cli.py +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/dist/.gitignore +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/docs/README.md +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/docs/about-docs.md +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/noxfile.py +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/pyproject.toml +0 -0
- {pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/uv.lock +0 -0
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: PyHardLinkBackup
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: HardLink/Deduplication Backups with Python
|
|
5
|
+
Project-URL: Documentation, https://github.com/jedie/PyHardLinkBackup
|
|
6
|
+
Project-URL: Source, https://github.com/jedie/PyHardLinkBackup
|
|
7
|
+
Author-email: Jens Diemer <PyHardLinkBackup@jensdiemer.de>
|
|
8
|
+
License: GPL-3.0-or-later
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Requires-Dist: bx-py-utils
|
|
11
|
+
Requires-Dist: cli-base-utilities
|
|
12
|
+
Requires-Dist: rich
|
|
13
|
+
Requires-Dist: tyro
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
1
16
|
# PyHardLinkBackup
|
|
2
17
|
|
|
3
18
|
[](https://github.com/jedie/PyHardLinkBackup/actions/workflows/tests.yml)
|
|
@@ -185,10 +200,21 @@ usage: ./dev-cli.py [-h] {benchmark-hashes,coverage,install,lint,mypy,nox,pip-au
|
|
|
185
200
|
|
|
186
201
|
v1 is a complete rewrite of PyHardLinkBackup.
|
|
187
202
|
|
|
203
|
+
Overview of main changes:
|
|
204
|
+
|
|
205
|
+
* Remove Django dependency:
|
|
206
|
+
* No SQlite database anymore -> Data for deduplication stored in filesystem only
|
|
207
|
+
* No Django Admin, because we have no database anymore ;)
|
|
208
|
+
* Change hash algorithm from SHA512 to SHA256, because it's faster and still secure enough
|
|
209
|
+
* Don't store `*.sha512` for every file anymore -> We store one `SHA256SUMS` file in every backup directory
|
|
210
|
+
|
|
188
211
|
## History
|
|
189
212
|
|
|
190
213
|
[comment]: <> (✂✂✂ auto generated history start ✂✂✂)
|
|
191
214
|
|
|
215
|
+
* [v1.1.0](https://github.com/jedie/PyHardLinkBackup/compare/v1.0.1...v1.1.0)
|
|
216
|
+
* 2026-01-14 - Change backup timestamp directory to old schema: '%Y-%m-%d-%H%M%S'
|
|
217
|
+
* 2026-01-14 - Add "Overview of main changes" to README
|
|
192
218
|
* [v1.0.1](https://github.com/jedie/PyHardLinkBackup/compare/v1.0.0...v1.0.1)
|
|
193
219
|
* 2026-01-13 - Store SHA256SUMS files in backup directories
|
|
194
220
|
* [v1.0.0](https://github.com/jedie/PyHardLinkBackup/compare/v0.13.0...v1.0.0)
|
|
@@ -219,6 +245,9 @@ v1 is a complete rewrite of PyHardLinkBackup.
|
|
|
219
245
|
* 2020-03-17 - dynamic chunk size
|
|
220
246
|
* 2020-03-17 - ignore *.sha512 by default
|
|
221
247
|
* 2020-03-17 - Update boot_pyhardlinkbackup.sh
|
|
248
|
+
|
|
249
|
+
<details><summary>Expand older history entries ...</summary>
|
|
250
|
+
|
|
222
251
|
* [v0.12.3](https://github.com/jedie/PyHardLinkBackup/compare/v0.12.2...v0.12.3)
|
|
223
252
|
* 2020-03-17 - update README.rst
|
|
224
253
|
* 2020-03-17 - don't publish if tests fail
|
|
@@ -228,9 +257,6 @@ v1 is a complete rewrite of PyHardLinkBackup.
|
|
|
228
257
|
* 2020-03-16 - just warn if used directly (needfull for devlopment to call this directly ;)
|
|
229
258
|
* 2020-03-16 - update requirements
|
|
230
259
|
* 2020-03-16 - +pytest-randomly
|
|
231
|
-
|
|
232
|
-
<details><summary>Expand older history entries ...</summary>
|
|
233
|
-
|
|
234
260
|
* [v0.12.2](https://github.com/jedie/PyHardLinkBackup/compare/v0.12.1...v0.12.2)
|
|
235
261
|
* 2020-03-06 - repare v0.12.2 release
|
|
236
262
|
* 2020-03-06 - enhance log file content
|
|
@@ -75,7 +75,7 @@ def backup_tree(*, src_root: Path, backup_root: Path, excludes: set[str]) -> Bac
|
|
|
75
75
|
phlb_conf_dir = backup_root / '.phlb'
|
|
76
76
|
phlb_conf_dir.mkdir(parents=False, exist_ok=True)
|
|
77
77
|
|
|
78
|
-
backup_dir = backup_root / src_root.name / datetime.now().strftime('%Y
|
|
78
|
+
backup_dir = backup_root / src_root.name / datetime.now().strftime('%Y-%m-%d-%H%M%S')
|
|
79
79
|
logger.info('Backup %s to %s', src_root, backup_dir)
|
|
80
80
|
backup_dir.mkdir(parents=True, exist_ok=False)
|
|
81
81
|
|
|
@@ -6,13 +6,13 @@ import textwrap
|
|
|
6
6
|
import zlib
|
|
7
7
|
from collections.abc import Iterable
|
|
8
8
|
from pathlib import Path
|
|
9
|
-
from unittest import TestCase
|
|
10
9
|
from unittest.mock import patch
|
|
11
10
|
|
|
12
11
|
from bx_py_utils.path import assert_is_file
|
|
13
12
|
from bx_py_utils.test_utils.assertion import assert_text_equal
|
|
14
13
|
from bx_py_utils.test_utils.datetime import parse_dt
|
|
15
14
|
from bx_py_utils.test_utils.log_utils import NoLogs
|
|
15
|
+
from bx_py_utils.test_utils.redirect import RedirectOut
|
|
16
16
|
from freezegun import freeze_time
|
|
17
17
|
from tabulate import tabulate
|
|
18
18
|
|
|
@@ -20,6 +20,7 @@ from PyHardLinkBackup.backup import BackupResult, backup_tree
|
|
|
20
20
|
from PyHardLinkBackup.constants import CHUNK_SIZE
|
|
21
21
|
from PyHardLinkBackup.utilities.file_size_database import FileSizeDatabase
|
|
22
22
|
from PyHardLinkBackup.utilities.filesystem import iter_scandir_files
|
|
23
|
+
from PyHardLinkBackup.utilities.tests.base_testcases import BaseTestCase
|
|
23
24
|
from PyHardLinkBackup.utilities.tests.test_file_hash_database import assert_hash_db_info
|
|
24
25
|
|
|
25
26
|
|
|
@@ -49,8 +50,9 @@ def set_file_times(path: Path, dt: datetime.datetime):
|
|
|
49
50
|
if dt.tzinfo is not None:
|
|
50
51
|
dt = dt.astimezone(datetime.timezone.utc).replace(tzinfo=None)
|
|
51
52
|
fixed_time = dt.timestamp()
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
with NoLogs(logger_name=''):
|
|
54
|
+
for entry in iter_scandir_files(path, excludes=set()):
|
|
55
|
+
os.utime(entry.path, (fixed_time, fixed_time))
|
|
54
56
|
|
|
55
57
|
|
|
56
58
|
def _fs_tree_overview(root: Path) -> str:
|
|
@@ -99,8 +101,7 @@ def _fs_tree_overview(root: Path) -> str:
|
|
|
99
101
|
|
|
100
102
|
def assert_fs_tree_overview(root: Path, expected_overview: str):
|
|
101
103
|
expected_overview = textwrap.dedent(expected_overview).strip()
|
|
102
|
-
|
|
103
|
-
actual_overview = _fs_tree_overview(root)
|
|
104
|
+
actual_overview = _fs_tree_overview(root)
|
|
104
105
|
assert_text_equal(
|
|
105
106
|
actual_overview,
|
|
106
107
|
expected_overview,
|
|
@@ -108,7 +109,7 @@ def assert_fs_tree_overview(root: Path, expected_overview: str):
|
|
|
108
109
|
)
|
|
109
110
|
|
|
110
111
|
|
|
111
|
-
class BackupTreeTestCase(
|
|
112
|
+
class BackupTreeTestCase(BaseTestCase):
|
|
112
113
|
def test_happy_path(self):
|
|
113
114
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
114
115
|
temp_path = Path(temp_dir)
|
|
@@ -156,16 +157,19 @@ class BackupTreeTestCase(TestCase):
|
|
|
156
157
|
self.assertLogs(level=logging.INFO),
|
|
157
158
|
patch('PyHardLinkBackup.backup.iter_scandir_files', SortedIterScandirFiles),
|
|
158
159
|
freeze_time('2026-01-01T12:34:56Z', auto_tick_seconds=0),
|
|
160
|
+
RedirectOut() as redirected_out,
|
|
159
161
|
):
|
|
160
162
|
result = backup_tree(
|
|
161
163
|
src_root=src_root,
|
|
162
164
|
backup_root=backup_root,
|
|
163
165
|
excludes={'.cache'},
|
|
164
166
|
)
|
|
167
|
+
self.assertEqual(redirected_out.stderr, '')
|
|
168
|
+
self.assertIn('Backup complete', redirected_out.stdout)
|
|
165
169
|
backup_dir = result.backup_dir
|
|
166
170
|
self.assertEqual(
|
|
167
171
|
str(Path(backup_dir).relative_to(temp_path)),
|
|
168
|
-
'backup/source/
|
|
172
|
+
'backup/source/2026-01-01-123456',
|
|
169
173
|
)
|
|
170
174
|
self.assertEqual(
|
|
171
175
|
result,
|
|
@@ -184,47 +188,50 @@ class BackupTreeTestCase(TestCase):
|
|
|
184
188
|
)
|
|
185
189
|
|
|
186
190
|
# The sources:
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
191
|
+
with self.assertLogs('PyHardLinkBackup', level=logging.DEBUG):
|
|
192
|
+
assert_fs_tree_overview(
|
|
193
|
+
root=src_root,
|
|
194
|
+
expected_overview="""
|
|
195
|
+
path birthtime type nlink size CRC32
|
|
196
|
+
.cache/tempfile.tmp 12:00:00 file 1 38 41d7a2c9
|
|
197
|
+
file2.txt 12:00:00 hardlink 2 14 8a11514a
|
|
198
|
+
hardlink2file1 12:00:00 hardlink 2 14 8a11514a
|
|
199
|
+
large_file.bin 12:00:00 file 1 67108865 9671eaac
|
|
200
|
+
min_sized_file1.bin 12:00:00 file 1 1000 f0d93de4
|
|
201
|
+
min_sized_file2.bin 12:00:00 file 1 1000 f0d93de4
|
|
202
|
+
subdir/file.txt 12:00:00 file 1 22 c0167e63
|
|
203
|
+
symlink2file1 12:00:00 symlink 2 14 8a11514a
|
|
204
|
+
""",
|
|
205
|
+
)
|
|
201
206
|
# The backup:
|
|
202
207
|
# * /.cache/ -> excluded
|
|
203
208
|
# * min_sized_file1.bin and min_sized_file2.bin -> hardlinked
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
209
|
+
with self.assertLogs('PyHardLinkBackup', level=logging.DEBUG):
|
|
210
|
+
assert_fs_tree_overview(
|
|
211
|
+
root=backup_dir,
|
|
212
|
+
expected_overview="""
|
|
213
|
+
path birthtime type nlink size CRC32
|
|
214
|
+
SHA256SUMS - file 1 410 45c07cf7
|
|
215
|
+
file2.txt 12:00:00 file 1 14 8a11514a
|
|
216
|
+
hardlink2file1 12:00:00 file 1 14 8a11514a
|
|
217
|
+
large_file.bin 12:00:00 file 1 67108865 9671eaac
|
|
218
|
+
min_sized_file1.bin 12:00:00 hardlink 2 1000 f0d93de4
|
|
219
|
+
min_sized_file2.bin 12:00:00 hardlink 2 1000 f0d93de4
|
|
220
|
+
subdir/SHA256SUMS - file 1 75 1af5ecc7
|
|
221
|
+
subdir/file.txt 12:00:00 file 1 22 c0167e63
|
|
222
|
+
symlink2file1 12:00:00 symlink 2 14 8a11514a
|
|
223
|
+
""",
|
|
224
|
+
)
|
|
219
225
|
|
|
220
226
|
# Let's check our FileHashDatabase:
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
227
|
+
with self.assertLogs('PyHardLinkBackup', level=logging.DEBUG):
|
|
228
|
+
assert_hash_db_info(
|
|
229
|
+
backup_root=backup_root,
|
|
230
|
+
expected="""
|
|
231
|
+
bb/c4/bbc4de2ca238d1… -> source/2026-01-01-123456/min_sized_file1.bin
|
|
232
|
+
e6/37/e6374ac11d9049… -> source/2026-01-01-123456/large_file.bin
|
|
233
|
+
""",
|
|
234
|
+
)
|
|
228
235
|
|
|
229
236
|
#######################################################################################
|
|
230
237
|
# Just backup again:
|
|
@@ -233,16 +240,19 @@ class BackupTreeTestCase(TestCase):
|
|
|
233
240
|
self.assertLogs(level=logging.INFO),
|
|
234
241
|
patch('PyHardLinkBackup.backup.iter_scandir_files', SortedIterScandirFiles),
|
|
235
242
|
freeze_time('2026-01-02T12:34:56Z', auto_tick_seconds=0),
|
|
243
|
+
RedirectOut() as redirected_out,
|
|
236
244
|
):
|
|
237
245
|
result = backup_tree(
|
|
238
246
|
src_root=src_root,
|
|
239
247
|
backup_root=backup_root,
|
|
240
248
|
excludes={'.cache'},
|
|
241
249
|
)
|
|
250
|
+
self.assertEqual(redirected_out.stderr, '')
|
|
251
|
+
self.assertIn('Backup complete', redirected_out.stdout)
|
|
242
252
|
backup_dir = result.backup_dir
|
|
243
253
|
self.assertEqual(
|
|
244
254
|
str(Path(backup_dir).relative_to(temp_path)),
|
|
245
|
-
'backup/source/
|
|
255
|
+
'backup/source/2026-01-02-123456',
|
|
246
256
|
)
|
|
247
257
|
self.assertEqual(
|
|
248
258
|
result,
|
|
@@ -262,30 +272,32 @@ class BackupTreeTestCase(TestCase):
|
|
|
262
272
|
# The second backup:
|
|
263
273
|
# * /.cache/ -> excluded
|
|
264
274
|
# * min_sized_file1.bin and min_sized_file2.bin -> hardlinked
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
275
|
+
with self.assertLogs('PyHardLinkBackup', level=logging.DEBUG):
|
|
276
|
+
assert_fs_tree_overview(
|
|
277
|
+
root=backup_dir,
|
|
278
|
+
expected_overview="""
|
|
279
|
+
path birthtime type nlink size CRC32
|
|
280
|
+
SHA256SUMS - file 1 410 45c07cf7
|
|
281
|
+
file2.txt 12:00:00 file 1 14 8a11514a
|
|
282
|
+
hardlink2file1 12:00:00 file 1 14 8a11514a
|
|
283
|
+
large_file.bin 12:00:00 hardlink 2 67108865 9671eaac
|
|
284
|
+
min_sized_file1.bin 12:00:00 hardlink 4 1000 f0d93de4
|
|
285
|
+
min_sized_file2.bin 12:00:00 hardlink 4 1000 f0d93de4
|
|
286
|
+
subdir/SHA256SUMS - file 1 75 1af5ecc7
|
|
287
|
+
subdir/file.txt 12:00:00 file 1 22 c0167e63
|
|
288
|
+
symlink2file1 12:00:00 symlink 2 14 8a11514a
|
|
289
|
+
""",
|
|
290
|
+
)
|
|
280
291
|
|
|
281
292
|
# The FileHashDatabase remains the same:
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
293
|
+
with self.assertLogs('PyHardLinkBackup', level=logging.DEBUG):
|
|
294
|
+
assert_hash_db_info(
|
|
295
|
+
backup_root=backup_root,
|
|
296
|
+
expected="""
|
|
297
|
+
bb/c4/bbc4de2ca238d1… -> source/2026-01-01-123456/min_sized_file1.bin
|
|
298
|
+
e6/37/e6374ac11d9049… -> source/2026-01-01-123456/large_file.bin
|
|
299
|
+
""",
|
|
300
|
+
)
|
|
289
301
|
|
|
290
302
|
#######################################################################################
|
|
291
303
|
# Don't create broken hardlinks!
|
|
@@ -296,7 +308,7 @@ class BackupTreeTestCase(TestCase):
|
|
|
296
308
|
"""
|
|
297
309
|
|
|
298
310
|
# Let's remove one of the files used for hardlinking from the first backup:
|
|
299
|
-
min_sized_file1_bak_path = backup_root / 'source/
|
|
311
|
+
min_sized_file1_bak_path = backup_root / 'source/2026-01-01-123456/min_sized_file1.bin'
|
|
300
312
|
assert_is_file(min_sized_file1_bak_path)
|
|
301
313
|
min_sized_file1_bak_path.unlink()
|
|
302
314
|
|
|
@@ -305,31 +317,35 @@ class BackupTreeTestCase(TestCase):
|
|
|
305
317
|
self.assertLogs(level=logging.INFO),
|
|
306
318
|
patch('PyHardLinkBackup.backup.iter_scandir_files', SortedIterScandirFiles),
|
|
307
319
|
freeze_time('2026-01-03T12:34:56Z', auto_tick_seconds=0),
|
|
320
|
+
RedirectOut() as redirected_out,
|
|
308
321
|
):
|
|
309
322
|
result = backup_tree(
|
|
310
323
|
src_root=src_root,
|
|
311
324
|
backup_root=backup_root,
|
|
312
325
|
excludes={'.cache'},
|
|
313
326
|
)
|
|
327
|
+
self.assertEqual(redirected_out.stderr, '')
|
|
328
|
+
self.assertIn('Backup complete', redirected_out.stdout)
|
|
314
329
|
backup_dir = result.backup_dir
|
|
315
330
|
|
|
316
331
|
# Note: min_sized_file1.bin and min_sized_file2.bin are hardlinked,
|
|
317
332
|
# but not with the first backup anymore! So it's only nlink=2 now!
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
+
with self.assertLogs('PyHardLinkBackup', level=logging.DEBUG):
|
|
334
|
+
assert_fs_tree_overview(
|
|
335
|
+
root=backup_dir,
|
|
336
|
+
expected_overview="""
|
|
337
|
+
path birthtime type nlink size CRC32
|
|
338
|
+
SHA256SUMS - file 1 410 45c07cf7
|
|
339
|
+
file2.txt 12:00:00 file 1 14 8a11514a
|
|
340
|
+
hardlink2file1 12:00:00 file 1 14 8a11514a
|
|
341
|
+
large_file.bin 12:00:00 hardlink 3 67108865 9671eaac
|
|
342
|
+
min_sized_file1.bin 12:00:00 hardlink 2 1000 f0d93de4
|
|
343
|
+
min_sized_file2.bin 12:00:00 hardlink 2 1000 f0d93de4
|
|
344
|
+
subdir/SHA256SUMS - file 1 75 1af5ecc7
|
|
345
|
+
subdir/file.txt 12:00:00 file 1 22 c0167e63
|
|
346
|
+
symlink2file1 12:00:00 symlink 2 14 8a11514a
|
|
347
|
+
""",
|
|
348
|
+
)
|
|
333
349
|
|
|
334
350
|
self.assertEqual(
|
|
335
351
|
result,
|
|
@@ -348,14 +364,15 @@ class BackupTreeTestCase(TestCase):
|
|
|
348
364
|
)
|
|
349
365
|
|
|
350
366
|
# Note: min_sized_file1.bin is now from the 2026-01-03 backup!
|
|
351
|
-
self.assertEqual(backup_dir.name, '
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
367
|
+
self.assertEqual(backup_dir.name, '2026-01-03-123456') # Latest backup dir name
|
|
368
|
+
with self.assertLogs('PyHardLinkBackup', level=logging.DEBUG):
|
|
369
|
+
assert_hash_db_info(
|
|
370
|
+
backup_root=backup_root,
|
|
371
|
+
expected="""
|
|
372
|
+
bb/c4/bbc4de2ca238d1… -> source/2026-01-03-123456/min_sized_file1.bin
|
|
373
|
+
e6/37/e6374ac11d9049… -> source/2026-01-01-123456/large_file.bin
|
|
374
|
+
""",
|
|
375
|
+
)
|
|
359
376
|
|
|
360
377
|
def test_symlink(self):
|
|
361
378
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
@@ -392,30 +409,37 @@ class BackupTreeTestCase(TestCase):
|
|
|
392
409
|
#######################################################################################
|
|
393
410
|
# Create first backup:
|
|
394
411
|
|
|
395
|
-
with
|
|
412
|
+
with (
|
|
413
|
+
self.assertLogs(level=logging.INFO),
|
|
414
|
+
freeze_time('2026-01-01T12:34:56Z', auto_tick_seconds=0),
|
|
415
|
+
RedirectOut() as redirected_out,
|
|
416
|
+
):
|
|
396
417
|
result = backup_tree(src_root=src_root, backup_root=backup_root, excludes=set())
|
|
418
|
+
self.assertEqual(redirected_out.stderr, '')
|
|
419
|
+
self.assertIn('Backup complete', redirected_out.stdout)
|
|
397
420
|
backup_dir1 = result.backup_dir
|
|
398
421
|
self.assertEqual(
|
|
399
422
|
str(Path(backup_dir1).relative_to(temp_path)),
|
|
400
|
-
'bak/src/
|
|
423
|
+
'bak/src/2026-01-01-123456',
|
|
401
424
|
)
|
|
402
425
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
426
|
+
with self.assertLogs('PyHardLinkBackup', level=logging.DEBUG):
|
|
427
|
+
assert_fs_tree_overview(
|
|
428
|
+
root=temp_path, # The complete overview os source + backup and outside file
|
|
429
|
+
expected_overview="""
|
|
430
|
+
path birthtime type nlink size CRC32
|
|
431
|
+
bak/src/2026-01-01-123456/SHA256SUMS - file 1 82 c03fd60e
|
|
432
|
+
bak/src/2026-01-01-123456/broken_symlink - symlink - - -
|
|
433
|
+
bak/src/2026-01-01-123456/source_file.txt 12:00:00 file 1 31 9309a10c
|
|
434
|
+
bak/src/2026-01-01-123456/symlink2outside 12:00:00 symlink 1 36 24b5bf4c
|
|
435
|
+
bak/src/2026-01-01-123456/symlink2source 12:00:00 symlink 1 31 9309a10c
|
|
436
|
+
outside_file.txt 12:00:00 file 1 36 24b5bf4c
|
|
437
|
+
src/broken_symlink - symlink - - -
|
|
438
|
+
src/source_file.txt 12:00:00 file 1 31 9309a10c
|
|
439
|
+
src/symlink2outside 12:00:00 symlink 1 36 24b5bf4c
|
|
440
|
+
src/symlink2source 12:00:00 symlink 1 31 9309a10c
|
|
441
|
+
""",
|
|
442
|
+
)
|
|
419
443
|
|
|
420
444
|
self.assertEqual(
|
|
421
445
|
result,
|
|
@@ -448,4 +472,5 @@ class BackupTreeTestCase(TestCase):
|
|
|
448
472
|
|
|
449
473
|
"""DocWrite: README.md ## backup implementation - Symlinks
|
|
450
474
|
Symlinks are not stored in our FileHashDatabase, because they are not considered for hardlinking."""
|
|
451
|
-
|
|
475
|
+
with self.assertLogs('PyHardLinkBackup', level=logging.DEBUG):
|
|
476
|
+
assert_hash_db_info(backup_root=backup_root, expected='')
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import logging
|
|
3
|
+
import unittest
|
|
4
|
+
|
|
5
|
+
from bx_py_utils.test_utils.context_managers import MassContextManager
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RaiseLogOutput(logging.Handler):
|
|
9
|
+
LOGGING_FORMAT = '%(levelname)s:%(name)s:%(message)s'
|
|
10
|
+
|
|
11
|
+
def __init__(self):
|
|
12
|
+
super().__init__()
|
|
13
|
+
self.setFormatter(logging.Formatter(self.LOGGING_FORMAT))
|
|
14
|
+
|
|
15
|
+
def emit(self, record):
|
|
16
|
+
raise AssertionError(
|
|
17
|
+
f'Uncaptured log message during the test:\n'
|
|
18
|
+
'------------------------------------------------------------------------------------\n'
|
|
19
|
+
f'{self.format(record)}\n'
|
|
20
|
+
'------------------------------------------------------------------------------------\n'
|
|
21
|
+
'(Hint: use self.assertLogs() context manager)'
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LoggingMustBeCapturedTestCaseMixin:
|
|
26
|
+
def setUp(self):
|
|
27
|
+
super().setUp()
|
|
28
|
+
self.logger = logging.getLogger()
|
|
29
|
+
|
|
30
|
+
self.old_handlers = self.logger.handlers[:]
|
|
31
|
+
self.old_level = self.logger.level
|
|
32
|
+
self.old_propagate = self.logger.propagate
|
|
33
|
+
|
|
34
|
+
self._log_buffer_handler = RaiseLogOutput()
|
|
35
|
+
self.logger.addHandler(self._log_buffer_handler)
|
|
36
|
+
self.logger.setLevel(logging.DEBUG)
|
|
37
|
+
self.logger.propagate = False
|
|
38
|
+
|
|
39
|
+
def tearDown(self):
|
|
40
|
+
self.logger.handlers = self.old_handlers
|
|
41
|
+
self.logger.propagate = self.old_propagate
|
|
42
|
+
self.logger.setLevel(self.old_level)
|
|
43
|
+
super().tearDown()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RaiseOutput(MassContextManager):
|
|
47
|
+
def __init__(self, test_case: unittest.TestCase):
|
|
48
|
+
self.test_case = test_case
|
|
49
|
+
self.mocks = (
|
|
50
|
+
contextlib.redirect_stdout(self),
|
|
51
|
+
contextlib.redirect_stderr(self),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def write(self, txt):
|
|
55
|
+
self.test_case.fail(
|
|
56
|
+
f'Output was written during the test:\n'
|
|
57
|
+
'------------------------------------------------------------------------------------\n'
|
|
58
|
+
f'{txt!r}\n'
|
|
59
|
+
'------------------------------------------------------------------------------------\n'
|
|
60
|
+
f'(Hint: use RedirectOut context manager)'
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def flush(self):
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
def getvalue(self):
|
|
67
|
+
return ''
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class OutputMustCapturedTestCaseMixin:
|
|
71
|
+
def setUp(self):
|
|
72
|
+
super().setUp()
|
|
73
|
+
self._cm = RaiseOutput(self)
|
|
74
|
+
self._cm_result = self._cm.__enter__()
|
|
75
|
+
|
|
76
|
+
def tearDown(self):
|
|
77
|
+
self._cm.__exit__(None, None, None)
|
|
78
|
+
super().tearDown()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class BaseTestCase(
|
|
82
|
+
OutputMustCapturedTestCaseMixin,
|
|
83
|
+
LoggingMustBeCapturedTestCaseMixin,
|
|
84
|
+
unittest.TestCase,
|
|
85
|
+
):
|
|
86
|
+
"""
|
|
87
|
+
A base TestCase that ensures that all logging and output is captured during tests.
|
|
88
|
+
"""
|
|
@@ -2,7 +2,6 @@ import logging
|
|
|
2
2
|
import tempfile
|
|
3
3
|
import textwrap
|
|
4
4
|
from pathlib import Path
|
|
5
|
-
from unittest import TestCase
|
|
6
5
|
|
|
7
6
|
from bx_py_utils.path import assert_is_dir
|
|
8
7
|
from bx_py_utils.test_utils.assertion import assert_text_equal
|
|
@@ -10,6 +9,7 @@ from bx_py_utils.test_utils.log_utils import NoLogs
|
|
|
10
9
|
|
|
11
10
|
from PyHardLinkBackup.utilities.file_hash_database import FileHashDatabase, HashAlreadyExistsError
|
|
12
11
|
from PyHardLinkBackup.utilities.filesystem import iter_scandir_files
|
|
12
|
+
from PyHardLinkBackup.utilities.tests.base_testcases import BaseTestCase
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
class TemporaryFileHashDatabase(tempfile.TemporaryDirectory):
|
|
@@ -25,6 +25,7 @@ class TemporaryFileHashDatabase(tempfile.TemporaryDirectory):
|
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
def get_hash_db_filenames(hash_db: FileHashDatabase) -> list[str]:
|
|
28
|
+
# with NoLogs('PyHardLinkBackup.utilities.filesystem'):
|
|
28
29
|
return sorted(
|
|
29
30
|
str(Path(entry.path).relative_to(hash_db.base_path))
|
|
30
31
|
for entry in iter_scandir_files(hash_db.base_path, excludes=set())
|
|
@@ -35,19 +36,19 @@ def get_hash_db_info(backup_root: Path) -> str:
|
|
|
35
36
|
db_base_path = backup_root / '.phlb' / 'hash-lookup'
|
|
36
37
|
assert_is_dir(db_base_path)
|
|
37
38
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
39
|
+
with NoLogs(logger_name='XY'):
|
|
40
|
+
lines = []
|
|
41
|
+
for entry in iter_scandir_files(db_base_path, excludes=set()):
|
|
42
|
+
hash_path = Path(entry.path)
|
|
43
|
+
rel_path = hash_path.relative_to(db_base_path)
|
|
44
|
+
rel_file_path = hash_path.read_text()
|
|
45
|
+
lines.append(f'{str(rel_path)[:20]}… -> {rel_file_path}')
|
|
44
46
|
return '\n'.join(sorted(lines))
|
|
45
47
|
|
|
46
48
|
|
|
47
49
|
def assert_hash_db_info(backup_root: Path, expected: str):
|
|
48
50
|
expected = textwrap.dedent(expected).strip()
|
|
49
|
-
|
|
50
|
-
actual = get_hash_db_info(backup_root)
|
|
51
|
+
actual = get_hash_db_info(backup_root)
|
|
51
52
|
assert_text_equal(
|
|
52
53
|
actual,
|
|
53
54
|
expected,
|
|
@@ -55,7 +56,7 @@ def assert_hash_db_info(backup_root: Path, expected: str):
|
|
|
55
56
|
)
|
|
56
57
|
|
|
57
58
|
|
|
58
|
-
class FileHashDatabaseTestCase(
|
|
59
|
+
class FileHashDatabaseTestCase(BaseTestCase):
|
|
59
60
|
def test_happy_path(self):
|
|
60
61
|
with TemporaryFileHashDatabase() as hash_db:
|
|
61
62
|
self.assertIsInstance(hash_db, FileHashDatabase)
|
|
@@ -73,10 +74,11 @@ class FileHashDatabaseTestCase(TestCase):
|
|
|
73
74
|
self.assertIs(hash_db.get('12345678abcdef'), None)
|
|
74
75
|
hash_db['12345678abcdef'] = file_a_path
|
|
75
76
|
self.assertEqual(hash_db.get('12345678abcdef'), file_a_path)
|
|
76
|
-
self.
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
with self.assertLogs('PyHardLinkBackup', level=logging.DEBUG):
|
|
78
|
+
self.assertEqual(
|
|
79
|
+
get_hash_db_filenames(hash_db),
|
|
80
|
+
['12/34/12345678abcdef'],
|
|
81
|
+
)
|
|
80
82
|
|
|
81
83
|
########################################################################################
|
|
82
84
|
# Another instance using the same directory:
|
|
@@ -94,21 +96,23 @@ class FileHashDatabaseTestCase(TestCase):
|
|
|
94
96
|
|
|
95
97
|
another_hash_db['12abcd345678abcdef'] = file_b_path
|
|
96
98
|
self.assertEqual(another_hash_db.get('12abcd345678abcdef'), file_b_path)
|
|
97
|
-
self.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
99
|
+
with self.assertLogs('PyHardLinkBackup', level=logging.DEBUG):
|
|
100
|
+
self.assertEqual(
|
|
101
|
+
get_hash_db_filenames(another_hash_db),
|
|
102
|
+
[
|
|
103
|
+
'12/34/12345678abcdef',
|
|
104
|
+
'12/ab/12abcd345678abcdef',
|
|
105
|
+
],
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
with self.assertLogs('PyHardLinkBackup', level=logging.DEBUG):
|
|
109
|
+
assert_hash_db_info(
|
|
110
|
+
backup_root=hash_db.backup_root,
|
|
111
|
+
expected="""
|
|
112
|
+
12/34/12345678abcdef… -> rel/path/to/file-A
|
|
113
|
+
12/ab/12abcd345678ab… -> rel/path/to/file-B
|
|
114
|
+
""",
|
|
115
|
+
)
|
|
112
116
|
|
|
113
117
|
########################################################################################
|
|
114
118
|
# Deny "overwrite" of existing hash:
|
|
@@ -128,9 +132,10 @@ class FileHashDatabaseTestCase(TestCase):
|
|
|
128
132
|
with self.assertLogs(level=logging.WARNING) as logs:
|
|
129
133
|
self.assertIs(hash_db.get('12345678abcdef'), None)
|
|
130
134
|
self.assertIn('Hash database entry found, but file does not exist', ''.join(logs.output))
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
135
|
+
with self.assertLogs('PyHardLinkBackup', level=logging.DEBUG):
|
|
136
|
+
assert_hash_db_info(
|
|
137
|
+
backup_root=hash_db.backup_root,
|
|
138
|
+
expected="""
|
|
139
|
+
12/ab/12abcd345678ab… -> rel/path/to/file-B
|
|
140
|
+
""",
|
|
141
|
+
)
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
import logging
|
|
1
2
|
import tempfile
|
|
2
3
|
from collections.abc import Iterable
|
|
3
4
|
from pathlib import Path
|
|
4
|
-
|
|
5
|
+
|
|
6
|
+
from bx_py_utils.test_utils.log_utils import NoLogs
|
|
5
7
|
|
|
6
8
|
from PyHardLinkBackup.utilities.file_size_database import FileSizeDatabase
|
|
7
9
|
from PyHardLinkBackup.utilities.filesystem import iter_scandir_files
|
|
10
|
+
from PyHardLinkBackup.utilities.tests.base_testcases import BaseTestCase
|
|
8
11
|
|
|
9
12
|
|
|
10
13
|
class TemporaryFileSizeDatabase(tempfile.TemporaryDirectory):
|
|
@@ -27,10 +30,11 @@ def get_size_db_filenames(size_db: FileSizeDatabase) -> Iterable[str]:
|
|
|
27
30
|
|
|
28
31
|
|
|
29
32
|
def get_sizes(size_db: FileSizeDatabase) -> Iterable[int]:
|
|
30
|
-
|
|
33
|
+
with NoLogs('PyHardLinkBackup.utilities.filesystem'):
|
|
34
|
+
return sorted(int(entry.name) for entry in iter_scandir_files(size_db.base_path, excludes=set()))
|
|
31
35
|
|
|
32
36
|
|
|
33
|
-
class FileSizeDatabaseTestCase(
|
|
37
|
+
class FileSizeDatabaseTestCase(BaseTestCase):
|
|
34
38
|
def test_happy_path(self):
|
|
35
39
|
with TemporaryFileSizeDatabase() as size_db:
|
|
36
40
|
self.assertIsInstance(size_db, FileSizeDatabase)
|
|
@@ -52,27 +56,29 @@ class FileSizeDatabaseTestCase(TestCase):
|
|
|
52
56
|
self.assertIn(1234, size_db)
|
|
53
57
|
self.assertIn(567890, size_db)
|
|
54
58
|
|
|
55
|
-
self.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
59
|
+
with self.assertLogs('PyHardLinkBackup', level=logging.DEBUG):
|
|
60
|
+
self.assertEqual(get_sizes(size_db), [1234, 567890])
|
|
61
|
+
self.assertEqual(
|
|
62
|
+
get_size_db_filenames(size_db),
|
|
63
|
+
[
|
|
64
|
+
'12/34/1234',
|
|
65
|
+
'56/78/567890',
|
|
66
|
+
],
|
|
67
|
+
)
|
|
63
68
|
|
|
64
69
|
########################################################################################
|
|
65
70
|
# Another instance using the same directory:
|
|
66
71
|
|
|
67
72
|
another_size_db = FileSizeDatabase(phlb_conf_dir=size_db.base_path.parent)
|
|
68
|
-
self.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
73
|
+
with self.assertLogs('PyHardLinkBackup', level=logging.DEBUG):
|
|
74
|
+
self.assertEqual(get_sizes(another_size_db), [1234, 567890])
|
|
75
|
+
self.assertEqual(
|
|
76
|
+
get_size_db_filenames(another_size_db),
|
|
77
|
+
[
|
|
78
|
+
'12/34/1234',
|
|
79
|
+
'56/78/567890',
|
|
80
|
+
],
|
|
81
|
+
)
|
|
76
82
|
|
|
77
83
|
########################################################################################
|
|
78
84
|
# "Share" directories:
|
|
@@ -107,25 +113,26 @@ class FileSizeDatabaseTestCase(TestCase):
|
|
|
107
113
|
########################################################################################
|
|
108
114
|
# Check final state:
|
|
109
115
|
|
|
110
|
-
self.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
116
|
+
with self.assertLogs('PyHardLinkBackup', level=logging.DEBUG):
|
|
117
|
+
self.assertEqual(
|
|
118
|
+
get_size_db_filenames(size_db),
|
|
119
|
+
[
|
|
120
|
+
'12/34/1234',
|
|
121
|
+
'12/34/123400001111',
|
|
122
|
+
'12/34/123400002222',
|
|
123
|
+
'12/88/128800003333',
|
|
124
|
+
'12/99/129900004444',
|
|
125
|
+
'56/78/567890',
|
|
126
|
+
],
|
|
127
|
+
)
|
|
128
|
+
self.assertEqual(
|
|
129
|
+
get_sizes(size_db),
|
|
130
|
+
[
|
|
131
|
+
1234,
|
|
132
|
+
567890,
|
|
133
|
+
123400001111,
|
|
134
|
+
123400002222,
|
|
135
|
+
128800003333,
|
|
136
|
+
129900004444,
|
|
137
|
+
],
|
|
138
|
+
)
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import hashlib
|
|
2
2
|
import os
|
|
3
3
|
import tempfile
|
|
4
|
-
import unittest
|
|
5
4
|
from pathlib import Path
|
|
6
5
|
|
|
7
6
|
from PyHardLinkBackup.constants import HASH_ALGO
|
|
8
7
|
from PyHardLinkBackup.utilities.filesystem import copy_and_hash, hash_file, iter_scandir_files, read_and_hash_file
|
|
8
|
+
from PyHardLinkBackup.utilities.tests.base_testcases import BaseTestCase
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
class TestHashFile(
|
|
11
|
+
class TestHashFile(BaseTestCase):
|
|
12
12
|
def test_hash_file(self):
|
|
13
13
|
self.assertEqual(
|
|
14
14
|
hashlib.new(HASH_ALGO, b'test content').hexdigest(),
|
|
@@ -1,18 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: PyHardLinkBackup
|
|
3
|
-
Version: 1.0.1
|
|
4
|
-
Summary: HardLink/Deduplication Backups with Python
|
|
5
|
-
Project-URL: Documentation, https://github.com/jedie/PyHardLinkBackup
|
|
6
|
-
Project-URL: Source, https://github.com/jedie/PyHardLinkBackup
|
|
7
|
-
Author-email: Jens Diemer <PyHardLinkBackup@jensdiemer.de>
|
|
8
|
-
License: GPL-3.0-or-later
|
|
9
|
-
Requires-Python: >=3.12
|
|
10
|
-
Requires-Dist: bx-py-utils
|
|
11
|
-
Requires-Dist: cli-base-utilities
|
|
12
|
-
Requires-Dist: rich
|
|
13
|
-
Requires-Dist: tyro
|
|
14
|
-
Description-Content-Type: text/markdown
|
|
15
|
-
|
|
16
1
|
# PyHardLinkBackup
|
|
17
2
|
|
|
18
3
|
[](https://github.com/jedie/PyHardLinkBackup/actions/workflows/tests.yml)
|
|
@@ -200,10 +185,21 @@ usage: ./dev-cli.py [-h] {benchmark-hashes,coverage,install,lint,mypy,nox,pip-au
|
|
|
200
185
|
|
|
201
186
|
v1 is a complete rewrite of PyHardLinkBackup.
|
|
202
187
|
|
|
188
|
+
Overview of main changes:
|
|
189
|
+
|
|
190
|
+
* Remove Django dependency:
|
|
191
|
+
* No SQlite database anymore -> Data for deduplication stored in filesystem only
|
|
192
|
+
* No Django Admin, because we have no database anymore ;)
|
|
193
|
+
* Change hash algorithm from SHA512 to SHA256, because it's faster and still secure enough
|
|
194
|
+
* Don't store `*.sha512` for every file anymore -> We store one `SHA256SUMS` file in every backup directory
|
|
195
|
+
|
|
203
196
|
## History
|
|
204
197
|
|
|
205
198
|
[comment]: <> (✂✂✂ auto generated history start ✂✂✂)
|
|
206
199
|
|
|
200
|
+
* [v1.1.0](https://github.com/jedie/PyHardLinkBackup/compare/v1.0.1...v1.1.0)
|
|
201
|
+
* 2026-01-14 - Change backup timestamp directory to old schema: '%Y-%m-%d-%H%M%S'
|
|
202
|
+
* 2026-01-14 - Add "Overview of main changes" to README
|
|
207
203
|
* [v1.0.1](https://github.com/jedie/PyHardLinkBackup/compare/v1.0.0...v1.0.1)
|
|
208
204
|
* 2026-01-13 - Store SHA256SUMS files in backup directories
|
|
209
205
|
* [v1.0.0](https://github.com/jedie/PyHardLinkBackup/compare/v0.13.0...v1.0.0)
|
|
@@ -234,6 +230,9 @@ v1 is a complete rewrite of PyHardLinkBackup.
|
|
|
234
230
|
* 2020-03-17 - dynamic chunk size
|
|
235
231
|
* 2020-03-17 - ignore *.sha512 by default
|
|
236
232
|
* 2020-03-17 - Update boot_pyhardlinkbackup.sh
|
|
233
|
+
|
|
234
|
+
<details><summary>Expand older history entries ...</summary>
|
|
235
|
+
|
|
237
236
|
* [v0.12.3](https://github.com/jedie/PyHardLinkBackup/compare/v0.12.2...v0.12.3)
|
|
238
237
|
* 2020-03-17 - update README.rst
|
|
239
238
|
* 2020-03-17 - don't publish if tests fail
|
|
@@ -243,9 +242,6 @@ v1 is a complete rewrite of PyHardLinkBackup.
|
|
|
243
242
|
* 2020-03-16 - just warn if used directly (needfull for devlopment to call this directly ;)
|
|
244
243
|
* 2020-03-16 - update requirements
|
|
245
244
|
* 2020-03-16 - +pytest-randomly
|
|
246
|
-
|
|
247
|
-
<details><summary>Expand older history entries ...</summary>
|
|
248
|
-
|
|
249
245
|
* [v0.12.2](https://github.com/jedie/PyHardLinkBackup/compare/v0.12.1...v0.12.2)
|
|
250
246
|
* 2020-03-06 - repare v0.12.2 release
|
|
251
247
|
* 2020-03-06 - enhance log file content
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/PyHardLinkBackup/cli_dev/shell_completion.py
RENAMED
|
File without changes
|
|
File without changes
|
{pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/PyHardLinkBackup/cli_dev/update_readme_history.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/PyHardLinkBackup/tests/test_project_setup.py
RENAMED
|
File without changes
|
|
File without changes
|
{pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/PyHardLinkBackup/tests/test_readme_history.py
RENAMED
|
File without changes
|
|
File without changes
|
{pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/PyHardLinkBackup/utilities/file_hash_database.py
RENAMED
|
File without changes
|
{pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/PyHardLinkBackup/utilities/file_size_database.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pyhardlinkbackup-1.0.1 → pyhardlinkbackup-1.1.0}/PyHardLinkBackup/utilities/tests/__init__.py
RENAMED
|
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
|